fix(mcp): surface admin-required state on settings tools page (#3527) (#3533)

GET /api/mcp/config returns 403 for non-admin users, but the previous
client returned the error body as MCPConfig, causing MCPServerList to
crash with 'Cannot convert undefined or null to object' on
Object.entries(config.mcp_servers).

- api.ts: introduce MCPConfigRequestError; loadMCPConfig and
  updateMCPConfig now throw it (carrying status + isAdminRequired)
  instead of letting non-2xx bodies leak through as parsed config
- tool-settings-page.tsx: render a friendly 'admin privileges required'
  empty state when the React Query error is an admin-required
  MCPConfigRequestError; keep MCPServerList resilient with
  Object.entries(servers ?? {}) and an empty-state for no servers
- i18n: add settings.tools.adminRequired and settings.tools.empty in
  en-US, zh-CN and the Translations type
- tests: cover 403 / 5xx / instanceof / detail-fallback for both
  loadMCPConfig and updateMCPConfig in tests/unit/core/mcp/api.test.ts

Refs: #3527
This commit is contained in:
Huixin615
2026-06-13 07:36:57 +08:00
committed by GitHub
parent 420a886e1d
commit a17d2ff8f8
8 changed files with 263 additions and 3 deletions
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { MCPConfigRequestError } from "@/core/mcp/api";
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
@@ -18,6 +19,8 @@ import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const adminRequired =
error instanceof MCPConfigRequestError && error.isAdminRequired;
return (
<SettingsSection
title={t.settings.tools.title}
@@ -25,6 +28,10 @@ export function ToolSettingsPage() {
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : adminRequired ? (
<div className="text-muted-foreground text-sm">
{t.settings.tools.adminRequired}
</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
@@ -37,12 +44,21 @@ export function ToolSettingsPage() {
function MCPServerList({
servers,
}: {
servers: Record<string, MCPServerConfig>;
servers?: Record<string, MCPServerConfig>;
}) {
const { t } = useI18n();
const { mutate: enableMCPServer } = useEnableMCPServer();
const entries = Object.entries(servers ?? {});
if (entries.length === 0) {
return (
<div className="text-muted-foreground text-sm">
{t.settings.tools.empty}
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{Object.entries(servers).map(([name, config]) => (
{entries.map(([name, config]) => (
<Item className="w-full" variant="outline" key={name}>
<ItemContent>
<ItemTitle>
+2
View File
@@ -495,6 +495,8 @@ export const enUS: Translations = {
tools: {
title: "Tools",
description: "Manage the configuration and enabled status of MCP tools.",
adminRequired: "Admin privileges are required to manage MCP tools.",
empty: "No MCP tools configured.",
},
channels: {
title: "Channels",
+2
View File
@@ -406,6 +406,8 @@ export interface Translations {
tools: {
title: string;
description: string;
adminRequired: string;
empty: string;
};
channels: {
title: string;
+2
View File
@@ -476,6 +476,8 @@ export const zhCN: Translations = {
tools: {
title: "工具",
description: "管理 MCP 工具的配置和启用状态。",
adminRequired: "需要管理员权限才能管理 MCP 工具。",
empty: "暂无 MCP 工具。",
},
channels: {
title: "渠道",
+34
View File
@@ -3,8 +3,36 @@ import { getBackendBaseURL } from "@/core/config";
import type { MCPConfig } from "./types";
export class MCPConfigRequestError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "MCPConfigRequestError";
this.status = status;
}
get isAdminRequired(): boolean {
return this.status === 403;
}
}
async function readErrorDetail(
response: Response,
fallback: string,
): Promise<string> {
const error = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
return typeof error.detail === "string" ? error.detail : fallback;
}
export async function loadMCPConfig() {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`);
if (!response.ok) {
throw new MCPConfigRequestError(
response.status,
await readErrorDetail(response, "Failed to load MCP configuration"),
);
}
return response.json() as Promise<MCPConfig>;
}
@@ -16,5 +44,11 @@ export async function updateMCPConfig(config: MCPConfig) {
},
body: JSON.stringify(config),
});
if (!response.ok) {
throw new MCPConfigRequestError(
response.status,
await readErrorDetail(response, "Failed to update MCP configuration"),
);
}
return response.json();
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import { loadMCPConfig, MCPConfigRequestError, updateMCPConfig } from "./api";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
queryKey: ["mcpConfig"],
queryFn: () => loadMCPConfig(),
retry: (count, error) =>
!(error instanceof MCPConfigRequestError) && count < 3,
});
return { config: data, isLoading, error };
}