feat(site/src/pages/AgentsPage): UI for per-user MCP custom_headers

Adds the web UI for the per-user custom_headers feature shipped in
earlier stack PRs.

Admin panel:

- MCPServerAdminPanel lets admins mark a subset of custom_headers
  keys as "user-set", with optional helper descriptions per key.
  Validation mirrors the backend (disjoint from admin-static
  CustomHeaders, no duplicates, only for AuthType custom_headers).
- The Auth section shows which keys are user-set vs admin-set so
  the admin understands at a glance which values they need to
  supply.

User-facing settings:

- New /agents/settings/mcp-servers page (registered in router.tsx,
  reachable from the chat-settings panel) lists every MCP server
  with user-set custom_headers and lets each user enter and save
  their per-key values. Empty values clear individual keys; a
  delete action clears all values for one server.
- MCPServerPicker surfaces a "needs setup" badge when an enabled
  server has user-set keys without stored values, plus a link to
  the new settings page.
- AgentChatInput propagates the badge to the MCP picker drawer so
  the user sees the prompt before sending a message.

API client:

- api.ts grows mcpServerUserHeaderValues, updateMCPServerUserHeaderValues,
  and deleteMCPServerUserHeaderValues wrappers for the three new
  experimental endpoints.
- queries/chats.ts wires the wrappers into TanStack Query for the
  pages above.

Stack: 5/6 (frontend)
This commit is contained in:
Steven Masley
2026-06-01 15:04:55 +00:00
parent d2f9ad783e
commit e6a970cd8d
14 changed files with 1578 additions and 139 deletions
+26
View File
@@ -3845,6 +3845,32 @@ class ExperimentalApiMethods {
);
};
getMCPServerUserHeaderValues = async (
id: string,
): Promise<TypesGen.MCPServerUserHeaderValues> => {
const response = await this.axios.get<TypesGen.MCPServerUserHeaderValues>(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`,
);
return response.data;
};
updateMCPServerUserHeaderValues = async (
id: string,
req: TypesGen.UpdateMCPServerUserHeaderValuesRequest,
): Promise<TypesGen.MCPServerUserHeaderValues> => {
const response = await this.axios.put<TypesGen.MCPServerUserHeaderValues>(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`,
req,
);
return response.data;
};
deleteMCPServerUserHeaderValues = async (id: string): Promise<void> => {
await this.axios.delete(
`${mcpServerConfigsPath}/${encodeURIComponent(id)}/user-headers`,
);
};
getChatCostSummary = async (
user = "me",
params?: ChatCostDateParams,
+43
View File
@@ -2037,6 +2037,49 @@ export const deleteMCPServerConfig = (queryClient: QueryClient) => ({
},
});
const mcpServerUserHeaderValuesKey = (id: string) =>
["mcp-server-user-header-values", id] as const;
export const mcpServerUserHeaderValues = (id: string) => ({
queryKey: mcpServerUserHeaderValuesKey(id),
queryFn: (): Promise<TypesGen.MCPServerUserHeaderValues> =>
API.experimental.getMCPServerUserHeaderValues(id),
});
type UpdateMCPServerUserHeaderValuesArgs = {
id: string;
req: TypesGen.UpdateMCPServerUserHeaderValuesRequest;
};
export const updateMCPServerUserHeaderValues = (queryClient: QueryClient) => ({
mutationFn: ({ id, req }: UpdateMCPServerUserHeaderValuesArgs) =>
API.experimental.updateMCPServerUserHeaderValues(id, req),
onSuccess: async (
_data: unknown,
variables: UpdateMCPServerUserHeaderValuesArgs,
) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: mcpServerUserHeaderValuesKey(variables.id),
}),
invalidateMCPServerConfigQueries(queryClient),
]);
},
});
export const deleteMCPServerUserHeaderValues = (queryClient: QueryClient) => ({
mutationFn: (id: string) =>
API.experimental.deleteMCPServerUserHeaderValues(id),
onSuccess: async (_data: unknown, id: string) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: mcpServerUserHeaderValuesKey(id),
}),
invalidateMCPServerConfigQueries(queryClient),
]);
},
});
type SetChatUserRoleVariables = {
chatId: string;
userId: string;
@@ -0,0 +1,147 @@
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "react-query";
import {
deleteMCPServerUserHeaderValues,
mcpServerConfigs,
mcpServerConfigsKey,
mcpServerUserHeaderValues,
updateMCPServerUserHeaderValues,
} from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import {
AgentSettingsUserMCPServersPageView,
filterUserConfigurableServers,
} from "./AgentSettingsUserMCPServersPageView";
const AgentSettingsUserMCPServersPage: FC = () => {
const queryClient = useQueryClient();
const serversQuery = useQuery(mcpServerConfigs());
// Only servers with custom_headers that require user-supplied values need
// the per-user header status fetch. OAuth2 status comes from the existing
// `auth_connected` field on the server config.
const visibleServers = useMemo(
() => filterUserConfigurableServers(serversQuery.data ?? []),
[serversQuery.data],
);
const customHeaderServers = useMemo(
() => visibleServers.filter((s) => s.auth_type === "custom_headers"),
[visibleServers],
);
const headerQueries = useQueries({
queries: customHeaderServers.map((server) => ({
...mcpServerUserHeaderValues(server.id),
})),
});
const headerValueStatus = useMemo(() => {
const out: Record<string, Record<string, boolean>> = {};
customHeaderServers.forEach((server, index) => {
const data = headerQueries[index]?.data;
if (data) {
out[server.id] = data.has_values ?? {};
}
});
return out;
}, [customHeaderServers, headerQueries]);
const loadingHeaderStatusIds = useMemo(() => {
const ids = new Set<string>();
customHeaderServers.forEach((server, index) => {
if (headerQueries[index]?.isLoading) {
ids.add(server.id);
}
});
return ids;
}, [customHeaderServers, headerQueries]);
const updateMutation = useMutation(
updateMCPServerUserHeaderValues(queryClient),
);
const deleteMutation = useMutation(
deleteMCPServerUserHeaderValues(queryClient),
);
const onSaveHeaderValues = useCallback(
async (
server: TypesGen.MCPServerConfig,
values: Record<string, string>,
) => {
await updateMutation.mutateAsync({ id: server.id, req: { values } });
},
[updateMutation],
);
const onClearHeaderValues = useCallback(
async (server: TypesGen.MCPServerConfig) => {
await deleteMutation.mutateAsync(server.id);
},
[deleteMutation],
);
// Reset the inline error state from the previous configure-dialog
// session so the next user does not see a stale failure banner.
const onResetSaveHeaderValuesError = useCallback(() => {
updateMutation.reset();
deleteMutation.reset();
}, [updateMutation, deleteMutation]);
// OAuth2 connect: open the experimental MCP OAuth2 connect URL in a popup
// and refresh the server list when the popup posts back.
const [oauth2PopupRef, setOAuth2PopupRef] = useState<Window | null>(null);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== location.origin) return;
if (
event.data?.type === "mcp-oauth2-complete" &&
typeof event.data.serverID === "string"
) {
setOAuth2PopupRef(null);
void queryClient.invalidateQueries({ queryKey: mcpServerConfigsKey });
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [queryClient]);
useEffect(() => {
if (!oauth2PopupRef) return;
const interval = setInterval(() => {
if (oauth2PopupRef.closed) {
setOAuth2PopupRef(null);
}
}, 500);
return () => clearInterval(interval);
}, [oauth2PopupRef]);
const onConnectOAuth2 = useCallback((server: TypesGen.MCPServerConfig) => {
const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`;
const popup = window.open(connectUrl, "_blank", "width=900,height=600");
if (popup) {
setOAuth2PopupRef(popup);
}
}, []);
return (
<AgentSettingsUserMCPServersPageView
servers={serversQuery.data}
isLoadingServers={serversQuery.isLoading}
serversError={serversQuery.error}
headerValueStatus={headerValueStatus}
loadingHeaderStatusIds={loadingHeaderStatusIds}
onConnectOAuth2={onConnectOAuth2}
onSaveHeaderValues={onSaveHeaderValues}
onClearHeaderValues={onClearHeaderValues}
isSavingHeaderValues={updateMutation.isPending}
isClearingHeaderValues={deleteMutation.isPending}
saveHeaderValuesError={updateMutation.error ?? deleteMutation.error}
onResetSaveHeaderValuesError={onResetSaveHeaderValuesError}
/>
);
};
export default AgentSettingsUserMCPServersPage;
@@ -0,0 +1,305 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import {
AgentSettingsUserMCPServersPageView,
type AgentSettingsUserMCPServersPageViewProps,
} from "./AgentSettingsUserMCPServersPageView";
const now = "2026-03-19T12:00:00.000Z";
const createServerConfig = (
overrides: Partial<TypesGen.MCPServerConfig> &
Pick<TypesGen.MCPServerConfig, "id" | "display_name" | "slug">,
): TypesGen.MCPServerConfig => ({
id: overrides.id,
display_name: overrides.display_name,
slug: overrides.slug,
description: overrides.description ?? "",
icon_url: overrides.icon_url ?? "",
transport: overrides.transport ?? "streamable_http",
url: overrides.url ?? "https://mcp.example.com/sse",
auth_type: overrides.auth_type ?? "none",
oauth2_client_id: overrides.oauth2_client_id,
has_oauth2_secret: overrides.has_oauth2_secret ?? false,
oauth2_auth_url: overrides.oauth2_auth_url,
oauth2_token_url: overrides.oauth2_token_url,
oauth2_scopes: overrides.oauth2_scopes,
api_key_header: overrides.api_key_header,
has_api_key: overrides.has_api_key ?? false,
has_custom_headers: overrides.has_custom_headers ?? false,
custom_headers_user_keys: overrides.custom_headers_user_keys ?? [],
custom_headers_user_key_descriptions:
overrides.custom_headers_user_key_descriptions ?? {},
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
model_intent: overrides.model_intent ?? false,
allow_in_plan_mode: overrides.allow_in_plan_mode ?? false,
forward_coder_headers: overrides.forward_coder_headers ?? false,
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
auth_connected: overrides.auth_connected ?? false,
});
const meta: Meta<typeof AgentSettingsUserMCPServersPageView> = {
title: "pages/AgentsPage/AgentSettingsUserMCPServersPageView",
component: AgentSettingsUserMCPServersPageView,
args: {
servers: [],
isLoadingServers: false,
serversError: null,
headerValueStatus: {},
loadingHeaderStatusIds: new Set(),
onConnectOAuth2: fn(),
onSaveHeaderValues: fn(async () => undefined),
onClearHeaderValues: fn(async () => undefined),
isSavingHeaderValues: false,
isClearingHeaderValues: false,
saveHeaderValuesError: null,
} satisfies AgentSettingsUserMCPServersPageViewProps,
};
export default meta;
type Story = StoryObj<typeof AgentSettingsUserMCPServersPageView>;
// ── Stories ────────────────────────────────────────────────────
export const NoServersRequireAction: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-none",
display_name: "Quiet Server",
slug: "quiet",
auth_type: "none",
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
expect(
body.getByText(/No MCP servers require your action/i),
).toBeInTheDocument();
});
},
};
export const Loading: Story = {
args: {
servers: undefined,
isLoadingServers: true,
},
};
export const ServersError: Story = {
args: {
servers: undefined,
serversError: new Error("Boom"),
},
};
export const OAuth2NeedsConnect: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-github",
display_name: "GitHub MCP",
slug: "github",
auth_type: "oauth2",
auth_connected: false,
}),
],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Connect/i }),
);
expect(args.onConnectOAuth2).toHaveBeenCalledTimes(1);
},
};
export const OAuth2Connected: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-github",
display_name: "GitHub MCP",
slug: "github",
auth_type: "oauth2",
auth_connected: true,
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
expect(body.getByText(/Signed in/i)).toBeInTheDocument();
});
},
};
export const CustomHeadersNotConfigured: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User"],
auth_connected: false,
}),
],
headerValueStatus: {
"mcp-honcho": { "X-Honcho-User": false },
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
expect(body.getByText(/Action required/i)).toBeInTheDocument();
});
},
};
export const CustomHeadersAllSet: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User"],
auth_connected: true,
}),
],
headerValueStatus: {
"mcp-honcho": { "X-Honcho-User": true },
},
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await waitFor(() => {
expect(body.getByText(/Connected/i)).toBeInTheDocument();
});
},
};
export const ConfigureSavesValues: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User", "X-Honcho-Session"],
custom_headers_user_key_descriptions: {
"X-Honcho-User":
"Your Honcho user ID, copy it from your Honcho profile.",
},
auth_connected: false,
}),
],
headerValueStatus: {
"mcp-honcho": { "X-Honcho-User": false, "X-Honcho-Session": false },
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Configure/i }),
);
// The admin-supplied description shows above the matching input.
expect(await body.findByText(/Your Honcho user ID/i)).toBeInTheDocument();
const userInput = await body.findByLabelText(/X-Honcho-User value/i);
await userEvent.type(userInput, "user-jwt-abc");
const sessionInput = body.getByLabelText(/X-Honcho-Session value/i);
await userEvent.type(sessionInput, "session-jwt-def");
await userEvent.click(body.getByRole("button", { name: /^Save$/i }));
await waitFor(() => {
expect(args.onSaveHeaderValues).toHaveBeenCalledTimes(1);
});
const call = (args.onSaveHeaderValues as ReturnType<typeof fn>).mock
.calls[0];
expect(call?.[1]).toEqual({
"X-Honcho-User": "user-jwt-abc",
"X-Honcho-Session": "session-jwt-def",
});
},
};
export const ConfigureClearsValues: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User"],
auth_connected: true,
}),
],
headerValueStatus: {
"mcp-honcho": { "X-Honcho-User": true },
},
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Edit/i }));
// Clear button appears because at least one value is set.
const clearButton = await body.findByRole("button", { name: /Clear all/i });
await userEvent.click(clearButton);
await waitFor(() => {
expect(args.onClearHeaderValues).toHaveBeenCalledTimes(1);
});
},
};
export const ConfigureSaveError: Story = {
args: {
servers: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User"],
auth_connected: false,
}),
],
headerValueStatus: {
"mcp-honcho": { "X-Honcho-User": false },
},
saveHeaderValuesError: "Server returned 500",
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Configure/i }),
);
await waitFor(() => {
expect(body.getByText(/Server returned 500/i)).toBeInTheDocument();
});
},
};
@@ -0,0 +1,448 @@
import { ServerIcon } from "lucide-react";
import { type FC, useEffect, useId, useMemo, useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { Input } from "#/components/Input/Input";
import { Loader } from "#/components/Loader/Loader";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { cn } from "#/utils/cn";
// ── Helpers ────────────────────────────────────────────────────
/**
* A server is shown to the user when it is enabled AND requires
* per-user action: either OAuth2 sign-in, or admin-marked
* custom-header keys for the user to supply.
*/
export const filterUserConfigurableServers = (
servers: readonly TypesGen.MCPServerConfig[],
): TypesGen.MCPServerConfig[] =>
servers.filter(
(s) =>
s.enabled &&
(s.auth_type === "oauth2" ||
(s.auth_type === "custom_headers" &&
(s.custom_headers_user_keys?.length ?? 0) > 0)),
);
// ── Server icon ────────────────────────────────────────────────
const MCPServerIcon: FC<{ iconUrl: string; name: string }> = ({
iconUrl,
name,
}) => {
if (iconUrl) {
return (
<ExternalImage
src={iconUrl}
alt={name}
className="size-6 rounded-sm object-cover"
/>
);
}
return (
<ServerIcon aria-hidden="true" className="size-6 text-content-secondary" />
);
};
// ── Props ──────────────────────────────────────────────────────
export interface AgentSettingsUserMCPServersPageViewProps {
readonly servers: readonly TypesGen.MCPServerConfig[] | undefined;
readonly isLoadingServers: boolean;
readonly serversError: unknown;
/** Map of server id → `has_values` reported by the API. */
readonly headerValueStatus: Readonly<Record<string, Record<string, boolean>>>;
/** Server ids whose header status is still loading. */
readonly loadingHeaderStatusIds: ReadonlySet<string>;
readonly onConnectOAuth2: (server: TypesGen.MCPServerConfig) => void;
readonly onSaveHeaderValues: (
server: TypesGen.MCPServerConfig,
values: Record<string, string>,
) => Promise<unknown>;
readonly onClearHeaderValues: (
server: TypesGen.MCPServerConfig,
) => Promise<unknown>;
readonly isSavingHeaderValues: boolean;
readonly isClearingHeaderValues: boolean;
readonly saveHeaderValuesError: unknown;
/** Clear stale mutation error state when a configure dialog opens. */
readonly onResetSaveHeaderValuesError?: () => void;
}
// ── Page view ──────────────────────────────────────────────────
export const AgentSettingsUserMCPServersPageView: FC<
AgentSettingsUserMCPServersPageViewProps
> = ({
servers,
isLoadingServers,
serversError,
headerValueStatus,
loadingHeaderStatusIds,
onConnectOAuth2,
onSaveHeaderValues,
onClearHeaderValues,
isSavingHeaderValues,
isClearingHeaderValues,
saveHeaderValuesError,
onResetSaveHeaderValuesError,
}) => {
const [configuringServer, setConfiguringServer] =
useState<TypesGen.MCPServerConfig | null>(null);
const visibleServers = useMemo(
() => filterUserConfigurableServers(servers ?? []),
[servers],
);
if (serversError) {
return <ErrorAlert error={serversError} />;
}
if (isLoadingServers || !servers) {
return <Loader />;
}
return (
<div className="space-y-4">
<header className="space-y-1">
<h2 className="m-0 text-base font-semibold text-content-primary">
MCP Servers
</h2>
<p className="m-0 text-sm text-content-secondary">
Connect or supply per-user credentials for MCP servers that require
action from you. Admin-set values are managed by your workspace
administrator.
</p>
</header>
<Table>
<TableHeader>
<TableRow>
<TableHead>Server</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[1%] text-right">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleServers.length === 0 ? (
<TableEmpty message="No MCP servers require your action right now." />
) : (
visibleServers.map((server) => (
<ServerRow
key={server.id}
server={server}
statusLoading={loadingHeaderStatusIds.has(server.id)}
headerHasValues={headerValueStatus[server.id]}
onConnectOAuth2={() => onConnectOAuth2(server)}
onConfigure={() => setConfiguringServer(server)}
/>
))
)}
</TableBody>
</Table>
<ConfigureHeadersDialog
server={configuringServer}
headerHasValues={
configuringServer
? headerValueStatus[configuringServer.id]
: undefined
}
onClose={() => setConfiguringServer(null)}
onOpen={onResetSaveHeaderValuesError}
onSave={onSaveHeaderValues}
onClear={onClearHeaderValues}
isSaving={isSavingHeaderValues}
isClearing={isClearingHeaderValues}
error={saveHeaderValuesError}
/>
</div>
);
};
// ── Server row ─────────────────────────────────────────────────
interface ServerRowProps {
readonly server: TypesGen.MCPServerConfig;
readonly statusLoading: boolean;
readonly headerHasValues: Record<string, boolean> | undefined;
readonly onConnectOAuth2: () => void;
readonly onConfigure: () => void;
}
const ServerRow: FC<ServerRowProps> = ({
server,
statusLoading,
headerHasValues,
onConnectOAuth2,
onConfigure,
}) => {
const isOAuth2 = server.auth_type === "oauth2";
const isCustomHeaders = server.auth_type === "custom_headers";
const requiredKeys = server.custom_headers_user_keys ?? [];
const hasAllValues =
isCustomHeaders &&
requiredKeys.length > 0 &&
requiredKeys.every((k) => headerHasValues?.[k] === true);
const connected = isOAuth2 ? server.auth_connected : hasAllValues;
const statusText = connected ? "Connected" : "Action required";
const statusClass = connected
? "text-content-success"
: "text-content-warning";
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2.5">
<MCPServerIcon iconUrl={server.icon_url} name={server.display_name} />
<div className="flex flex-col">
<span className="font-semibold text-content-primary">
{server.display_name}
</span>
{server.description && (
<span className="text-xs text-content-secondary">
{server.description}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
{statusLoading ? (
<Spinner loading />
) : (
<span className={cn("text-sm font-medium", statusClass)}>
{statusText}
</span>
)}
</TableCell>
<TableCell className="text-right">
{isOAuth2 && !server.auth_connected && (
<Button size="sm" onClick={onConnectOAuth2}>
Connect
</Button>
)}
{isOAuth2 && server.auth_connected && (
<span className="text-sm text-content-secondary">Signed in</span>
)}
{isCustomHeaders && (
<Button size="sm" variant="outline" onClick={onConfigure}>
{connected ? "Edit" : "Configure"}
</Button>
)}
</TableCell>
</TableRow>
);
};
// ── Configure dialog ───────────────────────────────────────────
interface ConfigureHeadersDialogProps {
readonly server: TypesGen.MCPServerConfig | null;
readonly headerHasValues: Record<string, boolean> | undefined;
readonly onClose: () => void;
/** Called once when a new dialog session begins so the parent can
* reset stale mutation error/state from the previous session. */
readonly onOpen?: () => void;
readonly onSave: (
server: TypesGen.MCPServerConfig,
values: Record<string, string>,
) => Promise<unknown>;
readonly onClear: (server: TypesGen.MCPServerConfig) => Promise<unknown>;
readonly isSaving: boolean;
readonly isClearing: boolean;
readonly error: unknown;
}
const ConfigureHeadersDialog: FC<ConfigureHeadersDialogProps> = ({
server,
headerHasValues,
onClose,
onOpen,
onSave,
onClear,
isSaving,
isClearing,
error,
}) => {
const inputIdPrefix = useId();
const requiredKeys = useMemo(
() => server?.custom_headers_user_keys ?? [],
[server],
);
const descriptions = useMemo(
() => server?.custom_headers_user_key_descriptions ?? {},
[server],
);
const [draft, setDraft] = useState<Record<string, string>>({});
const open = server !== null;
// When a new server is opened, reset the parent mutation state so a
// previous server's error banner does not bleed into this session.
const openServerId = server?.id ?? null;
// biome-ignore lint/correctness/useExhaustiveDependencies: only fire when the open server identity changes, not on every render of the onOpen callback reference
useEffect(() => {
if (openServerId !== null) {
onOpen?.();
}
}, [openServerId]);
const hasAnyExisting = requiredKeys.some(
(k) => headerHasValues?.[k] === true,
);
const isBusy = isSaving || isClearing;
const handleSave = async () => {
if (!server) return;
const values: Record<string, string> = {};
for (const key of requiredKeys) {
const v = draft[key];
if (typeof v === "string" && v !== "") {
values[key] = v;
}
}
try {
await onSave(server, values);
} catch {
// Error is surfaced via the `error` prop; keep the dialog open
// so the user can correct and retry.
return;
}
setDraft({});
onClose();
};
const handleClear = async () => {
if (!server) return;
try {
await onClear(server);
} catch {
// Error is surfaced via the `error` prop; keep the dialog open
// so the user can retry.
return;
}
setDraft({});
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraft({});
onClose();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Configure {server?.display_name ?? "MCP server"}
</DialogTitle>
<DialogDescription>
Provide values for the per-user headers required by this server.
Leave a field blank to keep the existing value.
</DialogDescription>
</DialogHeader>
{error ? <ErrorAlert error={error} /> : null}
<form
id="mcp-user-headers-form"
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void handleSave();
}}
>
{requiredKeys.map((key) => {
const existing = headerHasValues?.[key] === true;
const inputId = `${inputIdPrefix}-${key}`;
const description = descriptions[key];
return (
<div key={key} className="space-y-1">
<label
htmlFor={inputId}
className="text-xs font-medium text-content-primary"
>
{key}
{existing && (
<span className="ml-2 text-[10px] uppercase tracking-wide text-content-secondary">
value set
</span>
)}
</label>
{description && (
<p className="m-0 text-xs text-content-secondary">
{description}
</p>
)}
<Input
id={inputId}
className="font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={draft[key] ?? ""}
onChange={(e) =>
setDraft((d) => ({ ...d, [key]: e.target.value }))
}
placeholder={existing ? "Replace value" : "Enter value"}
aria-label={`${key} value`}
disabled={isBusy}
/>
</div>
);
})}
</form>
<DialogFooter>
{hasAnyExisting && (
<Button
variant="outline"
type="button"
onClick={() => void handleClear()}
disabled={isBusy}
>
<Spinner loading={isClearing} />
Clear all
</Button>
)}
<Button type="submit" form="mcp-user-headers-form" disabled={isBusy}>
<Spinner loading={isSaving} />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -701,11 +701,11 @@ const makeMCPServer = (
api_key_header: overrides.api_key_header,
has_api_key: overrides.has_api_key ?? false,
has_custom_headers: overrides.has_custom_headers ?? false,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
custom_headers_user_keys: overrides.custom_headers_user_keys ?? [],
custom_headers_user_key_descriptions:
overrides.custom_headers_user_key_descriptions ?? {},
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
model_intent: overrides.model_intent ?? false,
@@ -749,6 +749,23 @@ const githubMCP = makeMCPServer({
const githubMCPConnected = { ...githubMCP, auth_connected: true };
const honchoMCP = makeMCPServer({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
availability: "default_on",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User-Token"],
custom_headers_user_key_descriptions: {
"X-Honcho-User-Token": "Your personal Honcho API token.",
},
auth_connected: false,
enabled: true,
});
const honchoMCPConnected = { ...honchoMCP, auth_connected: true };
const mcpDefaults = {
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
@@ -774,6 +791,24 @@ export const WithMCPNeedingAuth: Story = {
},
};
/** MCP server needing custom-headers user values. Shows Configure button. */
export const WithMCPNeedingCustomHeaders: Story = {
args: {
...mcpDefaults,
mcpServers: [sentryMCP, honchoMCP],
selectedMCPServerIds: [sentryMCP.id, honchoMCP.id],
},
};
/** MCP server with custom_headers fully configured. Shows toggle. */
export const WithMCPCustomHeadersConnected: Story = {
args: {
...mcpDefaults,
mcpServers: [sentryMCP, honchoMCPConnected],
selectedMCPServerIds: [sentryMCP.id, honchoMCPConnected.id],
},
};
/** No MCP servers active — shows only "MCP" label with chevron. */
export const WithMCPNoneActive: Story = {
args: {
@@ -20,7 +20,7 @@ import {
useRef,
useState,
} from "react";
import { Link } from "react-router";
import { Link, useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import type {
AgentChatSendShortcut,
@@ -46,7 +46,6 @@ import {
import { Separator } from "#/components/Separator/Separator";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Tooltip,
TooltipContent,
@@ -80,6 +79,11 @@ import {
import type { AgentContextUsage } from "./ContextUsageIndicator";
import { ContextUsageIndicator } from "./ContextUsageIndicator";
import { ImageLightbox } from "./ImageLightbox";
import {
MCPServerAuthControl,
mcpServerNeedsAuth,
userMCPServersSettingsPath,
} from "./MCPServerPicker";
import { QueuedMessagesList } from "./QueuedMessagesList";
import { TextPreviewDialog } from "./TextPreviewDialog";
import { WorkspacePill } from "./WorkspacePill";
@@ -488,6 +492,13 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
);
};
const navigate = useNavigate();
const handleMcpConfigureHeaders = () => {
setPlusMenuOpen(false);
navigate(userMCPServersSettingsPath);
};
const selectedWorkspace = workspaceOptions?.find(
(ws) => ws.id === selectedWorkspaceId,
);
@@ -501,7 +512,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const activeMcpServers = enabledMcpServers.filter(
(s) =>
(s.availability === "force_on" || selectedMCPServerIds?.includes(s.id)) &&
!(s.auth_type === "oauth2" && !s.auth_connected),
!mcpServerNeedsAuth(s),
);
const badgeContainerRef = useRef<HTMLDivElement>(null);
@@ -1254,9 +1265,6 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
isForceOn ||
(selectedMCPServerIds?.includes(server.id) ??
false);
const needsAuth =
server.auth_type === "oauth2" &&
!server.auth_connected;
const isConnecting = mcpConnectingId === server.id;
return (
<div
@@ -1275,32 +1283,18 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<span className="min-w-0 flex-1 truncate text-xs text-content-secondary">
{server.display_name}
</span>
{needsAuth ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-[10px] leading-none"
onClick={() => handleMcpConnect(server)}
disabled={
isDisabled || mcpConnectingId !== null
}
>
{isConnecting ? (
<Spinner loading className="h-2.5 w-2.5" />
) : null}
Auth
</Button>
) : (
<Switch
size="sm"
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
)}
<MCPServerAuthControl
server={server}
isSelected={isSelected}
isConnecting={isConnecting}
disabled={isDisabled}
connectingDisabled={mcpConnectingId !== null}
forceOn={isForceOn}
onConnect={handleMcpConnect}
onConfigure={handleMcpConfigureHeaders}
onToggle={handleMcpToggle}
switchSize="sm"
/>
</div>
);
})}
@@ -1248,10 +1248,10 @@ const sampleMCPServers = [
has_oauth2_secret: false,
has_api_key: false,
has_custom_headers: false,
tool_allow_list: [],
tool_deny_list: [],
custom_headers_user_keys: [],
custom_headers_user_key_descriptions: {},
tool_allow_list: [],
tool_deny_list: [],
availability: "default_on",
enabled: true,
model_intent: false,
@@ -143,6 +143,13 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
state={location.state}
/>
)}
<SettingsNavItem
icon={ServerIcon}
label="MCP Servers"
active={settingsSection === "user-mcp-servers"}
to="/agents/settings/user-mcp-servers"
state={location.state}
/>
{isAdmin && (
<SettingsNavItem
icon={Settings2Icon}
@@ -28,11 +28,11 @@ const createServerConfig = (
api_key_header: overrides.api_key_header,
has_api_key: overrides.has_api_key ?? false,
has_custom_headers: overrides.has_custom_headers ?? false,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
custom_headers_user_keys: overrides.custom_headers_user_keys ?? [],
custom_headers_user_key_descriptions:
overrides.custom_headers_user_key_descriptions ?? {},
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
model_intent: overrides.model_intent ?? false,
@@ -754,3 +754,134 @@ export const CreateServerUserOIDC: Story = {
expect(call).not.toHaveProperty("custom_headers");
},
};
/**
* Create a server with mixed admin and user-set custom headers.
* Verifies the submission splits the rows into `custom_headers` and
* `custom_headers_user_keys`.
*/
export const CreateServerCustomHeadersWithUserSet: Story = {
args: {
serversData: [],
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(
await body.findByRole("button", { name: /Add your first server/i }),
);
await userEvent.type(await body.findByLabelText(/Display Name/i), "Honcho");
await userEvent.type(
body.getByLabelText(/Server URL/i),
"https://api.honcho.dev/mcp",
);
await userEvent.click(
await body.findByRole("button", { name: /Authentication/i }),
);
await userEvent.click(body.getByLabelText(/Authentication/i));
await userEvent.click(
await body.findByRole("option", { name: /Custom Headers/i }),
);
// Row 1: admin-set header.
await userEvent.click(
await body.findByRole("button", { name: /Add header/i }),
);
await userEvent.type(body.getByLabelText(/Header 1 name/i), "X-Honcho-App");
await userEvent.type(
body.getByLabelText(/Header 1 value/i),
"shared-app-id",
);
// Row 2: user-set header.
await userEvent.click(body.getByRole("button", { name: /Add header/i }));
await userEvent.type(
body.getByLabelText(/Header 2 name/i),
"X-Honcho-User",
);
await userEvent.click(body.getByLabelText(/Header 2 user-set/i));
// Row 2 should now show the placeholder instead of a value input.
expect(
body.getByLabelText(/Header 2 value \(set by user\)/i),
).toHaveTextContent(/Set by each user/i);
// Fill in an optional description for the user-set row.
await userEvent.type(
body.getByLabelText(/Description \(optional\)/i),
"Your Honcho user ID.",
);
await userEvent.click(body.getByRole("button", { name: /Create server/i }));
await waitFor(() => {
expect(args.onCreateServer).toHaveBeenCalledTimes(1);
});
expect(args.onCreateServer).toHaveBeenCalledWith(
expect.objectContaining({
auth_type: "custom_headers",
custom_headers: { "X-Honcho-App": "shared-app-id" },
custom_headers_user_keys: ["X-Honcho-User"],
custom_headers_user_key_descriptions: {
"X-Honcho-User": "Your Honcho user ID.",
},
}),
);
},
};
/**
* Editing a server that already has user-set keys pre-populates the rows
* in `User-set` mode (value hidden behind a placeholder).
*/
export const EditServerWithCustomHeadersUserKeys: Story = {
args: {
serversData: [
createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
url: "https://api.honcho.dev/mcp",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User"],
custom_headers_user_key_descriptions: {
"X-Honcho-User": "Your Honcho user ID.",
},
availability: "default_on",
enabled: true,
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(await body.findByRole("button", { name: /Honcho/ }));
// Open the Authentication section.
await userEvent.click(
await body.findByRole("button", { name: /Authentication/i }),
);
// Pre-populated user-set row should be visible with the key.
await waitFor(() => {
expect(body.getByLabelText(/Header 1 name/i)).toHaveValue(
"X-Honcho-User",
);
});
// User-set checkbox should be checked and the value field hidden.
expect(body.getByLabelText(/Header 1 user-set/i)).toBeChecked();
expect(
body.getByLabelText(/Header 1 value \(set by user\)/i),
).toBeInTheDocument();
expect(body.queryByLabelText(/^Header 1 value$/i)).not.toBeInTheDocument();
// Description for the pre-populated key should be displayed.
expect(body.getByLabelText(/Description \(optional\)/i)).toHaveValue(
"Your Honcho user ID.",
);
},
};
@@ -16,6 +16,7 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ChevronDownIcon as AnimatedChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import {
Collapsible,
CollapsibleContent,
@@ -350,7 +351,12 @@ interface MCPServerFormValues {
forwardCoderHeaders: boolean;
toolAllowList: string;
toolDenyList: string;
customHeaders: Array<{ key: string; value: string }>;
customHeaders: Array<{
key: string;
value: string;
userSet: boolean;
description: string;
}>;
customHeadersTouched: boolean;
}
@@ -381,7 +387,12 @@ const buildInitialValues = (
forwardCoderHeaders: server?.forward_coder_headers ?? false,
toolAllowList: joinList(server?.tool_allow_list),
toolDenyList: joinList(server?.tool_deny_list),
customHeaders: [],
customHeaders: (server?.custom_headers_user_keys ?? []).map((k) => ({
key: k,
value: "",
userSet: true,
description: server?.custom_headers_user_key_descriptions?.[k] ?? "",
})),
customHeadersTouched: false,
});
@@ -453,9 +464,22 @@ const ServerForm: FC<ServerFormProps> = ({
values.customHeadersTouched && {
custom_headers: Object.fromEntries(
values.customHeaders
.filter((h) => h.key.trim() !== "")
.filter((h) => !h.userSet && h.key.trim() !== "")
.map((h) => [h.key.trim(), h.value]),
),
custom_headers_user_keys: values.customHeaders
.filter((h) => h.userSet && h.key.trim() !== "")
.map((h) => h.key.trim()),
custom_headers_user_key_descriptions: Object.fromEntries(
values.customHeaders
.filter(
(h) =>
h.userSet &&
h.key.trim() !== "" &&
h.description.trim() !== "",
)
.map((h) => [h.key.trim(), h.description.trim()]),
),
}),
tool_allow_list: splitList(values.toolAllowList),
tool_deny_list: splitList(values.toolDenyList),
@@ -819,73 +843,180 @@ const ServerForm: FC<ServerFormProps> = ({
{server?.has_custom_headers &&
!form.values.customHeadersTouched && (
<p className="m-0 text-xs text-content-secondary">
This server has custom headers configured. Add headers
below to replace them.
This server has custom headers configured. Editing
replaces all of them (admin and user-set).
</p>
)}
{form.values.customHeaders.map((header, index) => (
<div key={index} className="flex items-start gap-2">
<div className="grid flex-1 items-start gap-2 sm:grid-cols-2">
<Input
className="h-9 text-[13px]"
value={header.key}
onChange={(e) => {
form.setFieldValue("customHeadersTouched", true);
const updated = [...form.values.customHeaders];
updated[index] = {
...updated[index],
key: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="Header name"
disabled={isDisabled}
aria-label={`Header ${index + 1} name`}
/>
<Input
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={header.value}
onChange={(e) => {
form.setFieldValue("customHeadersTouched", true);
const updated = [...form.values.customHeaders];
updated[index] = {
...updated[index],
value: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="Header value"
disabled={isDisabled}
aria-label={`Header ${index + 1} value`}
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
className="mt-0 size-9 shrink-0"
onClick={() => {
form.setFieldValue("customHeadersTouched", true);
form.setFieldValue(
"customHeaders",
form.values.customHeaders.filter(
(_, i) => i !== index,
),
);
}}
disabled={isDisabled}
aria-label={`Remove header ${index + 1}`}
<p className="m-0 text-xs text-content-secondary">
Mark a row as <strong>User-set</strong> to let each user
supply the value in their MCP server settings. Admin
values are shared across all users.
</p>
{form.values.customHeaders.map((header, index) => {
const userSetId = `${formId}-userset-${index}`;
const descriptionId = `${formId}-userset-desc-${index}`;
return (
<div
key={index}
className="flex flex-col gap-2 rounded-md border border-solid border-border/50 bg-surface-primary/40 p-2"
>
<XIcon className="size-4" />
</Button>
</div>
))}
<div className="flex items-start gap-2">
<div className="grid flex-1 items-start gap-2 sm:grid-cols-2">
<Input
className="h-9 text-[13px]"
value={header.key}
onChange={(e) => {
form.setFieldValue(
"customHeadersTouched",
true,
);
const updated = [
...form.values.customHeaders,
];
updated[index] = {
...updated[index],
key: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="Header name"
disabled={isDisabled}
aria-label={`Header ${index + 1} name`}
/>
{header.userSet ? (
<div
className="flex h-9 items-center rounded-md border border-dashed border-border/70 bg-surface-secondary/40 px-3 text-[13px] italic text-content-secondary"
role="textbox"
tabIndex={0}
aria-readonly="true"
aria-label={`Header ${index + 1} value (set by user)`}
>
Set by each user
</div>
) : (
<Input
className="h-9 font-mono text-[13px] [-webkit-text-security:disc]"
type="text"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
value={header.value}
onChange={(e) => {
form.setFieldValue(
"customHeadersTouched",
true,
);
const updated = [
...form.values.customHeaders,
];
updated[index] = {
...updated[index],
value: e.target.value,
};
form.setFieldValue(
"customHeaders",
updated,
);
}}
placeholder="Header value"
disabled={isDisabled}
aria-label={`Header ${index + 1} value`}
/>
)}
</div>
<label
htmlFor={userSetId}
className="flex h-9 cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-content-secondary"
>
<Checkbox
id={userSetId}
checked={header.userSet}
onCheckedChange={(checked) => {
form.setFieldValue(
"customHeadersTouched",
true,
);
const updated = [
...form.values.customHeaders,
];
const nextUserSet = checked === true;
updated[index] = {
...updated[index],
userSet: nextUserSet,
value: nextUserSet
? ""
: updated[index].value,
description: nextUserSet
? updated[index].description
: "",
};
form.setFieldValue("customHeaders", updated);
}}
disabled={isDisabled}
aria-label={`Header ${index + 1} user-set`}
/>
<span>User-set</span>
</label>
<Button
variant="outline"
size="icon"
type="button"
className="mt-0 size-9 shrink-0"
onClick={() => {
form.setFieldValue(
"customHeadersTouched",
true,
);
form.setFieldValue(
"customHeaders",
form.values.customHeaders.filter(
(_, i) => i !== index,
),
);
}}
disabled={isDisabled}
aria-label={`Remove header ${index + 1}`}
>
<XIcon className="size-4" />
</Button>
</div>
{header.userSet && (
<div className="flex flex-col gap-1">
<label
htmlFor={descriptionId}
className="text-xs text-content-secondary"
>
Description (optional) shown to users when they
fill in this header.
</label>
<Input
id={descriptionId}
className="h-9 text-[13px]"
value={header.description}
onChange={(e) => {
form.setFieldValue(
"customHeadersTouched",
true,
);
const updated = [
...form.values.customHeaders,
];
updated[index] = {
...updated[index],
description: e.target.value,
};
form.setFieldValue("customHeaders", updated);
}}
placeholder="e.g. Personal access token for upstream MCP server"
disabled={isDisabled}
/>
</div>
)}
</div>
);
})}
<Button
variant="outline"
size="sm"
@@ -894,7 +1025,12 @@ const ServerForm: FC<ServerFormProps> = ({
form.setFieldValue("customHeadersTouched", true);
form.setFieldValue("customHeaders", [
...form.values.customHeaders,
{ key: "", value: "" },
{
key: "",
value: "",
userSet: false,
description: "",
},
]);
}}
disabled={isDisabled}
@@ -1213,6 +1349,10 @@ export const MCPServerAdminPanel: FC<MCPServerAdminPanelProps> = ({
custom_headers_user_keys: req.custom_headers_user_keys
? [...req.custom_headers_user_keys]
: undefined,
custom_headers_user_key_descriptions:
req.custom_headers_user_key_descriptions
? { ...req.custom_headers_user_key_descriptions }
: undefined,
};
try {
await onUpdateServer({ id, req: updateReq });
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { expect, fn, screen, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { getDefaultMCPSelection, MCPServerPicker } from "./MCPServerPicker";
@@ -27,11 +27,11 @@ const createServerConfig = (
api_key_header: overrides.api_key_header,
has_api_key: overrides.has_api_key ?? false,
has_custom_headers: overrides.has_custom_headers ?? false,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
custom_headers_user_keys: overrides.custom_headers_user_keys ?? [],
custom_headers_user_key_descriptions:
overrides.custom_headers_user_key_descriptions ?? {},
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
model_intent: overrides.model_intent ?? false,
@@ -120,6 +120,29 @@ const datadogServer = createServerConfig({
auth_connected: false,
});
const honchoServer = createServerConfig({
id: "mcp-honcho",
display_name: "Honcho",
slug: "honcho",
description: "Persistent memory for agents",
url: "https://mcp.honcho.dev/v1",
transport: "streamable_http",
auth_type: "custom_headers",
has_custom_headers: true,
custom_headers_user_keys: ["X-Honcho-User-Token"],
custom_headers_user_key_descriptions: {
"X-Honcho-User-Token": "Your personal Honcho API token.",
},
availability: "default_on",
enabled: true,
auth_connected: false,
});
const honchoServerConnected = {
...honchoServer,
auth_connected: true,
};
const disabledServer = createServerConfig({
id: "mcp-disabled",
display_name: "Disabled Server",
@@ -215,6 +238,34 @@ export const OAuthConnected: Story = {
},
};
/** Custom-headers server needing user-supplied values. Shows Configure button. */
export const CustomHeadersNeedsConfig: Story = {
args: {
servers: [honchoServer],
selectedServerIds: [honchoServer.id],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole("button", { name: /MCP servers/i });
await userEvent.click(trigger);
// The popover content renders into a portal outside the canvas,
// so use the document-scoped screen helper to find the Configure
// button rendered against the honchoServer row.
const configure = await screen.findByRole("button", {
name: /Configure/i,
});
expect(configure).toBeInTheDocument();
},
};
/** Custom-headers server with all user values supplied. Shows toggle. */
export const CustomHeadersConnected: Story = {
args: {
servers: [honchoServerConnected],
selectedServerIds: [honchoServerConnected.id],
},
};
/** Multiple servers with mixed availability and auth states. */
export const MixedServers: Story = {
args: {
@@ -1,5 +1,6 @@
import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
@@ -91,6 +92,21 @@ export const getDefaultMCPSelection = (
return ids;
};
/**
* Returns true when the server cannot be used yet because the calling
* user still has authentication work to do: OAuth2 sign-in for
* `oauth2` servers, or user-supplied header values for
* `custom_headers` servers with admin-marked user keys. Used both to
* decide which control to render in the picker and to exclude
* unconnected servers from the trigger icon stack.
*/
export const mcpServerNeedsAuth = (server: TypesGen.MCPServerConfig): boolean =>
(server.auth_type === "oauth2" || server.auth_type === "custom_headers") &&
!server.auth_connected;
/** Route that hosts the user MCP settings page with the Configure dialog. */
export const userMCPServersSettingsPath = "/agents/settings/user-mcp-servers";
/** localStorage key for persisting the user's MCP server selection. */
export const mcpSelectionStorageKey = "agents.selected-mcp-server-ids";
@@ -181,6 +197,105 @@ const TriggerIconStack: FC<{
);
};
// ── Per-server auth/toggle control ─────────────────────────────
interface MCPServerAuthControlProps {
server: TypesGen.MCPServerConfig;
isSelected: boolean;
isConnecting: boolean;
disabled: boolean;
/** Disable the OAuth2 Auth button while another connect flow is in progress. */
connectingDisabled: boolean;
forceOn: boolean;
onConnect: (server: TypesGen.MCPServerConfig) => void;
onConfigure: () => void;
onToggle: (id: string, checked: boolean) => void;
/** Extra classes for the Auth/Configure button. */
buttonClassName?: string;
switchSize?: "sm";
}
/**
* Renders the right-hand control for a single MCP server row:
* - an `Auth` button when an OAuth2 server still needs sign-in,
* - a `Configure` button when a `custom_headers` server still needs
* per-user header values supplied,
* - otherwise a Switch toggle for inclusion in the conversation.
*
* Shared between `MCPServerPicker` (used in `AgentCreateForm`) and
* the inline MCP picker inside `AgentChatInput`'s plus menu.
*/
export const MCPServerAuthControl: FC<MCPServerAuthControlProps> = ({
server,
isSelected,
isConnecting,
disabled,
connectingDisabled,
forceOn,
onConnect,
onConfigure,
onToggle,
buttonClassName,
switchSize,
}) => {
const needsOAuth = server.auth_type === "oauth2" && !server.auth_connected;
const needsHeaderConfig =
server.auth_type === "custom_headers" && !server.auth_connected;
if (needsOAuth) {
return (
<Button
variant="outline"
size="sm"
className={cn(
"h-6 shrink-0 px-2 text-[10px] leading-none",
buttonClassName,
)}
onClick={(e) => {
e.stopPropagation();
onConnect(server);
}}
disabled={disabled || connectingDisabled}
aria-label={`Authenticate with ${server.display_name}`}
>
{isConnecting ? <Spinner loading className="h-2.5 w-2.5" /> : null}
Auth
</Button>
);
}
if (needsHeaderConfig) {
return (
<Button
variant="outline"
size="sm"
className={cn(
"h-6 shrink-0 px-2 text-[10px] leading-none",
buttonClassName,
)}
onClick={(e) => {
e.stopPropagation();
onConfigure();
}}
disabled={disabled}
aria-label={`Configure ${server.display_name}`}
>
Configure
</Button>
);
}
return (
<Switch
size={switchSize}
checked={isSelected}
onCheckedChange={(checked) => onToggle(server.id, checked)}
disabled={disabled || forceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
);
};
// ── Component ──────────────────────────────────────────────────
export const MCPServerPicker: FC<MCPServerPickerProps> = ({
@@ -204,9 +319,11 @@ export const MCPServerPicker: FC<MCPServerPickerProps> = ({
const activeServers = enabledServers.filter(
(s) =>
(s.availability === "force_on" || selectedServerIds.includes(s.id)) &&
!(s.auth_type === "oauth2" && !s.auth_connected),
!mcpServerNeedsAuth(s),
);
const navigate = useNavigate();
// Listen for OAuth2 completion postMessage from popup.
useEffect(() => {
const handler = (event: MessageEvent) => {
@@ -262,6 +379,11 @@ export const MCPServerPicker: FC<MCPServerPickerProps> = ({
);
};
const handleConfigureHeaders = () => {
setOpen(false);
navigate(userMCPServersSettingsPath);
};
if (enabledServers.length === 0) {
return null;
}
@@ -289,8 +411,6 @@ export const MCPServerPicker: FC<MCPServerPickerProps> = ({
const isForceOn = server.availability === "force_on";
const isSelected =
isForceOn || selectedServerIds.includes(server.id);
const needsAuth =
server.auth_type === "oauth2" && !server.auth_connected;
const isConnecting = connectingServerId === server.id;
return (
@@ -308,33 +428,18 @@ export const MCPServerPicker: FC<MCPServerPickerProps> = ({
{isForceOn && (
<LockIcon className="size-3 shrink-0 text-content-secondary" />
)}
{needsAuth ? (
<Button
variant="outline"
size="sm"
className="h-6 w-fit min-w-0 shrink-0 gap-0 px-2 text-[10px] leading-none border-border/50"
onClick={(e) => {
e.stopPropagation();
handleConnect(server);
}}
disabled={disabled || connectingServerId !== null}
aria-label={`Authenticate with ${server.display_name}`}
>
{isConnecting ? (
<Spinner loading className="h-2.5 w-2.5" />
) : null}
Auth
</Button>
) : (
<Switch
checked={isSelected}
onCheckedChange={(checked) =>
handleToggle(server.id, checked)
}
disabled={disabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
)}
<MCPServerAuthControl
server={server}
isSelected={isSelected}
isConnecting={isConnecting}
disabled={disabled}
connectingDisabled={connectingServerId !== null}
forceOn={isForceOn}
onConnect={handleConnect}
onConfigure={handleConfigureHeaders}
onToggle={handleToggle}
buttonClassName="w-fit min-w-0 gap-0 border-border/50"
/>
</div>
</TooltipTrigger>
<TooltipContent
+7
View File
@@ -396,6 +396,9 @@ const AgentSettingsModelsPage = lazy(
const AgentSettingsMCPServersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsMCPServersPage"),
);
const AgentSettingsUserMCPServersPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsUserMCPServersPage"),
);
const AgentSettingsSpendPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsSpendPage"),
);
@@ -797,6 +800,10 @@ export const router = createBrowserRouter(
path="mcp-servers"
element={<AgentSettingsMCPServersPage />}
/>
<Route
path="user-mcp-servers"
element={<AgentSettingsUserMCPServersPage />}
/>
<Route path="spend" element={<AgentSettingsSpendPage />} />
<Route path="limits" element={<Navigate to="spend" replace />} />
<Route path="usage" element={<NavigateWithSearch to="spend" />} />