mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 10:55:59 +00:00
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:
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { MCPConfigRequestError } from "@/core/mcp/api";
|
||||||
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
||||||
import type { MCPServerConfig } from "@/core/mcp/types";
|
import type { MCPServerConfig } from "@/core/mcp/types";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -18,6 +19,8 @@ import { SettingsSection } from "./settings-section";
|
|||||||
export function ToolSettingsPage() {
|
export function ToolSettingsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { config, isLoading, error } = useMCPConfig();
|
const { config, isLoading, error } = useMCPConfig();
|
||||||
|
const adminRequired =
|
||||||
|
error instanceof MCPConfigRequestError && error.isAdminRequired;
|
||||||
return (
|
return (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t.settings.tools.title}
|
title={t.settings.tools.title}
|
||||||
@@ -25,6 +28,10 @@ export function ToolSettingsPage() {
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
<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 ? (
|
) : error ? (
|
||||||
<div>Error: {error.message}</div>
|
<div>Error: {error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -37,12 +44,21 @@ export function ToolSettingsPage() {
|
|||||||
function MCPServerList({
|
function MCPServerList({
|
||||||
servers,
|
servers,
|
||||||
}: {
|
}: {
|
||||||
servers: Record<string, MCPServerConfig>;
|
servers?: Record<string, MCPServerConfig>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const { mutate: enableMCPServer } = useEnableMCPServer();
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<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}>
|
<Item className="w-full" variant="outline" key={name}>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
|
|||||||
@@ -495,6 +495,8 @@ export const enUS: Translations = {
|
|||||||
tools: {
|
tools: {
|
||||||
title: "Tools",
|
title: "Tools",
|
||||||
description: "Manage the configuration and enabled status of MCP 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: {
|
channels: {
|
||||||
title: "Channels",
|
title: "Channels",
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ export interface Translations {
|
|||||||
tools: {
|
tools: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
adminRequired: string;
|
||||||
|
empty: string;
|
||||||
};
|
};
|
||||||
channels: {
|
channels: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -476,6 +476,8 @@ export const zhCN: Translations = {
|
|||||||
tools: {
|
tools: {
|
||||||
title: "工具",
|
title: "工具",
|
||||||
description: "管理 MCP 工具的配置和启用状态。",
|
description: "管理 MCP 工具的配置和启用状态。",
|
||||||
|
adminRequired: "需要管理员权限才能管理 MCP 工具。",
|
||||||
|
empty: "暂无 MCP 工具。",
|
||||||
},
|
},
|
||||||
channels: {
|
channels: {
|
||||||
title: "渠道",
|
title: "渠道",
|
||||||
|
|||||||
@@ -3,8 +3,36 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
|
|
||||||
import type { MCPConfig } from "./types";
|
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() {
|
export async function loadMCPConfig() {
|
||||||
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`);
|
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>;
|
return response.json() as Promise<MCPConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,5 +44,11 @@ export async function updateMCPConfig(config: MCPConfig) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new MCPConfigRequestError(
|
||||||
|
response.status,
|
||||||
|
await readErrorDetail(response, "Failed to update MCP configuration"),
|
||||||
|
);
|
||||||
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { loadMCPConfig, updateMCPConfig } from "./api";
|
import { loadMCPConfig, MCPConfigRequestError, updateMCPConfig } from "./api";
|
||||||
|
|
||||||
export function useMCPConfig() {
|
export function useMCPConfig() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["mcpConfig"],
|
queryKey: ["mcpConfig"],
|
||||||
queryFn: () => loadMCPConfig(),
|
queryFn: () => loadMCPConfig(),
|
||||||
|
retry: (count, error) =>
|
||||||
|
!(error instanceof MCPConfigRequestError) && count < 3,
|
||||||
});
|
});
|
||||||
return { config: data, isLoading, error };
|
return { config: data, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the error-handling behaviour of the MCP config API client.
|
||||||
|
*
|
||||||
|
* Issue #3527: when a non-admin user opens Settings → Tools, the gateway
|
||||||
|
* returns 403 `{detail: "Admin privileges required to manage MCP
|
||||||
|
* configuration."}` for `GET /api/mcp/config`. The previous client
|
||||||
|
* silently treated the 403 body as a valid `MCPConfig`, so the UI then
|
||||||
|
* crashed with `Cannot convert undefined or null to object` when it tried
|
||||||
|
* `Object.entries(config.mcp_servers)`.
|
||||||
|
*
|
||||||
|
* These tests pin the contract that non-2xx responses are surfaced as
|
||||||
|
* `MCPConfigRequestError` carrying the HTTP status and backend `detail`,
|
||||||
|
* so the React Query hook's `error` branch can render a friendly empty
|
||||||
|
* state (admin-required for 403) instead of crashing.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/core/api/fetcher", () => ({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/core/config", () => ({
|
||||||
|
getBackendBaseURL: () => "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||||
|
import {
|
||||||
|
MCPConfigRequestError,
|
||||||
|
loadMCPConfig,
|
||||||
|
updateMCPConfig,
|
||||||
|
} from "@/core/mcp/api";
|
||||||
|
|
||||||
|
const mockedFetch = vi.mocked(fetcher);
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadMCPConfig", () => {
|
||||||
|
test("returns parsed config on 200", async () => {
|
||||||
|
const config = { mcp_servers: { foo: { enabled: true } } };
|
||||||
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, config));
|
||||||
|
await expect(loadMCPConfig()).resolves.toEqual(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws MCPConfigRequestError with isAdminRequired on 403 (issue #3527)", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse(403, {
|
||||||
|
detail: "Admin privileges required to manage MCP configuration.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(loadMCPConfig()).rejects.toMatchObject({
|
||||||
|
name: "MCPConfigRequestError",
|
||||||
|
status: 403,
|
||||||
|
isAdminRequired: true,
|
||||||
|
message: "Admin privileges required to manage MCP configuration.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws MCPConfigRequestError with isAdminRequired=false on non-403 errors", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(
|
||||||
|
new Response("", { status: 500, statusText: "Internal Server Error" }),
|
||||||
|
);
|
||||||
|
await expect(loadMCPConfig()).rejects.toMatchObject({
|
||||||
|
name: "MCPConfigRequestError",
|
||||||
|
status: 500,
|
||||||
|
isAdminRequired: false,
|
||||||
|
message: "Failed to load MCP configuration",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the rejected value is an instance of MCPConfigRequestError", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(jsonResponse(403, { detail: "nope" }));
|
||||||
|
await expect(loadMCPConfig()).rejects.toBeInstanceOf(MCPConfigRequestError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMCPConfig", () => {
|
||||||
|
test("returns parsed body on 200", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
|
||||||
|
await expect(updateMCPConfig({ mcp_servers: {} })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws MCPConfigRequestError with isAdminRequired on 403", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(
|
||||||
|
jsonResponse(403, {
|
||||||
|
detail: "Admin privileges required to manage MCP configuration.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(updateMCPConfig({ mcp_servers: {} })).rejects.toMatchObject({
|
||||||
|
name: "MCPConfigRequestError",
|
||||||
|
status: 403,
|
||||||
|
isAdminRequired: true,
|
||||||
|
message: "Admin privileges required to manage MCP configuration.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to generic message on non-403 errors", async () => {
|
||||||
|
mockedFetch.mockResolvedValueOnce(
|
||||||
|
new Response("", { status: 500, statusText: "Internal Server Error" }),
|
||||||
|
);
|
||||||
|
await expect(updateMCPConfig({ mcp_servers: {} })).rejects.toMatchObject({
|
||||||
|
name: "MCPConfigRequestError",
|
||||||
|
status: 500,
|
||||||
|
isAdminRequired: false,
|
||||||
|
message: "Failed to update MCP configuration",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/core/api/fetcher", () => ({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { fetch } from "@/core/api/fetcher";
|
||||||
|
import { MCPConfigRequestError, loadMCPConfig } from "@/core/mcp/api";
|
||||||
|
|
||||||
|
const mockedFetch = vi.mocked(fetch);
|
||||||
|
|
||||||
|
function makeClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retryDelay: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useMCPConfig retry policy", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when loadMCPConfig throws MCPConfigRequestError (403)", async () => {
|
||||||
|
mockedFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: async () => ({ detail: "Forbidden" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const client = makeClient();
|
||||||
|
await expect(
|
||||||
|
client.fetchQuery({
|
||||||
|
queryKey: ["mcpConfig"],
|
||||||
|
queryFn: () => loadMCPConfig(),
|
||||||
|
retry: (count, error) =>
|
||||||
|
!(error instanceof MCPConfigRequestError) && count < 3,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(MCPConfigRequestError);
|
||||||
|
|
||||||
|
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries up to 3 times on generic errors", async () => {
|
||||||
|
mockedFetch.mockRejectedValue(new Error("network down"));
|
||||||
|
|
||||||
|
const client = makeClient();
|
||||||
|
await expect(
|
||||||
|
client.fetchQuery({
|
||||||
|
queryKey: ["mcpConfig"],
|
||||||
|
queryFn: () => loadMCPConfig(),
|
||||||
|
retry: (count, error) =>
|
||||||
|
!(error instanceof MCPConfigRequestError) && count < 3,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("network down");
|
||||||
|
|
||||||
|
// initial + 3 retries = 4 calls
|
||||||
|
expect(mockedFetch).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry on MCPConfigRequestError 5xx either (deterministic typed error)", async () => {
|
||||||
|
mockedFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ detail: "Boom" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const client = makeClient();
|
||||||
|
await expect(
|
||||||
|
client.fetchQuery({
|
||||||
|
queryKey: ["mcpConfig"],
|
||||||
|
queryFn: () => loadMCPConfig(),
|
||||||
|
retry: (count, error) =>
|
||||||
|
!(error instanceof MCPConfigRequestError) && count < 3,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(MCPConfigRequestError);
|
||||||
|
|
||||||
|
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user