mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(capabilities): restore administrator management of shared MCP plugins (#6018)
* fix(capabilities): restore administrator management of shared MCP plugins * fix(mcp): clarify control ownership and require mutation scope
This commit is contained in:
parent
f94dbf21f3
commit
9dc7bb30da
@ -646,7 +646,7 @@ Signed-in users' notification toggle, default model, conversation mode, and reas
|
||||
Capability Center groups plugins by office collaboration, documents and knowledge, search and research, business and data, and development and operations. The directory includes setup references alongside existing MCP configurations and Lark. Recommended integrations and built-in support do not imply an installed or verified connection; the Installed filter shows configured MCP entries and installed Lark only.
|
||||
|
||||
Personal MCP connections configured in the web interface are persisted per user.
|
||||
Deployment tools remain shared. See [connection ownership](docs/capability-center.md#personal-and-deployment-mcp-configuration).
|
||||
Deployment tools remain shared. Administrators can add, edit, enable, disable and delete shared MCP servers under **Platform provided**; ordinary users see their status without controls. Personal plugin switches affect only the signed-in user's connections. See [connection ownership](docs/capability-center.md#personal-and-deployment-mcp-configuration).
|
||||
|
||||
For plugin manifests, adapter registration, and Agent capability selection, see
|
||||
[Capability Center integration contract](docs/capability-center.md).
|
||||
|
||||
@ -52,7 +52,7 @@ DeerFlow's incoming IM channels. Existing manually configured CLI connections
|
||||
are not rewritten when the catalog entry changes.
|
||||
|
||||
Configuration saves credentials without sending a message or creating a CRM
|
||||
record. Connections created in the web interface are personal: credentials,
|
||||
record. Connections created under **My plugins** are personal: credentials,
|
||||
enabled state and edits belong to the signed-in user, including administrators.
|
||||
They are stored under `.deer-flow/users/<user_id>/integrations/mcp.json` and
|
||||
remain masked in the editor. Existing deployment connections in
|
||||
@ -60,6 +60,9 @@ remain masked in the editor. Existing deployment connections in
|
||||
The page separates **Platform provided** (deployment setup guides and shared MCP
|
||||
entries) from **My plugins** (personal connections, configuration templates and
|
||||
Lark account authorization). Existing search and category filters apply to both.
|
||||
Administrators can add, edit, toggle and delete shared MCP entries under
|
||||
**Platform provided**. These changes apply to all users; ordinary users see
|
||||
shared entries read-only.
|
||||
Edit, toggle and delete personal entries using the existing MCP controls.
|
||||
An Agent selects these connections through **Plugins and skills**, just like
|
||||
other MCP servers. New tool selection applies on the next run.
|
||||
|
||||
@ -46,7 +46,7 @@ against raw artifact paths before encoding.
|
||||
Personal server management uses targeted `POST /api/mcp/personal/config/servers`,
|
||||
`PUT /api/mcp/personal/config/server`, and bodyless
|
||||
`DELETE /api/mcp/personal/config/servers/{server_name}` mutations. The deployment
|
||||
`/api/mcp/config` endpoints remain operator-only and are not used by this editor. Delete names are
|
||||
`/api/mcp/config` endpoints remain admin-only and are used by the Platform provided editor. Delete names are
|
||||
percent-encoded, including legacy empty and slash-containing names; every
|
||||
successful mutation invalidates `["mcpConfig"]` only after the response.
|
||||
Current-chat MCP background tasks use `core/background-tasks`: the header
|
||||
@ -198,4 +198,4 @@ binding; explicit page-local overrides survive new-thread route replacement and
|
||||
reset on conversation changes. Gateway supplies defaults for clients without a
|
||||
selector; frontend visibility must not become a runtime enforcement boundary.
|
||||
|
||||
MCP editor requests use the personal configuration API. Query keys for personal MCP config and capability installations include the authenticated user ID; remount the editor when that ID changes. Deployment installations remain read-only under Platform provided, alongside deployment setup guides. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.
|
||||
MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.
|
||||
|
||||
@ -14,8 +14,9 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { capabilityCopy } from "@/core/capabilities/copy";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { MCPConfigRequestError } from "@/core/mcp/api";
|
||||
import { MCPConfigRequestError, type MCPScope } from "@/core/mcp/api";
|
||||
import {
|
||||
useEnableMCPServer,
|
||||
useMCPConfig,
|
||||
@ -44,6 +45,7 @@ import { PluginIcon } from "./plugin-icon";
|
||||
import { PluginIconPicker } from "./plugin-icon-picker";
|
||||
|
||||
type MCPPluginManagerProps = {
|
||||
scope?: MCPScope;
|
||||
query?: string;
|
||||
catalog?: PluginDirectoryEntry[];
|
||||
category?: PluginCategory | "all";
|
||||
@ -53,7 +55,7 @@ type MCPPluginManagerProps = {
|
||||
};
|
||||
|
||||
export function MCPPluginManager(props: MCPPluginManagerProps) {
|
||||
const { config, isLoading, error } = useMCPConfig();
|
||||
const { config, isLoading, error } = useMCPConfig(props.scope);
|
||||
// Keep the directory mounted while MCP discovery completes. Replacing the
|
||||
// entire subtree can swallow a click on an independently available plugin.
|
||||
return (
|
||||
@ -68,6 +70,7 @@ export function MCPPluginManager(props: MCPPluginManagerProps) {
|
||||
|
||||
function MCPServerList({
|
||||
servers,
|
||||
scope = "user",
|
||||
query = "",
|
||||
catalog = [],
|
||||
category = "all",
|
||||
@ -81,9 +84,13 @@ function MCPServerList({
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isPending, mutate: enableMCPServer } = useEnableMCPServer();
|
||||
const { isPending: isWriting, mutate: mutateServer } = useMCPServerMutation();
|
||||
const { t, locale } = useI18n();
|
||||
const labels = capabilityCopy(locale);
|
||||
const scopeLabel =
|
||||
scope === "deployment" ? labels.platformTitle : labels.personalTitle;
|
||||
const { isPending, mutate: enableMCPServer } = useEnableMCPServer(scope);
|
||||
const { isPending: isWriting, mutate: mutateServer } =
|
||||
useMCPServerMutation(scope);
|
||||
const [editor, setEditor] = useState<
|
||||
{ mode: "add" } | { mode: "edit"; name: string } | null
|
||||
>(null);
|
||||
@ -250,6 +257,7 @@ function MCPServerList({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={readOnly || isMutating}
|
||||
aria-label={`${t.capabilities.addPlugin} (${scopeLabel})`}
|
||||
onClick={openAddEditor}
|
||||
>
|
||||
{t.capabilities.addPlugin}
|
||||
@ -308,11 +316,11 @@ function MCPServerList({
|
||||
? undefined
|
||||
: () => openEditEditor(name, config)
|
||||
}
|
||||
detailsLabel={`${t.capabilities.details} ${displayName}`}
|
||||
detailsLabel={`${t.capabilities.details} ${displayName} (${scopeLabel})`}
|
||||
>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
aria-label={`${t.capabilities.enabled} ${displayName}`}
|
||||
aria-label={`${t.capabilities.enabled} ${displayName} (${scopeLabel})`}
|
||||
disabled={readOnly || isMutating}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
@ -321,7 +329,7 @@ function MCPServerList({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${t.common.edit} ${displayName}`}
|
||||
aria-label={`${t.common.edit} ${displayName} (${scopeLabel})`}
|
||||
disabled={readOnly || isMutating}
|
||||
onClick={() => openEditEditor(name, config)}
|
||||
>
|
||||
@ -330,7 +338,7 @@ function MCPServerList({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${t.common.delete} ${displayName}`}
|
||||
aria-label={`${t.common.delete} ${displayName} (${scopeLabel})`}
|
||||
disabled={readOnly || isMutating}
|
||||
onClick={() => setPendingRemoval(name)}
|
||||
>
|
||||
|
||||
@ -77,6 +77,7 @@ export function PluginGallery({ query }: { query: string }) {
|
||||
const labels = capabilityCopy(locale);
|
||||
const { user } = useAuth();
|
||||
const canManage = !!user && !isStaticWebsiteOnly();
|
||||
const canManagePlatform = canManage && user?.system_role === "admin";
|
||||
const directory = useCapabilityCatalog();
|
||||
const definitions = directory.data ?? [];
|
||||
const adapterNames = [
|
||||
@ -175,9 +176,10 @@ export function PluginGallery({ query }: { query: string }) {
|
||||
),
|
||||
});
|
||||
});
|
||||
// Deployment entries remain read-only; the editor manages personal connections.
|
||||
// Non-admins receive only the sanitized deployment installation projection.
|
||||
for (const item of installations.filter(
|
||||
(item) => item.adapter === "mcp" && item.scope !== "user",
|
||||
(item) =>
|
||||
!canManagePlatform && item.adapter === "mcp" && item.scope !== "user",
|
||||
)) {
|
||||
const manifest = definitions.find((plugin) => plugin.id === item.plugin_id);
|
||||
platformCatalog.push({
|
||||
@ -267,12 +269,24 @@ export function PluginGallery({ query }: { query: string }) {
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">{labels.platformHint}</p>
|
||||
</div>
|
||||
<PluginDirectory
|
||||
query={query}
|
||||
category={category}
|
||||
installedOnly={filter === "installed"}
|
||||
entries={platformCatalog}
|
||||
/>
|
||||
{canManagePlatform ? (
|
||||
<MCPPluginManager
|
||||
key={`deployment:${user?.id}`}
|
||||
scope="deployment"
|
||||
query={query}
|
||||
category={category}
|
||||
installedOnly={filter === "installed"}
|
||||
catalog={platformCatalog}
|
||||
definitions={definitions}
|
||||
/>
|
||||
) : (
|
||||
<PluginDirectory
|
||||
query={query}
|
||||
category={category}
|
||||
installedOnly={filter === "installed"}
|
||||
entries={platformCatalog}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<section
|
||||
aria-labelledby="personal-plugins-heading"
|
||||
|
||||
@ -19,12 +19,12 @@ import {
|
||||
import type { MCPScope } from "./api";
|
||||
import type { MCPServerConfig } from "./types";
|
||||
|
||||
export function useMCPConfig() {
|
||||
export function useMCPConfig(scope: MCPScope = "user") {
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["mcpConfig", "user", user?.id],
|
||||
queryFn: () => loadMCPConfig("user"),
|
||||
enabled: !!user,
|
||||
queryKey: ["mcpConfig", scope, user?.id],
|
||||
queryFn: () => loadMCPConfig(scope),
|
||||
enabled: !!user && (scope === "user" || user.system_role === "admin"),
|
||||
retry: (count, error) =>
|
||||
!(error instanceof MCPConfigRequestError) && count < 3,
|
||||
});
|
||||
@ -38,7 +38,7 @@ interface EnableMCPServerVariables {
|
||||
|
||||
export function getEnableMCPServerMutationOptions(
|
||||
queryClient: QueryClient,
|
||||
scope: MCPScope = "deployment",
|
||||
scope: MCPScope,
|
||||
) {
|
||||
return {
|
||||
mutationFn: ({ serverName, enabled }: EnableMCPServerVariables) =>
|
||||
@ -55,9 +55,9 @@ export function getEnableMCPServerMutationOptions(
|
||||
};
|
||||
}
|
||||
|
||||
export function useEnableMCPServer() {
|
||||
export function useEnableMCPServer(scope: MCPScope = "user") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(getEnableMCPServerMutationOptions(queryClient, "user"));
|
||||
return useMutation(getEnableMCPServerMutationOptions(queryClient, scope));
|
||||
}
|
||||
|
||||
export type MCPServerMutationVariables =
|
||||
@ -77,7 +77,7 @@ export type MCPServerMutationVariables =
|
||||
|
||||
export function getMCPServerMutationOptions(
|
||||
queryClient: QueryClient,
|
||||
scope: MCPScope = "deployment",
|
||||
scope: MCPScope,
|
||||
) {
|
||||
return {
|
||||
mutationFn: (variables: MCPServerMutationVariables) => {
|
||||
@ -102,7 +102,7 @@ export function getMCPServerMutationOptions(
|
||||
};
|
||||
}
|
||||
|
||||
export function useMCPServerMutation() {
|
||||
export function useMCPServerMutation(scope: MCPScope = "user") {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(getMCPServerMutationOptions(queryClient, "user"));
|
||||
return useMutation(getMCPServerMutationOptions(queryClient, scope));
|
||||
}
|
||||
|
||||
@ -178,7 +178,7 @@ test("ordinary users see shared connections read-only and configure their own cr
|
||||
personal.locator("article").filter({ hasText: "Team CRM" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
personal.getByRole("button", { name: "Add MCP plugin" }),
|
||||
personal.getByRole("button", { name: "Add MCP plugin (My plugins)" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
personal.locator("article").filter({ hasText: "HubSpot" }),
|
||||
@ -190,7 +190,7 @@ test("ordinary users see shared connections read-only and configure their own cr
|
||||
page.locator("article").filter({ hasText: "Team CRM" }).getByRole("switch"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add MCP plugin" }),
|
||||
page.getByRole("button", { name: "Add MCP plugin (My plugins)" }),
|
||||
).toHaveCount(1);
|
||||
await page
|
||||
.getByRole("button", {
|
||||
|
||||
@ -161,7 +161,9 @@ test("catalog navigation, search, details, and migrated settings", async ({
|
||||
.getByRole("textbox", { name: "搜索插件名称或用途" })
|
||||
.fill("Notion");
|
||||
await expect(page.locator("article")).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "编辑 Notion", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "编辑 Notion (我的插件)", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "MCP 服务器 JSON 定义" }),
|
||||
@ -229,7 +231,9 @@ test("MCP access errors preserve the independently available Lark integration",
|
||||
page.locator("article").filter({ hasText: "Lark / Feishu" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add MCP plugin" }),
|
||||
page
|
||||
.getByRole("region", { name: "My plugins", exact: true })
|
||||
.getByRole("button", { name: "Add MCP plugin (My plugins)" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
@ -283,14 +287,16 @@ test("plugin filters remain usable after an MCP refetch fails", async ({
|
||||
await installed.click();
|
||||
await expect(page.locator("article")).toHaveCount(5);
|
||||
await page
|
||||
.getByRole("switch", { name: "Enabled GitHub", exact: true })
|
||||
.getByRole("switch", { name: "Enabled GitHub (My plugins)", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "Admin privileges" }),
|
||||
).toBeVisible();
|
||||
await expect(installed).toHaveAttribute("aria-selected", "true");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Add MCP plugin" }),
|
||||
page
|
||||
.getByRole("region", { name: "My plugins", exact: true })
|
||||
.getByRole("button", { name: "Add MCP plugin (My plugins)" }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("tab", { name: "All plugins", exact: true }).click();
|
||||
await expect(
|
||||
@ -425,14 +431,20 @@ test("manifest installation saves through the adapter and refreshes the catalog"
|
||||
.getByRole("textbox", { name: "Search plugins by name or purpose" })
|
||||
.fill("");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Edit team-code", exact: true }),
|
||||
page.getByRole("button", {
|
||||
name: "Edit team-code (My plugins)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator("article").filter({ hasText: "team-code" }),
|
||||
).toHaveCount(1);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Edit team-code", exact: true }),
|
||||
page.getByRole("button", {
|
||||
name: "Edit team-code (My plugins)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@ -22,8 +22,12 @@ for (const { viewport, name } of [
|
||||
|
||||
for (const mode of ["edit", "add"] as const) {
|
||||
await page
|
||||
.getByRole("region", { name: "My plugins", exact: true })
|
||||
.getByRole("button", {
|
||||
name: mode === "edit" ? `Edit ${name}` : "Add MCP plugin",
|
||||
name:
|
||||
mode === "edit"
|
||||
? `Edit ${name} (My plugins)`
|
||||
: "Add MCP plugin (My plugins)",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
@ -116,8 +120,12 @@ for (const viewport of [
|
||||
|
||||
for (const mode of ["edit", "add"] as const) {
|
||||
await page
|
||||
.getByRole("region", { name: "My plugins", exact: true })
|
||||
.getByRole("button", {
|
||||
name: mode === "edit" ? "Edit github" : "Add MCP plugin",
|
||||
name:
|
||||
mode === "edit"
|
||||
? "Edit github (My plugins)"
|
||||
: "Add MCP plugin (My plugins)",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
@ -226,7 +234,9 @@ test.describe("MCP server settings", () => {
|
||||
|
||||
const settingsDialog = page;
|
||||
await expect(page).toHaveURL(/workspace\/capabilities$/);
|
||||
await settingsDialog.getByRole("button", { name: "Edit remote" }).click();
|
||||
await settingsDialog
|
||||
.getByRole("button", { name: "Edit remote (My plugins)" })
|
||||
.click();
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Edit MCP server" });
|
||||
const definitionBox = editor.getByRole("textbox");
|
||||
|
||||
293
frontend/tests/e2e/platform-mcp-controls.spec.ts
Normal file
293
frontend/tests/e2e/platform-mcp-controls.spec.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
async function mockConnections(page: Page) {
|
||||
mockLangGraphAPI(page);
|
||||
const state = { platform: false, personal: false, role: "admin" };
|
||||
const writes: { scope: string; body: unknown }[] = [];
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: state.role,
|
||||
email: `${state.role}@example.test`,
|
||||
system_role: state.role,
|
||||
needs_setup: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/capabilities/installations/mcp?scope=all", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
can_manage: state.role === "admin",
|
||||
items: [
|
||||
{
|
||||
id: "shared-github",
|
||||
plugin_id: null,
|
||||
adapter: "mcp",
|
||||
name: "github",
|
||||
reference: "github",
|
||||
description: "Shared repository tools",
|
||||
installed: true,
|
||||
enabled: state.platform,
|
||||
scope: "deployment",
|
||||
auth_status: "configured",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const [scope, endpoint] of [
|
||||
["platform", "/api/mcp/config"],
|
||||
["personal", "/api/mcp/personal/config"],
|
||||
] as const) {
|
||||
await page.route(`**${endpoint}`, (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
server_name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
writes.push({ scope, body });
|
||||
state[scope] = body.enabled;
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
mcp_servers: {
|
||||
github: {
|
||||
enabled: state[scope],
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
description:
|
||||
scope === "platform"
|
||||
? "Shared repository tools"
|
||||
: "Personal repository tools",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return { state, writes };
|
||||
}
|
||||
|
||||
test("administrators toggle shared MCP globally without changing a same-named personal connection", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const { state, writes } = await mockConnections(page);
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await page.goto("/workspace/capabilities");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search plugins by name or purpose" })
|
||||
.fill("github");
|
||||
const platform = page.getByRole("region", {
|
||||
name: "Platform provided",
|
||||
exact: true,
|
||||
});
|
||||
const sharedSwitch = page.getByRole("switch", {
|
||||
name: "Enabled github (Platform provided)",
|
||||
exact: true,
|
||||
});
|
||||
const personalSwitch = page.getByRole("switch", {
|
||||
name: "Enabled github (My plugins)",
|
||||
exact: true,
|
||||
});
|
||||
await expect(platform.getByText("github", { exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("platform-mcp-controls.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await expect(sharedSwitch).not.toBeChecked();
|
||||
await sharedSwitch.click();
|
||||
await expect(sharedSwitch).toBeChecked();
|
||||
await expect(personalSwitch).not.toBeChecked();
|
||||
await personalSwitch.click();
|
||||
await expect(personalSwitch).toBeChecked();
|
||||
await expect(sharedSwitch).toBeChecked();
|
||||
await sharedSwitch.click();
|
||||
await expect(sharedSwitch).not.toBeChecked();
|
||||
await expect(personalSwitch).toBeChecked();
|
||||
expect(writes).toEqual([
|
||||
{ scope: "platform", body: { server_name: "github", enabled: true } },
|
||||
{ scope: "personal", body: { server_name: "github", enabled: true } },
|
||||
{ scope: "platform", body: { server_name: "github", enabled: false } },
|
||||
]);
|
||||
|
||||
state.role = "user";
|
||||
await page.evaluate(() =>
|
||||
document.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
await expect(platform.getByRole("switch")).toHaveCount(0);
|
||||
await expect(
|
||||
platform.getByRole("button", { name: /Add MCP|Edit |Delete / }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
platform.locator("article").filter({ hasText: "github" }),
|
||||
).toContainText("Disabled");
|
||||
await expect(personalSwitch).toBeEnabled();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Search plugins by name or purpose" })
|
||||
.fill("github");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("platform-mcp-member.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect(writes).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("a rejected platform toggle keeps the stored state and reports the error", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockConnections(page);
|
||||
let finishRequest!: () => void;
|
||||
await page.route("**/api/mcp/config", async (route) => {
|
||||
if (route.request().method() === "GET") return route.fallback();
|
||||
await new Promise<void>((resolve) => {
|
||||
finishRequest = resolve;
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
json: { detail: "Administrator access required" },
|
||||
});
|
||||
});
|
||||
await page.goto("/workspace/capabilities");
|
||||
const toggle = page
|
||||
.getByRole("region", { name: "Platform provided", exact: true })
|
||||
.getByRole("switch", {
|
||||
name: "Enabled github (Platform provided)",
|
||||
exact: true,
|
||||
});
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
finishRequest();
|
||||
await expect(
|
||||
page.getByText("Administrator access required", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("administrators add, edit and delete shared MCP using the existing editor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { writes } = await mockConnections(page);
|
||||
const servers: Record<string, Record<string, unknown>> = {
|
||||
github: {
|
||||
enabled: false,
|
||||
type: "http",
|
||||
url: "https://example.test/mcp",
|
||||
headers: { Authorization: "***" },
|
||||
routing: { mode: "prefer" },
|
||||
},
|
||||
};
|
||||
const changes: { method: string; body: unknown }[] = [];
|
||||
await page.route("**/api/mcp/config**", async (route) => {
|
||||
const request = route.request();
|
||||
const method = request.method();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (method === "POST") {
|
||||
const body = request.postDataJSON() as { mcp_servers: typeof servers };
|
||||
Object.assign(servers, body.mcp_servers);
|
||||
changes.push({ method, body });
|
||||
} else if (method === "PUT") {
|
||||
const body = request.postDataJSON() as {
|
||||
server_name: string;
|
||||
server: Record<string, unknown>;
|
||||
};
|
||||
servers[body.server_name] = body.server;
|
||||
changes.push({ method, body });
|
||||
} else if (method === "DELETE") {
|
||||
delete servers[decodeURIComponent(path.split("/").pop()!)];
|
||||
changes.push({ method, body: path });
|
||||
}
|
||||
await route.fulfill({ json: { mcp_servers: servers } });
|
||||
});
|
||||
await page.goto("/workspace/capabilities");
|
||||
const platform = page.getByRole("region", {
|
||||
name: "Platform provided",
|
||||
exact: true,
|
||||
});
|
||||
const personal = page.getByRole("region", {
|
||||
name: "My plugins",
|
||||
exact: true,
|
||||
});
|
||||
await platform
|
||||
.getByRole("button", {
|
||||
name: "Edit github (Platform provided)",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const definition = JSON.parse(
|
||||
await dialog.getByRole("textbox").inputValue(),
|
||||
) as { mcpServers: typeof servers };
|
||||
expect(definition.mcpServers.github).toEqual(servers.github);
|
||||
definition.mcpServers.github!.description = "Updated shared tools";
|
||||
await dialog.getByRole("textbox").fill(JSON.stringify(definition));
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(
|
||||
platform.getByText("Updated shared tools", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(servers.github?.headers).toEqual({ Authorization: "***" });
|
||||
expect(servers.github?.routing).toEqual({ mode: "prefer" });
|
||||
await platform
|
||||
.getByRole("button", {
|
||||
name: "Add MCP plugin (Platform provided)",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const added = {
|
||||
description: "Team tools",
|
||||
enabled: false,
|
||||
type: "http",
|
||||
url: "https://example.test/new",
|
||||
};
|
||||
await dialog
|
||||
.getByRole("textbox")
|
||||
.fill(JSON.stringify({ "team-tools": added }));
|
||||
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(
|
||||
platform.getByRole("button", {
|
||||
name: "Edit team-tools (Platform provided)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await platform
|
||||
.getByRole("button", {
|
||||
name: "Delete github (Platform provided)",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(
|
||||
platform.getByRole("button", {
|
||||
name: "Edit github (Platform provided)",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
personal.getByRole("button", {
|
||||
name: "Edit github (My plugins)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
personal.getByRole("switch", {
|
||||
name: "Enabled github (My plugins)",
|
||||
exact: true,
|
||||
}),
|
||||
).not.toBeChecked();
|
||||
expect(changes).toEqual([
|
||||
{
|
||||
method: "PUT",
|
||||
body: { server_name: "github", server: definition.mcpServers.github },
|
||||
},
|
||||
{ method: "POST", body: { mcp_servers: { "team-tools": added } } },
|
||||
{ method: "DELETE", body: "/api/mcp/config/servers/github" },
|
||||
]);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
@ -160,7 +160,9 @@ test("upload previews, cancellation, save, reload, restore default, and create a
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/workspace/capabilities");
|
||||
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "编辑 github (我的插件)", exact: true })
|
||||
.click();
|
||||
const file = await sampleImage(page);
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.locator('input[type="file"]').setInputFiles(file);
|
||||
@ -173,7 +175,9 @@ test("upload previews, cancellation, save, reload, restore default, and create a
|
||||
await expect(
|
||||
page.locator('article img[data-plugin-icon="github"]'),
|
||||
).toHaveAttribute("src", "/images/plugins/github.svg");
|
||||
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "编辑 github (我的插件)", exact: true })
|
||||
.click();
|
||||
await expect(dialog.locator("img")).toHaveAttribute(
|
||||
"src",
|
||||
"/images/plugins/github.svg",
|
||||
@ -205,7 +209,9 @@ test("upload previews, cancellation, save, reload, restore default, and create a
|
||||
]),
|
||||
)
|
||||
.toEqual([128, 128]);
|
||||
await page.getByRole("button", { name: "编辑 github", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "编辑 github (我的插件)", exact: true })
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "恢复默认", exact: true }).click();
|
||||
await expect(dialog.locator("img")).toHaveAttribute(
|
||||
"src",
|
||||
@ -220,7 +226,8 @@ test("upload previews, cancellation, save, reload, restore default, and create a
|
||||
expect(servers.github?.presentation).toEqual({ display_name: "Engineering" });
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "添加 MCP 插件", exact: true })
|
||||
.getByRole("region", { name: "我的插件", exact: true })
|
||||
.getByRole("button", { name: "添加 MCP 插件 (我的插件)", exact: true })
|
||||
.click();
|
||||
await dialog.getByRole("textbox").fill(
|
||||
JSON.stringify({
|
||||
@ -274,7 +281,9 @@ test("invalid or oversized uploads do not replace the existing icon", async ({
|
||||
}),
|
||||
);
|
||||
await page.goto("/workspace/capabilities");
|
||||
await page.getByRole("button", { name: "Edit github", exact: true }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Edit github (My plugins)", exact: true })
|
||||
.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const input = dialog.locator('input[type="file"]');
|
||||
await input.setInputFiles({
|
||||
@ -342,7 +351,9 @@ test("a custom MCP name does not impersonate a catalog brand in rows or editing"
|
||||
.filter({ hasText: "Private connection without provider metadata" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator("img")).toHaveCount(0);
|
||||
await row.getByRole("button", { name: "Edit github", exact: true }).click();
|
||||
await row
|
||||
.getByRole("button", { name: "Edit github (My plugins)", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await expect(page.getByRole("dialog").locator("img")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@ -1861,6 +1861,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/mcp/config", (route) =>
|
||||
route.fulfill({ json: { mcp_servers: {} } }),
|
||||
);
|
||||
|
||||
void page.route("**/api/mcp/personal/config", (route) =>
|
||||
route.fulfill({ json: { mcp_servers: {} } }),
|
||||
);
|
||||
|
||||
@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MCPPluginManager } from "@/components/workspace/capabilities/mcp-plugin-manager";
|
||||
|
||||
const mcpMockState = rs.hoisted(() => ({
|
||||
locale: "en-US",
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
error: null as Error | null,
|
||||
@ -27,6 +28,7 @@ const DURABLE_TASK_SERVER = {
|
||||
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({
|
||||
locale: mcpMockState.locale,
|
||||
t: {
|
||||
capabilities: {
|
||||
icon: {
|
||||
@ -135,11 +137,15 @@ function lastMutation() {
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add server" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add server (My plugins)" }),
|
||||
);
|
||||
}
|
||||
|
||||
function openEditDialog(name: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: `Edit ${name}` }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: `Edit ${name} (My plugins)` }),
|
||||
);
|
||||
}
|
||||
|
||||
function definitionTextbox(): HTMLTextAreaElement {
|
||||
@ -151,6 +157,7 @@ function definitionTextbox(): HTMLTextAreaElement {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mcpMockState.locale = "en-US";
|
||||
mcpMockState.isPending = false;
|
||||
mcpMockState.isLoading = false;
|
||||
mcpMockState.error = null;
|
||||
@ -186,7 +193,9 @@ describe("MCPPluginManager MCP switches", () => {
|
||||
screen.getByRole("button", { name: "Configure Lark" }),
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "All plugins" })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Add server" })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Add server (My plugins)" }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@ -294,7 +303,7 @@ describe("MCPPluginManager add server", () => {
|
||||
expect(screen.getByText("No matches found")).toBeDefined();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Add server" })
|
||||
.getByRole("button", { name: "Add server (My plugins)" })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
});
|
||||
@ -396,7 +405,9 @@ describe("MCPPluginManager remove server", () => {
|
||||
twoServers();
|
||||
|
||||
render(<MCPPluginManager />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Delete github (My plugins)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
expect(lastMutation()).toEqual({
|
||||
@ -409,7 +420,9 @@ describe("MCPPluginManager remove server", () => {
|
||||
twoServers();
|
||||
|
||||
render(<MCPPluginManager />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete github" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Delete github (My plugins)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mcpMockState.updateMutate).not.toHaveBeenCalled();
|
||||
@ -420,7 +433,7 @@ describe("MCPPluginManager remove server", () => {
|
||||
|
||||
render(<MCPPluginManager />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Delete (empty name)" }),
|
||||
screen.getByRole("button", { name: "Delete (empty name) (My plugins)" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
@ -430,3 +443,37 @@ describe("MCPPluginManager remove server", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP controls identify their ownership", () => {
|
||||
it.each([
|
||||
{ locale: "en-US", platform: "Platform provided", personal: "My plugins" },
|
||||
{ locale: "zh-CN", platform: "平台提供", personal: "我的插件" },
|
||||
])(
|
||||
"distinguishes same-named connections without region context in $locale",
|
||||
({ locale, platform, personal }) => {
|
||||
mcpMockState.locale = locale;
|
||||
setServers({ github: { enabled: true, description: "GitHub tools" } });
|
||||
render(
|
||||
<>
|
||||
<MCPPluginManager scope="deployment" />
|
||||
<MCPPluginManager />
|
||||
</>,
|
||||
);
|
||||
for (const label of [platform, personal]) {
|
||||
for (const [role, action] of [
|
||||
["button", "Add server"],
|
||||
["button", "View details github"],
|
||||
["button", "Edit github"],
|
||||
["button", "Delete github"],
|
||||
["switch", "Enabled github"],
|
||||
]) {
|
||||
expect(
|
||||
screen.getAllByRole(role!, {
|
||||
name: `${action} (${label})`,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -96,96 +96,15 @@ describe("useMCPConfig retry policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP server state mutation", () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToastError.mockReset();
|
||||
});
|
||||
|
||||
it("invalidates MCP config after a successful targeted update", async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs
|
||||
.spyOn(client, "invalidateQueries")
|
||||
.mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getEnableMCPServerMutationOptions(client));
|
||||
|
||||
await mutation.execute({ serverName: "github", enabled: false });
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["mcpConfig"],
|
||||
describe.each(["deployment", "user"] as const)(
|
||||
"MCP server state mutation (%s)",
|
||||
(scope) => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToastError.mockReset();
|
||||
});
|
||||
expect(mockedToastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the backend error detail when a targeted update fails", async () => {
|
||||
const detail =
|
||||
"MCP server 'semantic-scholar' uses disallowed stdio command 's2-mcp-server'.";
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getEnableMCPServerMutationOptions(client));
|
||||
|
||||
await expect(
|
||||
mutation.execute({ serverName: "semantic-scholar", enabled: true }),
|
||||
).rejects.toThrow(detail);
|
||||
|
||||
expect(mockedToastError).toHaveBeenCalledWith(detail);
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP server CRUD mutation", () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToastError.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
variables: {
|
||||
operation: "create" as const,
|
||||
servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
path: "/api/mcp/config/servers",
|
||||
method: "POST",
|
||||
body: {
|
||||
mcp_servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "update" as const,
|
||||
serverName: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
path: "/api/mcp/config/server",
|
||||
method: "PUT",
|
||||
body: {
|
||||
server_name: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "delete" as const,
|
||||
serverName: "team/tools",
|
||||
},
|
||||
path: "/api/mcp/config/servers/team%2Ftools",
|
||||
method: "DELETE",
|
||||
body: undefined,
|
||||
},
|
||||
])(
|
||||
"sends a targeted $method request and invalidates the config",
|
||||
async ({ variables, path, method, body }) => {
|
||||
it("invalidates MCP config after a successful targeted update", async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
@ -195,63 +114,172 @@ describe("MCP server CRUD mutation", () => {
|
||||
.mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
|
||||
await mutation.execute(variables);
|
||||
.build(client, getEnableMCPServerMutationOptions(client, scope));
|
||||
|
||||
await mutation.execute({ serverName: "github", enabled: false });
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url.endsWith(path)).toBe(true);
|
||||
expect(request.method).toBe(method);
|
||||
if (body === undefined) {
|
||||
expect(request.body).toBeUndefined();
|
||||
} else {
|
||||
expect(JSON.parse(request.body as string)).toEqual(body);
|
||||
}
|
||||
expect(
|
||||
url.endsWith(
|
||||
scope === "user" ? "/api/mcp/personal/config" : "/api/mcp/config",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(request.method).toBe("PATCH");
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["mcpConfig"],
|
||||
});
|
||||
expect(mockedToastError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an empty server name addressable without a DELETE body", async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
it("shows the backend error detail when a targeted update fails", async () => {
|
||||
const detail =
|
||||
"MCP server 'semantic-scholar' uses disallowed stdio command 's2-mcp-server'.";
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getEnableMCPServerMutationOptions(client, scope));
|
||||
|
||||
await expect(
|
||||
mutation.execute({ serverName: "semantic-scholar", enabled: true }),
|
||||
).rejects.toThrow(detail);
|
||||
|
||||
expect(mockedToastError).toHaveBeenCalledWith(detail);
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(["deployment", "user"] as const)(
|
||||
"MCP server CRUD mutation (%s)",
|
||||
(scope) => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedToastError.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
variables: {
|
||||
operation: "create" as const,
|
||||
servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
path: "/api/mcp/config/servers",
|
||||
method: "POST",
|
||||
body: {
|
||||
mcp_servers: { github: { enabled: true, description: "GitHub" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "update" as const,
|
||||
serverName: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
path: "/api/mcp/config/server",
|
||||
method: "PUT",
|
||||
body: {
|
||||
server_name: "github",
|
||||
server: { enabled: false, description: "GitHub tools" },
|
||||
},
|
||||
},
|
||||
{
|
||||
variables: {
|
||||
operation: "delete" as const,
|
||||
serverName: "team/tools",
|
||||
},
|
||||
path: "/api/mcp/config/servers/team%2Ftools",
|
||||
method: "DELETE",
|
||||
body: undefined,
|
||||
},
|
||||
])(
|
||||
"sends a targeted $method request and invalidates the config",
|
||||
async ({ variables, path, method, body }) => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs
|
||||
.spyOn(client, "invalidateQueries")
|
||||
.mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client, scope));
|
||||
|
||||
await mutation.execute(variables);
|
||||
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(
|
||||
url.endsWith(
|
||||
scope === "user"
|
||||
? path.replace("/mcp/config", "/mcp/personal/config")
|
||||
: path,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(request.method).toBe(method);
|
||||
if (body === undefined) {
|
||||
expect(request.body).toBeUndefined();
|
||||
} else {
|
||||
expect(JSON.parse(request.body as string)).toEqual(body);
|
||||
}
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["mcpConfig"],
|
||||
});
|
||||
expect(mockedToastError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
const client = makeClient();
|
||||
rs.spyOn(client, "invalidateQueries").mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
|
||||
await mutation.execute({ operation: "delete", serverName: "" });
|
||||
it("keeps an empty server name addressable without a DELETE body", async () => {
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ mcp_servers: {} }), { status: 200 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
rs.spyOn(client, "invalidateQueries").mockResolvedValue();
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client, scope));
|
||||
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url.endsWith("/api/mcp/config/servers/")).toBe(true);
|
||||
expect(request.method).toBe("DELETE");
|
||||
expect(request.body).toBeUndefined();
|
||||
});
|
||||
await mutation.execute({ operation: "delete", serverName: "" });
|
||||
|
||||
it("surfaces the Gateway rejection detail without invalidating", async () => {
|
||||
const detail =
|
||||
"MCP server 'evil' uses disallowed stdio command 'bash'. Allowed commands: npx, uvx.";
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client));
|
||||
const [url, request] = mockedFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(
|
||||
url.endsWith(
|
||||
scope === "user"
|
||||
? "/api/mcp/personal/config/servers/"
|
||||
: "/api/mcp/config/servers/",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(request.method).toBe("DELETE");
|
||||
expect(request.body).toBeUndefined();
|
||||
});
|
||||
|
||||
await expect(
|
||||
mutation.execute({
|
||||
operation: "create",
|
||||
servers: { evil: { enabled: true, description: "" } },
|
||||
}),
|
||||
).rejects.toThrow(detail);
|
||||
it("surfaces the Gateway rejection detail without invalidating", async () => {
|
||||
const detail =
|
||||
"MCP server 'evil' uses disallowed stdio command 'bash'. Allowed commands: npx, uvx.";
|
||||
mockedFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail }), { status: 400 }),
|
||||
);
|
||||
const client = makeClient();
|
||||
const invalidateQueries = rs.spyOn(client, "invalidateQueries");
|
||||
const mutation = client
|
||||
.getMutationCache()
|
||||
.build(client, getMCPServerMutationOptions(client, scope));
|
||||
|
||||
expect(mockedToastError).toHaveBeenCalledWith(detail);
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
await expect(
|
||||
mutation.execute({
|
||||
operation: "create",
|
||||
servers: { evil: { enabled: true, description: "" } },
|
||||
}),
|
||||
).rejects.toThrow(detail);
|
||||
|
||||
expect(mockedToastError).toHaveBeenCalledWith(detail);
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -3,18 +3,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const auth = rs.hoisted(() => ({ user: { id: "alice" } }));
|
||||
const auth = rs.hoisted(() => ({ user: { id: "alice", system_role: "user" } }));
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: () => auth }));
|
||||
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
|
||||
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { useMCPConfig } from "@/core/mcp/hooks";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
rs.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("personal MCP ownership", () => {
|
||||
it("does not display a previous account's late response after switching users", async () => {
|
||||
auth.user = { id: "alice" };
|
||||
auth.user = { id: "alice", system_role: "user" };
|
||||
let finishAlice!: (response: Response) => void;
|
||||
rs.mocked(fetch).mockImplementation(async () => {
|
||||
if (auth.user.id === "alice") {
|
||||
@ -34,7 +37,7 @@ describe("personal MCP ownership", () => {
|
||||
);
|
||||
const hook = renderHook(() => useMCPConfig(), { wrapper });
|
||||
await waitFor(() => expect(finishAlice).toBeDefined());
|
||||
auth.user = { id: "bob" };
|
||||
auth.user = { id: "bob", system_role: "user" };
|
||||
hook.rerender();
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.config?.mcp_servers).toHaveProperty(
|
||||
@ -64,3 +67,64 @@ describe("personal MCP ownership", () => {
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deployment MCP ownership", () => {
|
||||
it("separates an administrator's deployment and personal queries", async () => {
|
||||
auth.user = { id: "admin", system_role: "admin" };
|
||||
rs.mocked(fetch).mockImplementation(
|
||||
async (url) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
mcp_servers: {
|
||||
github: {
|
||||
enabled: false,
|
||||
description:
|
||||
typeof url === "string" && url.includes("/personal/")
|
||||
? "Personal"
|
||||
: "Platform",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(
|
||||
() => ({
|
||||
personal: useMCPConfig(),
|
||||
platform: useMCPConfig("deployment"),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
hook.result.current.personal.config?.mcp_servers.github?.description,
|
||||
).toBe("Personal");
|
||||
expect(
|
||||
hook.result.current.platform.config?.mcp_servers.github?.description,
|
||||
).toBe("Platform");
|
||||
});
|
||||
expect(client.getQueryData(["mcpConfig", "user", "admin"])).toBeDefined();
|
||||
expect(
|
||||
client.getQueryData(["mcpConfig", "deployment", "admin"]),
|
||||
).toBeDefined();
|
||||
auth.user = { id: "member", system_role: "user" };
|
||||
hook.rerender();
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.personal.config).toBeDefined(),
|
||||
);
|
||||
expect(hook.result.current.platform.config).toBeUndefined();
|
||||
expect(
|
||||
rs
|
||||
.mocked(fetch)
|
||||
.mock.calls.filter(
|
||||
([url]) => typeof url === "string" && url.endsWith("/api/mcp/config"),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
client.clear();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user