feat(mcp): add resource group scopes with permission inheritance

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-07 02:18:28 +08:00
1 parent 2eaf7f9914
commit 691ecd084b
31 files changed
+2398 -1047

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -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>