feat(ui/mcp): surface BYOK toggle for static auth types in create/edit forms

Gate is_byok on api_key/bearer_token/token/basic instead of OpenAPI transport, in both forms, so BYOK no longer requires an API or direct DB change.
This commit is contained in:
Tin Chi Lo 2026-06-10 17:17:47 -07:00
parent a6b7dcc7d6
commit 5a7f9157fb
5 changed files with 342 additions and 96 deletions

View file

@ -0,0 +1,89 @@
import React from "react";
import { Form, Select, Input, Switch, Tooltip } from "antd";
import type { FormInstance } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { AUTH_TYPE } from "./types";
export const BYOK_AUTH_FORMAT_HINT: Record<string, string> = {
[AUTH_TYPE.BEARER_TOKEN]: "Authorization: Bearer {key}",
[AUTH_TYPE.TOKEN]: "Authorization: token {key}",
[AUTH_TYPE.API_KEY]: "x-api-key: {key}",
[AUTH_TYPE.BASIC]: "Authorization: Basic {key}",
};
interface ByokFieldsProps {
form: FormInstance;
}
const ByokFields: React.FC<ByokFieldsProps> = ({ form }) => {
const isByok = Form.useWatch("is_byok", form);
const authType = Form.useWatch("auth_type", form) as string | undefined;
const formatHint = authType ? BYOK_AUTH_FORMAT_HINT[authType] : undefined;
return (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
BYOK (Bring Your Own Key)
<Tooltip title="When enabled, each user supplies their own API key for this server instead of a single shared key. Keys are stored per-user and never shared.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="is_byok"
valuePropName="checked"
>
<Switch />
</Form.Item>
{isByok && (
<>
{formatHint && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 flex-shrink-0" />
<span>
User keys will be sent as: <code className="font-mono bg-blue-100 px-1 rounded">{formatHint}</code>
</span>
</div>
)}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Access Description
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_description"
>
<Select
mode="tags"
placeholder="Add access description items (press Enter after each)"
className="w-full"
tokenSeparators={[","]}
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
API Key Help URL
<Tooltip title="Optional link shown to users to help them find their API key">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_api_key_help_url"
>
<Input placeholder="https://docs.example.com/api-keys" />
</Form.Item>
</>
)}
</>
);
};
export default ByokFields;

View file

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "../networking";
@ -773,3 +773,127 @@ describe("CreateMCPServer", () => {
});
});
});
const BYOK_LABEL = "BYOK (Bring Your Own Key)";
function getByokSwitch(): HTMLButtonElement | null {
const label = screen.queryByText(BYOK_LABEL);
const formItem = label?.closest(".ant-form-item");
return (formItem?.querySelector('button[role="switch"]') as HTMLButtonElement) ?? null;
}
describe("CreateMCPServer BYOK toggle", () => {
beforeEach(() => {
vi.clearAllMocks();
oauthHook.tokenResponse = null;
oauthHook.onTokenReceived = null;
});
async function selectHttpTransport() {
render(<CreateMCPServer {...defaultProps} />);
await selectAntOption("Transport Type", "Streamable HTTP");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument();
});
}
it("shows the BYOK toggle for each static-credential auth type", async () => {
for (const authLabel of ["API Key", "Bearer Token", "Token", "Basic Auth"]) {
await selectHttpTransport();
await selectAntOption("Authentication", authLabel);
await waitFor(() => {
expect(screen.getByText(BYOK_LABEL)).toBeInTheDocument();
});
cleanup();
}
});
it("hides the BYOK toggle for non-static auth types (none, oauth, sigv4)", async () => {
for (const authLabel of ["None", "OAuth", "AWS SigV4 (Bedrock AgentCore MCPs)"]) {
await selectHttpTransport();
await selectAntOption("Authentication", authLabel);
await waitFor(() => {
expect(screen.queryByText(BYOK_LABEL)).not.toBeInTheDocument();
});
cleanup();
}
});
it("swaps the shared Authentication Value field for per-user fields when BYOK is on", async () => {
await selectHttpTransport();
await selectAntOption("Authentication", "Bearer Token");
await waitFor(() => {
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
});
const byokSwitch = getByokSwitch();
expect(byokSwitch).toBeTruthy();
await act(async () => {
fireEvent.click(byokSwitch!);
});
await waitFor(() => {
expect(screen.queryByText("Authentication Value")).not.toBeInTheDocument();
expect(screen.getByText("Access Description")).toBeInTheDocument();
});
});
it("sends is_byok=true and omits the shared credential when BYOK is enabled", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
await user.type(getServerNameInput(), "Byok_Server");
await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
await selectAntOption("Authentication", "Bearer Token");
await waitFor(() => expect(screen.getByText(BYOK_LABEL)).toBeInTheDocument());
await act(async () => {
fireEvent.click(getByokSwitch()!);
});
await waitFor(() => expect(screen.queryByText("Authentication Value")).not.toBeInTheDocument());
vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "byok-1" } as any);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
});
await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1));
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.is_byok).toBe(true);
expect(payload.auth_type).toBe("bearer_token");
expect(payload.credentials).toBeUndefined();
});
it("forces is_byok=false when auth_type is switched away from a static type after enabling it", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
await user.type(getServerNameInput(), "Switched_Server");
await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
await selectAntOption("Authentication", "Bearer Token");
await waitFor(() => expect(screen.getByText(BYOK_LABEL)).toBeInTheDocument());
await act(async () => {
fireEvent.click(getByokSwitch()!);
});
// Switching to OAuth removes BYOK eligibility; a stale is_byok=true must not persist.
await selectAntOption("Authentication", "None");
await waitFor(() => expect(screen.queryByText(BYOK_LABEL)).not.toBeInTheDocument());
vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "switched-1" } as any);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
});
await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1));
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.is_byok).toBe(false);
});
});

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
import { Modal, Tooltip, Form, Select, Input, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "../networking";
@ -15,6 +15,7 @@ import {
MCP_OAUTH2_FLOW_M2M,
} from "./types";
import OAuthFormFields from "./OAuthFormFields";
import ByokFields from "./ByokFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
import MCPToolConfiguration from "./mcp_tool_configuration";
@ -100,6 +101,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isByok = shouldShowAuthValueField && Boolean(formValues.is_byok);
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
@ -382,6 +384,14 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
restValues.transport = "http";
}
// BYOK is only valid for static-credential auth types; drop a stale ``true``
// (and its metadata) left behind by switching auth_type after toggling it on.
const isByokEnabled =
AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(restValues.auth_type) && Boolean(restValues.is_byok);
restValues.is_byok = isByokEnabled;
restValues.byok_description = isByokEnabled ? restValues.byok_description || [] : [];
restValues.byok_api_key_help_url = isByokEnabled ? restValues.byok_api_key_help_url || null : null;
// Parse token_validation JSON if provided
let tokenValidation: Record<string, any> | null = null;
if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
@ -422,7 +432,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
};
const includeCredentials =
restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
!isByokEnabled && restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
if (includeCredentials && credentialsPayload && Object.keys(credentialsPayload).length > 0) {
payload.credentials = credentialsPayload;
@ -742,96 +752,6 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
)}
{/* BYOK toggle - only for OpenAPI */}
{transportType === TRANSPORT.OPENAPI && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
BYOK (Bring Your Own Key)
<Tooltip title="When enabled, each user provides their own API key for this service. Keys are stored per-user and never shared.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="is_byok"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.is_byok !== cur.is_byok || prev.auth_type !== cur.auth_type}
>
{({ getFieldValue }) =>
getFieldValue("is_byok") ? (
<>
{/* Auth format hint */}
{getFieldValue("auth_type") && getFieldValue("auth_type") !== "none" && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 flex-shrink-0" />
<span>
User keys will be sent as:{" "}
<code className="font-mono bg-blue-100 px-1 rounded">
{getFieldValue("auth_type") === "bearer_token" && "Authorization: Bearer {key}"}
{getFieldValue("auth_type") === "token" && "Authorization: token {key}"}
{getFieldValue("auth_type") === "api_key" && "x-api-key: {key}"}
{getFieldValue("auth_type") === "basic" && "Authorization: Basic {key}"}
{getFieldValue("auth_type") === "authorization" && "Authorization: {key}"}
</code>
{!getFieldValue("auth_type") && "Set Authentication Type below to specify the format."}
</span>
</div>
)}
{!getFieldValue("auth_type") && (
<div className="mb-4 p-3 bg-yellow-50 rounded-lg text-sm text-yellow-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 flex-shrink-0" />
<span>
Set the <strong>Authentication Type</strong> below to specify how user keys are sent
(e.g., Bearer Token, API Key header).
</span>
</div>
)}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Access Description
<Tooltip title="List of permissions shown to users in the connection modal (e.g. 'Create and manage Jira issues')">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_description"
>
<Select
mode="tags"
placeholder="Add access description items (press Enter after each)"
className="w-full"
tokenSeparators={[","]}
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
API Key Help URL
<Tooltip title="Optional link shown to users to help them find their API key">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="byok_api_key_help_url"
>
<Input placeholder="https://docs.example.com/api-keys" />
</Form.Item>
</>
) : null
}
</Form.Item>
</>
)}
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
{transportType !== "stdio" && transportType !== "" && (
<Collapse
@ -855,7 +775,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Select>
</Form.Item>
{shouldShowAuthValueField && (
{shouldShowAuthValueField && <ByokFields form={form} />}
{shouldShowAuthValueField && !isByok && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">

View file

@ -940,3 +940,101 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => {
expect(mockSetToken).not.toHaveBeenCalled();
});
});
describe("MCPServerEdit (BYOK)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const byokServer = {
server_id: "byok_server_1",
server_name: "ByokServer",
alias: "byok_server",
description: "BYOK MCP server",
transport: "http",
url: "https://example.com/mcp",
auth_type: "bearer_token",
is_byok: true,
byok_description: ["Create and manage issues"],
byok_api_key_help_url: "https://docs.example.com/keys",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
mcp_access_groups: [],
};
const getByokSwitch = () => {
const formItem = screen.queryByText("BYOK (Bring Your Own Key)")?.closest(".ant-form-item");
return (formItem?.querySelector('button[role="switch"]') as HTMLButtonElement) ?? null;
};
const save = async () => {
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
await act(async () => {
fireEvent.click(saveButtons[0]);
});
await waitFor(() => expect(networking.updateMCPServer).toHaveBeenCalledTimes(1));
return vi.mocked(networking.updateMCPServer).mock.calls[0][1];
};
it("renders the BYOK toggle on for an existing BYOK server and hides the shared auth value field", async () => {
render(
<MCPServerEdit
mcpServer={byokServer}
accessToken={null}
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await waitFor(() => expect(getByokSwitch()).toBeTruthy());
expect(getByokSwitch()).toHaveAttribute("aria-checked", "true");
expect(screen.queryByText("Authentication Value")).not.toBeInTheDocument();
});
it("persists is_byok=true when saving an existing BYOK server", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue(byokServer as any);
render(
<MCPServerEdit
mcpServer={byokServer}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await waitFor(() => expect(getByokSwitch()).toBeTruthy());
const payload = await save();
expect(payload.is_byok).toBe(true);
expect(payload.credentials).toBeUndefined();
});
it("persists is_byok=false and restores the shared auth value field when BYOK is toggled off", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...byokServer, is_byok: false } as any);
render(
<MCPServerEdit
mcpServer={byokServer}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await waitFor(() => expect(getByokSwitch()).toBeTruthy());
await act(async () => {
fireEvent.click(getByokSwitch()!);
});
await waitFor(() => expect(screen.getByText("Authentication Value")).toBeInTheDocument());
const payload = await save();
expect(payload.is_byok).toBe(false);
});
});

View file

@ -18,6 +18,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import ByokFields from "./ByokFields";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars } from "./utils";
@ -65,6 +66,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isByokRaw = Form.useWatch("is_byok", form);
const isByok = shouldShowAuthValueField && Boolean(isByokRaw);
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
@ -587,6 +590,14 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
restValues.transport = "http";
}
// BYOK is only valid for static-credential auth types; drop a stale ``true``
// (and its metadata) left behind by switching auth_type after toggling it on.
const isByokEnabled =
AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(restValues.auth_type) && Boolean(restValues.is_byok);
restValues.is_byok = isByokEnabled;
restValues.byok_description = isByokEnabled ? restValues.byok_description || [] : [];
restValues.byok_api_key_help_url = isByokEnabled ? restValues.byok_api_key_help_url || null : null;
// Parse token_validation JSON if provided
let tokenValidation: Record<string, any> | null = null;
if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
@ -670,7 +681,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
};
const includeCredentials =
restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
!isByokEnabled && restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
if (includeCredentials && credentialsPayload && Object.keys(credentialsPayload).length > 0) {
payload.credentials = credentialsPayload;
@ -824,6 +835,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</Form.Item>
)}
{!isStdioTransport && shouldShowAuthValueField && <ByokFields form={form} />}
{isStdioTransport && (
<div className="rounded-lg border border-gray-200 p-4 space-y-4">
<p className="text-sm text-gray-600">
@ -884,7 +897,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</div>
)}
{!isStdioTransport && shouldShowAuthValueField && (
{!isStdioTransport && shouldShowAuthValueField && !isByok && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">