feat(mcp): add resource group scopes with permission inheritance
This commit is contained in:
31 files changed
+2398
-1047
No files matched your search
@@ -7,13 +7,13 @@ type StepId = "capabilities" | "connections" | "databases" | "overrides";
|
||||
const { t } = useI18n();
|
||||
|
||||
const steps = computed<Array<{ id: StepId; title: string; description: string }>>(() => [
|
||||
{ id: "overrides", title: t("settings.mcpAuthStepOverridesTitle"), description: t("settings.mcpAuthStepOverridesDescription") },
|
||||
{ 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 currentStep = ref<StepId>("overrides");
|
||||
const currentIndex = computed(() => steps.value.findIndex((step) => step.id === currentStep.value));
|
||||
|
||||
function scrollableAncestor(target: EventTarget | null): HTMLElement | null {
|
||||
@@ -56,12 +56,12 @@ function nextStep(event: Event) {
|
||||
<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')">
|
||||
<nav class="flex items-stretch gap-1 overflow-x-auto rounded-md border bg-muted/50 p-1 sm:grid 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="group flex min-h-16 min-w-48 flex-none items-start 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 sm:min-w-0"
|
||||
: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)"
|
||||
@@ -71,16 +71,16 @@ function nextStep(event: Event) {
|
||||
: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 class="min-w-0 flex-1">
|
||||
<span class="block whitespace-normal text-xs font-medium leading-5">{{ step.title }}</span>
|
||||
<span class="mt-0.5 block whitespace-normal text-[10px] leading-4 text-muted-foreground">{{ 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 === 'overrides' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'overrides'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 1, title: t('settings.mcpAuthStepOverridesTitle') })"><slot name="overrides" /></div>
|
||||
<div :class="currentStep === 'connections' ? 'block' : 'hidden'" :aria-hidden="currentStep !== 'connections'" role="region" :aria-label="t('settings.mcpAuthStepRegionLabel', { step: 2, 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: 3, title: t('settings.mcpAuthStepDatabasesTitle') })"><slot name="databases" /></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">
|
||||
|
||||
@@ -10,19 +10,17 @@ import type { McpConnectionPolicy } from "@/stores/settingsStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
type DatabaseScope = McpConnectionPolicy["databaseScope"];
|
||||
type DatabaseExecutionMode = "inherit" | "read_only" | "safe_write" | "high_risk_write";
|
||||
type ExecutionMode = Exclude<DatabaseExecutionMode, "inherit">;
|
||||
|
||||
type ExecutionMode = "read_only" | "safe_write" | "high_risk_write";
|
||||
const { t } = useI18n();
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [6, 10, 20] as const;
|
||||
const DATABASE_SCOPES: DatabaseScope[] = ["all", "selected", "none"];
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
connections: readonly ConnectionConfig[];
|
||||
allowedConnectionIds: readonly string[] | null;
|
||||
connectionPolicies: readonly McpConnectionPolicy[];
|
||||
globalExecutionMode: ExecutionMode;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
@@ -31,6 +29,7 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:connectionPolicies": [value: McpConnectionPolicy[]];
|
||||
"set:database-policy": [connectionId: string, databaseName: string, mode: ExecutionMode | "inherit"];
|
||||
}>();
|
||||
|
||||
const selectedConnectionId = ref("");
|
||||
@@ -92,13 +91,13 @@ function policyFor(connectionId: string): McpConnectionPolicy {
|
||||
function updateSelectedPolicy(patch: Partial<McpConnectionPolicy>) {
|
||||
if (props.disabled || !selectedConnectionId.value) return;
|
||||
const existing = policyFor(selectedConnectionId.value);
|
||||
const migration = existing.executionModePolicyVersion === 1 ? {} : { ...promoteLegacyConnectionDefault(existing), executionModePolicyVersion: 1 as const };
|
||||
const next = { ...existing, ...migration, ...patch };
|
||||
const next = { ...existing, ...patch };
|
||||
const policies = props.connectionPolicies.filter((policy) => policy.connectionId !== selectedConnectionId.value);
|
||||
emit("update:connectionPolicies", [...policies, next]);
|
||||
}
|
||||
|
||||
function setScope(scope: DatabaseScope) {
|
||||
if (scope !== "selected" && selectedDatabasePolicies.value.length > 0 && !window.confirm(t("settings.mcpDatabaseScopeClearsPermissionExceptions"))) return;
|
||||
updateSelectedPolicy({
|
||||
databaseScope: scope,
|
||||
allowedDatabases: scope === "selected" ? selectedDatabases.value : [],
|
||||
@@ -106,6 +105,17 @@ function setScope(scope: DatabaseScope) {
|
||||
});
|
||||
}
|
||||
|
||||
function onScopeKeydown(event: KeyboardEvent, scope: DatabaseScope) {
|
||||
if (props.disabled || props.busy || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const currentIndex = DATABASE_SCOPES.indexOf(scope);
|
||||
const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? DATABASE_SCOPES.length - 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (currentIndex - 1 + DATABASE_SCOPES.length) % DATABASE_SCOPES.length : (currentIndex + 1) % DATABASE_SCOPES.length;
|
||||
const nextScope = DATABASE_SCOPES[nextIndex];
|
||||
const group = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest<HTMLElement>('[role="radiogroup"]') : null;
|
||||
group?.querySelector<HTMLElement>(`[data-database-scope="${nextScope}"]`)?.focus();
|
||||
setScope(nextScope);
|
||||
}
|
||||
|
||||
function setConnectionPage(page: number) {
|
||||
connectionPage.value = Math.min(Math.max(1, page), connectionPageCount.value);
|
||||
}
|
||||
@@ -122,6 +132,7 @@ function selectConnection(connectionId: string) {
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string, checked: boolean) {
|
||||
if (!checked && selectedDatabasePolicies.value.some((policy) => policy.databaseName === database) && !window.confirm(t("settings.mcpDatabaseScopeClearsPermissionExceptions"))) return;
|
||||
const databases = checked ? [...new Set([...selectedDatabases.value, database])] : selectedDatabases.value.filter((item) => item !== database);
|
||||
updateSelectedPolicy({
|
||||
databaseScope: "selected",
|
||||
@@ -130,72 +141,13 @@ function toggleDatabase(database: string, checked: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
function databaseExecutionMode(database: string): DatabaseExecutionMode {
|
||||
function databasePolicyMode(database: string): ExecutionMode | "inherit" {
|
||||
const policy = selectedDatabasePolicies.value.find((item) => item.databaseName === database);
|
||||
if (!policy) return "inherit";
|
||||
if (policy.readOnly) return "read_only";
|
||||
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
}
|
||||
|
||||
function connectionExecutionMode(): DatabaseExecutionMode {
|
||||
const policy = selectedPolicy.value;
|
||||
if (!policy || !policy.executionModeConfigured) return "inherit";
|
||||
if (policy.readOnly) return "read_only";
|
||||
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
}
|
||||
|
||||
function effectiveDatabaseExecutionMode(database: string): ExecutionMode {
|
||||
const databaseMode = databaseExecutionMode(database);
|
||||
if (databaseMode !== "inherit") return databaseMode;
|
||||
const connectionMode = connectionExecutionMode();
|
||||
return connectionMode === "inherit" ? props.globalExecutionMode : connectionMode;
|
||||
}
|
||||
|
||||
function executionModeLabel(mode: ExecutionMode): string {
|
||||
return t(mode === "read_only" ? "settings.mcpExecutionModeReadOnly" : mode === "safe_write" ? "settings.mcpExecutionModeSafeWrite" : "settings.mcpExecutionModeHighRiskWrite");
|
||||
}
|
||||
|
||||
function executionModeRank(mode: DatabaseExecutionMode): number {
|
||||
return mode === "read_only" ? 0 : mode === "safe_write" ? 1 : mode === "high_risk_write" ? 2 : 3;
|
||||
}
|
||||
|
||||
function promoteLegacyConnectionDefault(policy: McpConnectionPolicy): Pick<McpConnectionPolicy, "readOnly" | "allowDangerousSql" | "executionModeConfigured" | "databasePolicies"> {
|
||||
if (policy.executionModePolicyVersion === 1) {
|
||||
return { readOnly: policy.readOnly, allowDangerousSql: policy.allowDangerousSql, executionModeConfigured: policy.executionModeConfigured, databasePolicies: policy.databasePolicies };
|
||||
}
|
||||
const legacyMode = connectionExecutionMode();
|
||||
const effectiveMode = executionModeRank(legacyMode) < executionModeRank(props.globalExecutionMode) ? legacyMode : props.globalExecutionMode;
|
||||
const databasePolicies = policy.databasePolicies.map((databasePolicy) => {
|
||||
const databaseMode = databasePolicy.readOnly ? "read_only" : databasePolicy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
const effectiveDatabaseMode = executionModeRank(databaseMode) < executionModeRank(effectiveMode) ? databaseMode : effectiveMode;
|
||||
return {
|
||||
...databasePolicy,
|
||||
readOnly: effectiveDatabaseMode === "read_only",
|
||||
allowDangerousSql: effectiveDatabaseMode === "high_risk_write",
|
||||
};
|
||||
});
|
||||
return {
|
||||
readOnly: effectiveMode === "read_only",
|
||||
allowDangerousSql: effectiveMode === "high_risk_write",
|
||||
executionModeConfigured: true,
|
||||
databasePolicies,
|
||||
};
|
||||
}
|
||||
|
||||
function setDatabaseExecutionMode(database: string, mode: DatabaseExecutionMode) {
|
||||
const existing = policyFor(selectedConnectionId.value);
|
||||
const migrated = promoteLegacyConnectionDefault(existing);
|
||||
const policies = migrated.databasePolicies.filter((policy) => policy.databaseName !== database);
|
||||
if (mode !== "inherit") {
|
||||
policies.push({
|
||||
databaseName: database,
|
||||
readOnly: mode === "read_only",
|
||||
allowDangerousSql: mode === "high_risk_write",
|
||||
});
|
||||
}
|
||||
updateSelectedPolicy({ ...promoteLegacyConnectionDefault(existing), databasePolicies: policies, executionModePolicyVersion: 1 });
|
||||
}
|
||||
|
||||
function addManualDatabase() {
|
||||
const database = manualDatabase.value.trim();
|
||||
if (!database) return;
|
||||
@@ -295,9 +247,9 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));
|
||||
<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>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpDatabaseScopeDescription") }}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="disabled || Boolean(loadingConnectionId)" @click="loadConnectionDatabases">
|
||||
<Button v-if="selectedScope !== 'all'" 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") }}
|
||||
@@ -305,19 +257,55 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));
|
||||
</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')">
|
||||
<Button
|
||||
type="button"
|
||||
role="radio"
|
||||
data-database-scope="all"
|
||||
variant="outline"
|
||||
class="h-auto justify-start px-3 py-2 text-left"
|
||||
:class="selectedScope === 'all' ? 'dbx-choice-selected' : ''"
|
||||
:aria-checked="selectedScope === 'all'"
|
||||
:tabindex="selectedScope === 'all' ? 0 : -1"
|
||||
:disabled="disabled || busy"
|
||||
@click="setScope('all')"
|
||||
@keydown="onScopeKeydown($event, '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')">
|
||||
<Button
|
||||
type="button"
|
||||
role="radio"
|
||||
data-database-scope="selected"
|
||||
variant="outline"
|
||||
class="h-auto justify-start px-3 py-2 text-left"
|
||||
:class="selectedScope === 'selected' ? 'dbx-choice-selected' : ''"
|
||||
:aria-checked="selectedScope === 'selected'"
|
||||
:tabindex="selectedScope === 'selected' ? 0 : -1"
|
||||
:disabled="disabled || busy"
|
||||
@click="setScope('selected')"
|
||||
@keydown="onScopeKeydown($event, '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')">
|
||||
<Button
|
||||
type="button"
|
||||
role="radio"
|
||||
data-database-scope="none"
|
||||
variant="outline"
|
||||
class="h-auto justify-start px-3 py-2 text-left"
|
||||
:class="selectedScope === 'none' ? 'dbx-choice-selected' : ''"
|
||||
:aria-checked="selectedScope === 'none'"
|
||||
:tabindex="selectedScope === 'none' ? 0 : -1"
|
||||
:disabled="disabled || busy"
|
||||
@click="setScope('none')"
|
||||
@keydown="onScopeKeydown($event, '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
|
||||
@@ -337,17 +325,20 @@ watch(databasePageCount, () => setDatabasePage(databasePage.value));
|
||||
<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>
|
||||
<div v-if="selectedDatabases.includes(database)" class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("settings.mcpDatabasePolicySetting") }}</span>
|
||||
<select :value="databaseExecutionMode(database)" :disabled="disabled || busy" class="h-7 max-w-32 rounded border bg-background px-1.5 text-[11px]" @click.stop @change="setDatabaseExecutionMode(database, ($event.target as HTMLSelectElement).value as DatabaseExecutionMode)">
|
||||
<option value="inherit">{{ t("settings.mcpDatabasePolicyInherit") }}</option>
|
||||
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
|
||||
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
|
||||
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
|
||||
</select>
|
||||
<Badge variant="secondary" class="text-[10px] font-normal">{{ t("settings.mcpDatabasePolicyEffective", { mode: executionModeLabel(effectiveDatabaseExecutionMode(database)) }) }}</Badge>
|
||||
</div>
|
||||
<Check v-if="selectedDatabases.includes(database)" class="h-3.5 w-3.5 text-green-600" />
|
||||
<select
|
||||
v-if="selectedDatabases.includes(database)"
|
||||
:value="databasePolicyMode(database)"
|
||||
class="h-7 shrink-0 rounded border bg-background px-1.5 text-[11px]"
|
||||
:disabled="disabled || busy"
|
||||
@click.stop
|
||||
@change="$emit('set:database-policy', selectedConnectionId, database, ($event.target as HTMLSelectElement).value as ExecutionMode | 'inherit')"
|
||||
>
|
||||
<option value="inherit">{{ t("settings.mcpConnectionPolicyInherit") }}</option>
|
||||
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
|
||||
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
|
||||
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ChevronDown, ChevronRight, Database, Folder, Search } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ConnectionConfig, SidebarLayout, SidebarOrderEntry } from "@/types/database";
|
||||
|
||||
type ScopeMode = "all" | "custom";
|
||||
type ExecutionMode = "read_only" | "safe_write" | "high_risk_write";
|
||||
type ResourceNode = GroupNode | ConnectionNode;
|
||||
|
||||
interface GroupNode {
|
||||
type: "group";
|
||||
id: string;
|
||||
name: string;
|
||||
pathNames: string[];
|
||||
pathIds: string[];
|
||||
depth: number;
|
||||
children: ResourceNode[];
|
||||
}
|
||||
|
||||
interface ConnectionNode {
|
||||
type: "connection";
|
||||
id: string;
|
||||
connection: ConnectionConfig;
|
||||
pathIds: string[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
layout: SidebarLayout;
|
||||
connections: readonly ConnectionConfig[];
|
||||
allowedGroupIds: readonly string[];
|
||||
allowedConnectionIds: readonly string[] | null;
|
||||
groupPolicies?: readonly { groupId: string; readOnly: boolean; allowDangerousSql: boolean }[];
|
||||
connectionPolicies?: readonly { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured?: boolean }[];
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{ disabled: false, busy: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:scope": [value: { allowedGroupIds: string[]; allowedConnectionIds: string[] | null }];
|
||||
"set:group-policy": [groupId: string, mode: ExecutionMode | "inherit"];
|
||||
"set:connection-policy": [connectionId: string, mode: ExecutionMode | "inherit"];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const search = ref("");
|
||||
const expandedGroupIds = ref(new Set<string>());
|
||||
const initializedExpansion = ref(false);
|
||||
|
||||
const scopeMode = computed<ScopeMode>(() => (props.allowedConnectionIds === null ? "all" : "custom"));
|
||||
const connectionById = computed(() => new Map(props.connections.map((connection) => [connection.id, connection])));
|
||||
const groupNameById = computed(() => new Map(props.layout.groups.map((group) => [group.id, group.name])));
|
||||
|
||||
function entryChildren(entry: Extract<SidebarOrderEntry, { type: "group" }>): SidebarOrderEntry[] {
|
||||
return entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? [];
|
||||
}
|
||||
|
||||
const resourceTree = computed<ResourceNode[]>(() => {
|
||||
const seenConnections = new Set<string>();
|
||||
const seenGroups = new Set<string>();
|
||||
const build = (entries: SidebarOrderEntry[], pathIds: string[], pathNames: string[], depth: number): ResourceNode[] =>
|
||||
entries.flatMap((entry): ResourceNode[] => {
|
||||
if (entry.type === "connection") {
|
||||
const connection = connectionById.value.get(entry.id);
|
||||
if (!connection || seenConnections.has(entry.id)) return [];
|
||||
seenConnections.add(entry.id);
|
||||
return [{ type: "connection", id: entry.id, connection, pathIds, depth }];
|
||||
}
|
||||
if (seenGroups.has(entry.id)) return [];
|
||||
const name = groupNameById.value.get(entry.id);
|
||||
if (!name) return [];
|
||||
seenGroups.add(entry.id);
|
||||
const nextPathIds = [...pathIds, entry.id];
|
||||
const nextPathNames = [...pathNames, name];
|
||||
return [{ type: "group", id: entry.id, name, pathIds: nextPathIds, pathNames: nextPathNames, depth, children: build(entryChildren(entry), nextPathIds, nextPathNames, depth + 1) }];
|
||||
});
|
||||
|
||||
const nodes = build(props.layout.order, [], [], 0);
|
||||
for (const connection of props.connections) {
|
||||
if (!seenConnections.has(connection.id)) nodes.push({ type: "connection", id: connection.id, connection, pathIds: [], depth: 0 });
|
||||
}
|
||||
if (!initializedExpansion.value) {
|
||||
expandedGroupIds.value = new Set();
|
||||
initializedExpansion.value = true;
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
const selectedGroupIds = computed(() => new Set(props.allowedGroupIds));
|
||||
const explicitConnectionIds = computed(() => new Set(props.allowedConnectionIds ?? []));
|
||||
|
||||
function selectedAncestor(node: ResourceNode): string | null {
|
||||
const ancestors = node.type === "group" ? node.pathIds.slice(0, -1) : node.pathIds;
|
||||
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
||||
if (selectedGroupIds.value.has(ancestors[index])) return ancestors[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupLabel(groupId: string): string {
|
||||
const group = allGroups.value.get(groupId);
|
||||
return group?.pathNames.join(" / ") ?? groupId;
|
||||
}
|
||||
|
||||
const allGroups = computed(() => {
|
||||
const result = new Map<string, GroupNode>();
|
||||
const visit = (nodes: ResourceNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== "group") continue;
|
||||
result.set(node.id, node);
|
||||
visit(node.children);
|
||||
}
|
||||
};
|
||||
visit(resourceTree.value);
|
||||
return result;
|
||||
});
|
||||
|
||||
function descendants(group: GroupNode): { groupIds: string[]; connectionIds: string[] } {
|
||||
const groupIds: string[] = [];
|
||||
const connectionIds: string[] = [];
|
||||
const visit = (nodes: ResourceNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "connection") connectionIds.push(node.id);
|
||||
else {
|
||||
groupIds.push(node.id);
|
||||
visit(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(group.children);
|
||||
return { groupIds, connectionIds };
|
||||
}
|
||||
|
||||
const effectiveConnectionIds = computed(() => {
|
||||
if (props.allowedConnectionIds === null) return new Set(props.connections.map((connection) => connection.id));
|
||||
const result = new Set(props.allowedConnectionIds);
|
||||
for (const groupId of props.allowedGroupIds) {
|
||||
const group = allGroups.value.get(groupId);
|
||||
if (!group) continue;
|
||||
for (const connectionId of descendants(group).connectionIds) result.add(connectionId);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const groupCoveredConnectionIds = computed(() => {
|
||||
const result = new Set<string>();
|
||||
for (const groupId of props.allowedGroupIds) {
|
||||
const group = allGroups.value.get(groupId);
|
||||
if (!group) continue;
|
||||
for (const connectionId of descendants(group).connectionIds) result.add(connectionId);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const directConnectionCount = computed(() => props.connections.filter((connection) => explicitConnectionIds.value.has(connection.id) && !groupCoveredConnectionIds.value.has(connection.id)).length);
|
||||
|
||||
const directGroupCount = computed(
|
||||
() =>
|
||||
props.allowedGroupIds.filter((groupId) => {
|
||||
const group = allGroups.value.get(groupId);
|
||||
return group && !selectedAncestor(group);
|
||||
}).length,
|
||||
);
|
||||
|
||||
const summary = computed(() =>
|
||||
scopeMode.value === "all"
|
||||
? t("settings.mcpResourceScopeAllSummary", { count: props.connections.length })
|
||||
: t("settings.mcpResourceScopeCustomSummary", {
|
||||
effective: props.connections.filter((connection) => effectiveConnectionIds.value.has(connection.id)).length,
|
||||
groups: directGroupCount.value,
|
||||
connections: directConnectionCount.value,
|
||||
}),
|
||||
);
|
||||
|
||||
function searchableText(node: ResourceNode): string {
|
||||
if (node.type === "group") return node.pathNames.join(" ").toLocaleLowerCase();
|
||||
const connection = node.connection;
|
||||
return [connection.name, connection.db_type, connection.host, connection.port, connection.database, connection.id].filter(Boolean).join(" ").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function subtreeMatches(node: ResourceNode, query: string): boolean {
|
||||
if (searchableText(node).includes(query)) return true;
|
||||
return node.type === "group" && node.children.some((child) => subtreeMatches(child, query));
|
||||
}
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const rows: ResourceNode[] = [];
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
const visit = (nodes: ResourceNode[], includeAll = false) => {
|
||||
for (const node of nodes) {
|
||||
const ownMatch = !query || searchableText(node).includes(query);
|
||||
if (query && !includeAll && !subtreeMatches(node, query)) continue;
|
||||
rows.push(node);
|
||||
if (node.type === "group" && (query || expandedGroupIds.value.has(node.id))) visit(node.children, includeAll || ownMatch);
|
||||
}
|
||||
};
|
||||
visit(resourceTree.value);
|
||||
return rows;
|
||||
});
|
||||
|
||||
function setScopeMode(mode: ScopeMode) {
|
||||
if (props.disabled || mode === scopeMode.value) return;
|
||||
if (mode === "all" && (props.allowedGroupIds.length > 0 || (props.allowedConnectionIds?.length ?? 0) > 0) && !window.confirm(t("settings.mcpResourceScopeAllConfirm"))) return;
|
||||
emit("update:scope", mode === "all" ? { allowedGroupIds: [], allowedConnectionIds: null } : { allowedGroupIds: [], allowedConnectionIds: [] });
|
||||
}
|
||||
|
||||
function groupHasSelectedDescendant(group: GroupNode): boolean {
|
||||
const childIds = descendants(group);
|
||||
return childIds.groupIds.some((id) => selectedGroupIds.value.has(id)) || childIds.connectionIds.some((id) => explicitConnectionIds.value.has(id));
|
||||
}
|
||||
|
||||
function toggleExpanded(groupId: string) {
|
||||
const next = new Set(expandedGroupIds.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
expandedGroupIds.value = next;
|
||||
}
|
||||
|
||||
function setGroupAllowed(group: GroupNode, allowed: boolean) {
|
||||
if (props.disabled || selectedAncestor(group)) return;
|
||||
const childIds = descendants(group);
|
||||
const nextGroups = props.allowedGroupIds.filter((id) => id !== group.id && (!allowed || !childIds.groupIds.includes(id)));
|
||||
const nextConnections = (props.allowedConnectionIds ?? []).filter((id) => !allowed || !childIds.connectionIds.includes(id));
|
||||
if (allowed) nextGroups.push(group.id);
|
||||
emit("update:scope", { allowedGroupIds: nextGroups, allowedConnectionIds: nextConnections });
|
||||
}
|
||||
|
||||
function setConnectionAllowed(connectionId: string, allowed: boolean) {
|
||||
if (props.disabled) return;
|
||||
const next = new Set(props.allowedConnectionIds ?? []);
|
||||
if (allowed) next.add(connectionId);
|
||||
else next.delete(connectionId);
|
||||
emit("update:scope", { allowedGroupIds: [...props.allowedGroupIds], allowedConnectionIds: [...next] });
|
||||
}
|
||||
|
||||
function policyMode(policy: { readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured?: boolean } | undefined): ExecutionMode | "inherit" {
|
||||
if (!policy || policy.executionModeConfigured === false) return "inherit";
|
||||
if (policy.readOnly) return "read_only";
|
||||
return policy.allowDangerousSql ? "high_risk_write" : "safe_write";
|
||||
}
|
||||
|
||||
function groupPolicyMode(groupId: string): ExecutionMode | "inherit" {
|
||||
return policyMode(props.groupPolicies?.find((policy) => policy.groupId === groupId));
|
||||
}
|
||||
|
||||
function connectionPolicyMode(connectionId: string): ExecutionMode | "inherit" {
|
||||
return policyMode(props.connectionPolicies?.find((policy) => policy.connectionId === connectionId));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-md border bg-background" :aria-busy="busy">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 p-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-sm font-medium">{{ t("settings.mcpResourceScopeTitle") }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.mcpResourceScopeDescription") }}</p>
|
||||
</div>
|
||||
<Badge variant="outline" class="max-w-full rounded-md font-normal">{{ summary }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1 border-t bg-muted/40 p-1" role="radiogroup" :aria-label="t('settings.mcpResourceScopeTitle')">
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" :class="scopeMode === 'all' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'all'" :disabled="disabled" @click="setScopeMode('all')">
|
||||
{{ t("settings.mcpScopeModeAll") }}
|
||||
</Button>
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" :class="scopeMode === 'custom' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'custom'" :disabled="disabled" @click="setScopeMode('custom')">
|
||||
{{ t("settings.mcpResourceScopeModeCustom") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="scopeMode === 'custom'">
|
||||
<div class="border-t p-2">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="search" class="h-8 pl-8 text-xs" :placeholder="t('settings.mcpResourceScopeSearch')" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="visibleRows.length" class="max-h-80 overflow-auto border-t py-1" role="tree" :aria-label="t('settings.mcpResourceScopeTreeLabel')">
|
||||
<div
|
||||
v-for="node in visibleRows"
|
||||
:key="`${node.type}:${node.id}`"
|
||||
role="treeitem"
|
||||
class="flex min-h-9 items-center gap-2 px-2 text-xs hover:bg-muted/50"
|
||||
:aria-level="node.depth + 1"
|
||||
:aria-expanded="node.type === 'group' ? (search.trim() ? true : expandedGroupIds.has(node.id)) : undefined"
|
||||
:style="{ paddingLeft: `${8 + node.depth * 18}px` }"
|
||||
>
|
||||
<button v-if="node.type === 'group'" type="button" class="flex size-6 shrink-0 items-center justify-center rounded hover:bg-muted" :aria-label="node.name" @click="toggleExpanded(node.id)">
|
||||
<ChevronDown v-if="search.trim() || expandedGroupIds.has(node.id)" class="size-3.5" />
|
||||
<ChevronRight v-else class="size-3.5" />
|
||||
</button>
|
||||
<span v-else class="size-6 shrink-0" />
|
||||
|
||||
<input
|
||||
v-if="node.type === 'group'"
|
||||
type="checkbox"
|
||||
:checked="selectedGroupIds.has(node.id) || Boolean(selectedAncestor(node))"
|
||||
:indeterminate="!selectedGroupIds.has(node.id) && !selectedAncestor(node) && groupHasSelectedDescendant(node)"
|
||||
:disabled="disabled || Boolean(selectedAncestor(node))"
|
||||
:aria-checked="!selectedGroupIds.has(node.id) && !selectedAncestor(node) && groupHasSelectedDescendant(node) ? 'mixed' : selectedGroupIds.has(node.id) || Boolean(selectedAncestor(node))"
|
||||
:aria-label="node.pathNames.join(' / ')"
|
||||
@change="setGroupAllowed(node, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<input v-else type="checkbox" :checked="explicitConnectionIds.has(node.id) || Boolean(selectedAncestor(node))" :disabled="disabled || Boolean(selectedAncestor(node))" :aria-label="node.connection.name" @change="setConnectionAllowed(node.id, ($event.target as HTMLInputElement).checked)" />
|
||||
|
||||
<Folder v-if="node.type === 'group'" class="size-4 shrink-0 text-muted-foreground" />
|
||||
<Database v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">{{ node.type === "group" ? node.name : node.connection.name }}</p>
|
||||
<p v-if="node.type === 'connection'" class="truncate font-mono text-[10px] text-muted-foreground">{{ node.connection.db_type }} · {{ node.connection.host || node.connection.database || node.id }}</p>
|
||||
</div>
|
||||
<select
|
||||
v-if="node.type === 'group' && selectedGroupIds.has(node.id)"
|
||||
:value="groupPolicyMode(node.id)"
|
||||
class="h-7 shrink-0 rounded border bg-background px-1.5 text-[11px]"
|
||||
:disabled="disabled || busy"
|
||||
@click.stop
|
||||
@change="emit('set:group-policy', node.id, ($event.target as HTMLSelectElement).value as ExecutionMode | 'inherit')"
|
||||
>
|
||||
<option value="inherit">{{ t("settings.mcpConnectionPolicyInherit") }}</option>
|
||||
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
|
||||
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
|
||||
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="node.type === 'connection' && (explicitConnectionIds.has(node.id) || Boolean(selectedAncestor(node)))"
|
||||
:value="connectionPolicyMode(node.id)"
|
||||
class="h-7 shrink-0 rounded border bg-background px-1.5 text-[11px]"
|
||||
:disabled="disabled || busy"
|
||||
@click.stop
|
||||
@change="emit('set:connection-policy', node.id, ($event.target as HTMLSelectElement).value as ExecutionMode | 'inherit')"
|
||||
>
|
||||
<option value="inherit">{{ selectedAncestor(node) ? t("settings.mcpGroupPolicyInherit") : t("settings.mcpConnectionPolicyInherit") }}</option>
|
||||
<option value="read_only">{{ t("settings.mcpConnectionPolicyReadOnly") }}</option>
|
||||
<option value="safe_write">{{ t("settings.mcpConnectionPolicySafeWrite") }}</option>
|
||||
<option value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</option>
|
||||
</select>
|
||||
<Badge v-if="selectedAncestor(node)" variant="secondary" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopeInherited", { group: groupLabel(selectedAncestor(node)!) }) }}</Badge>
|
||||
<Badge v-else-if="node.type === 'group' && selectedGroupIds.has(node.id)" variant="outline" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopeDynamic") }}</Badge>
|
||||
<Badge v-else-if="node.type === 'group' && groupHasSelectedDescendant(node)" variant="secondary" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopePartial") }}</Badge>
|
||||
<Badge v-else-if="node.type === 'connection' && explicitConnectionIds.has(node.id)" variant="outline" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopeDirect") }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="border-t px-3 py-8 text-center text-xs text-muted-foreground">{{ t("settings.mcpResourceScopeEmpty") }}</p>
|
||||
<p class="border-t px-3 py-2 text-[11px] text-muted-foreground">{{ t("settings.mcpResourceScopeHint") }}</p>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
Reference in new issue
Block a user