mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />} />
|
||||
|
||||
Reference in New Issue
Block a user