feat(site): add MCP server picker to agent chat UI (#23470)

## Summary

Adds a user-facing MCP server configuration panel to the chat input
toolbar. Users can toggle which MCP servers provide tools for their chat
sessions, and authenticate with OAuth2 servers via popup windows.

## Changes

### New Components
- **`MCPServerPicker`** (`MCPServerPicker.tsx`): Popover-based picker
that appears in the chat input toolbar next to the model selector. Shows
all enabled MCP servers with toggles.
- **`MCPServerPicker.stories.tsx`**: 13 Storybook stories covering all
states.

### Availability Policies
Respects the admin-configured availability for each server:
- **`force_on`**: Always active, toggle disabled, lock icon shown. User
cannot disable.
- **`default_on`**: Pre-selected by default, user can opt out via
toggle.
- **`default_off`**: Not selected by default, user must opt in via
toggle.

### OAuth2 Authentication
For servers with `auth_type: "oauth2"`:
- Shows auth status (connected/not connected)
- "Connect to authenticate" link opens a popup window to
`/api/experimental/mcp/servers/{id}/oauth2/connect`
- Listens for `postMessage` with `{type: "mcp-oauth2-complete"}` from
the callback page
- Same UX pattern as external auth on the Create Workspace screen

### Integration Points
- `AgentChatInput`: MCP picker appears in the toolbar after the model
selector
- `AgentDetail`: Manages MCP selection state, initializes from
`chat.mcp_server_ids` or defaults
- `AgentDetailView` / `AgentDetailContent`: Props plumbed through to
input
- `AgentCreatePage` / `AgentCreateForm`: MCP selection for new chats
- `mcp_server_ids` now sent with `CreateChatMessageRequest` and
`CreateChatRequest`

### Helper
- `getDefaultMCPSelection()`: Computes default selection from
availability policies (`force_on` + `default_on`)

## Storybook Stories
| Story | Description |
|-------|-------------|
| NoServers | No servers - picker hidden |
| AllDisabled | All disabled servers - picker hidden |
| SingleForceOn | Force-on server with locked toggle |
| SingleDefaultOnNoAuth | Default-on with no auth required |
| SingleDefaultOff | Optional server not selected |
| OAuthNeedsAuth | OAuth2 server needing authentication |
| OAuthConnected | OAuth2 server already connected |
| MixedServers | Multiple servers with mixed availability/auth |
| AllConnected | All OAuth2 servers authenticated |
| Disabled | Picker in disabled state |
| WithDisabledServer | Disabled servers filtered out |
| AllOptedOut | All toggled off except force_on |
| OptionalOAuthNeedsAuth | Optional OAuth2 needing auth |
This commit is contained in:
Kyle Carberry
2026-03-24 08:13:18 -04:00
committed by GitHub
parent 2dc3466f07
commit f62f2ffe6a
12 changed files with 840 additions and 4 deletions
+1 -1
View File
@@ -697,7 +697,7 @@ export const deleteChatUsageLimitGroupOverride = (
// ── MCP Server Configs ───────────────────────────────────────
const mcpServerConfigsKey = ["mcp-server-configs"] as const;
export const mcpServerConfigsKey = ["mcp-server-configs"] as const;
export const mcpServerConfigs = () => ({
queryKey: mcpServerConfigsKey,
+12 -1
View File
@@ -1,4 +1,9 @@
import { chatModelConfigs, chatModels, createChat } from "api/queries/chats";
import {
chatModelConfigs,
chatModels,
createChat,
mcpServerConfigs,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -24,6 +29,7 @@ const AgentCreatePage: FC = () => {
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const mcpServersQuery = useQuery(mcpServerConfigs());
const createMutation = useMutation(createChat(queryClient));
const catalogModelOptions = getModelOptionsFromCatalog(
@@ -39,6 +45,7 @@ const AgentCreatePage: FC = () => {
fileIDs,
workspaceId,
model,
mcpServerIds,
}: CreateChatOptions) => {
const modelConfigID =
(model && modelConfigIDByModelID.get(model)) || nilUUID;
@@ -55,6 +62,8 @@ const AgentCreatePage: FC = () => {
content,
workspace_id: workspaceId,
model_config_id: modelConfigID,
mcp_server_ids:
mcpServerIds && mcpServerIds.length > 0 ? mcpServerIds : undefined,
});
if (typeof window !== "undefined") {
@@ -84,6 +93,8 @@ const AgentCreatePage: FC = () => {
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
mcpServers={mcpServersQuery.data ?? []}
onMCPAuthComplete={() => void mcpServersQuery.refetch()}
/>
</>
);
@@ -17,6 +17,7 @@ import {
chatMessagesKey,
chatModelsKey,
chatsKey,
mcpServerConfigsKey,
} from "api/queries/chats";
import { workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
@@ -176,6 +177,7 @@ const buildQueries = (
data: mockWorkspace,
},
{ key: chatModelsKey, data: mockModelCatalog },
{ key: mcpServerConfigsKey, data: [] },
];
};
@@ -214,6 +216,7 @@ const meta: Meta<typeof AgentDetailLayout> = {
beforeEach: () => {
localStorage.removeItem(RIGHT_PANEL_OPEN_KEY);
spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key"));
spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]);
return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY);
},
};
+40
View File
@@ -11,6 +11,7 @@ import {
deleteChatQueuedMessage,
editChatMessage,
interruptChat,
mcpServerConfigs,
promoteChatQueuedMessage,
userCompactionThresholds,
} from "api/queries/chats";
@@ -50,6 +51,7 @@ import {
AgentDetailNotFoundView,
AgentDetailView,
} from "./components/AgentDetailView";
import { getDefaultMCPSelection } from "./components/MCPServerPicker";
import { useGitWatcher } from "./hooks/useGitWatcher";
import {
buildModelConfigIDByModelID,
@@ -315,8 +317,23 @@ const AgentDetail: FC = () => {
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const userThresholdsQuery = useQuery(userCompactionThresholds());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const mcpServersQuery = useQuery(mcpServerConfigs());
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
// MCP server selection state.
const mcpServers = mcpServersQuery.data ?? [];
const [selectedMCPServerIds, setSelectedMCPServerIds] = useState<
string[] | null
>(null);
const handleMCPSelectionChange = (ids: string[]) => {
setSelectedMCPServerIds(ids);
};
const handleMCPAuthComplete = (_serverId: string) => {
void mcpServersQuery.refetch();
};
const modelOptions = getModelOptionsFromCatalog(
chatModelsQuery.data,
chatModelConfigsQuery.data,
@@ -405,6 +422,21 @@ const AgentDetail: FC = () => {
};
const chatRecord = chatQuery.data;
// Initialize MCP selection from chat record or defaults.
const effectiveMCPServerIds = (() => {
if (selectedMCPServerIds !== null) {
return selectedMCPServerIds;
}
// If the chat has MCP server IDs recorded (even empty, meaning
// the user deliberately opted out), use those.
if (chatRecord?.mcp_server_ids) {
return chatRecord.mcp_server_ids;
}
// Otherwise, compute defaults from server availability.
return getDefaultMCPSelection(mcpServers);
})();
// Flatten paginated messages into chronological order.
// Pages arrive newest-first per page, and pages[0] is the
// most recent page.
@@ -640,6 +672,10 @@ const AgentDetail: FC = () => {
const request: TypesGen.CreateChatMessageRequest = {
content,
model_config_id: selectedModelConfigID,
mcp_server_ids:
effectiveMCPServerIds.length > 0
? [...effectiveMCPServerIds]
: undefined,
};
clearChatErrorReason(agentId);
clearStreamError();
@@ -909,6 +945,10 @@ const AgentDetail: FC = () => {
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
onFetchMoreMessages={chatMessagesQuery.fetchNextPage}
desktopChatId={desktopEnabled ? agentId : undefined}
mcpServers={mcpServers}
selectedMCPServerIds={effectiveMCPServerIds}
onMCPSelectionChange={handleMCPSelectionChange}
onMCPAuthComplete={handleMCPAuthComplete}
/>
);
};
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
import { useEffect, useRef } from "react";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
@@ -343,3 +344,115 @@ export const AttachmentsOnly: Story = {
};
})(),
};
// ── MCP server fixtures ────────────────────────────────────────
const now = "2026-03-19T12:00:00.000Z";
const makeMCPServer = (
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,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
auth_connected: overrides.auth_connected ?? false,
});
const sentryMCP = makeMCPServer({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
icon_url: "/icon/widgets.svg",
availability: "force_on",
auth_type: "oauth2",
auth_connected: true,
enabled: true,
});
const linearMCP = makeMCPServer({
id: "mcp-linear",
display_name: "Linear",
slug: "linear",
availability: "default_on",
auth_type: "api_key",
enabled: true,
});
const githubMCP = makeMCPServer({
id: "mcp-github",
display_name: "GitHub",
slug: "github",
icon_url: "/icon/github.svg",
availability: "default_on",
auth_type: "oauth2",
auth_connected: false,
enabled: true,
});
const githubMCPConnected = { ...githubMCP, auth_connected: true };
const mcpDefaults = {
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
};
// ── MCP stories ────────────────────────────────────────────────
/** Input with multiple MCP servers selected — shows icon stack in toolbar. */
export const WithMCPServers: Story = {
args: {
...mcpDefaults,
mcpServers: [sentryMCP, linearMCP, githubMCPConnected],
selectedMCPServerIds: [sentryMCP.id, linearMCP.id, githubMCPConnected.id],
},
};
/** MCP server needing OAuth — shows Auth button instead of toggle. */
export const WithMCPNeedingAuth: Story = {
args: {
...mcpDefaults,
mcpServers: [sentryMCP, githubMCP],
selectedMCPServerIds: [sentryMCP.id, githubMCP.id],
},
};
/** No MCP servers active — shows only "MCP" label with chevron. */
export const WithMCPNoneActive: Story = {
args: {
...mcpDefaults,
mcpServers: [
{
...sentryMCP,
availability: "default_off",
auth_connected: false,
},
{
...linearMCP,
availability: "default_off",
auth_type: "oauth2",
auth_connected: false,
},
],
selectedMCPServerIds: [],
},
};
@@ -1,3 +1,4 @@
import type * as TypesGen from "api/typesGenerated";
import type { ChatMessagePart, ChatQueuedMessage } from "api/typesGenerated";
import {
ModelSelector,
@@ -38,6 +39,7 @@ import { cn } from "utils/cn";
import { isMobileViewport } from "utils/mobile";
import { formatProviderLabel } from "../utils/modelOptions";
import { ImageLightbox } from "./ImageLightbox";
import { MCPServerPicker } from "./MCPServerPicker";
import { QueuedMessagesList } from "./QueuedMessagesList";
export type { ChatMessageInputRef } from "components/ChatMessageInput/ChatMessageInput";
@@ -112,6 +114,11 @@ interface AgentChatInputProps {
onRemoveAttachment?: (index: number) => void;
uploadStates?: Map<File, UploadState>;
previewUrls?: Map<File, string>;
// MCP Server picker.
mcpServers?: readonly TypesGen.MCPServerConfig[];
selectedMCPServerIds?: readonly string[];
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
}
const hasFiniteTokenValue = (value: number | undefined): value is number =>
typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -357,6 +364,10 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onRemoveAttachment,
uploadStates,
previewUrls,
mcpServers,
selectedMCPServerIds,
onMCPSelectionChange,
onMCPAuthComplete,
}) => {
const internalRef = useRef<ChatMessageInputRef>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
@@ -637,7 +648,6 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
{" "}
<ModelSelector
value={selectedModel}
onValueChange={onModelChange}
@@ -648,6 +658,18 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
dropdownSide="top"
dropdownAlign="center"
/>
{mcpServers &&
mcpServers.length > 0 &&
onMCPSelectionChange &&
onMCPAuthComplete && (
<MCPServerPicker
servers={mcpServers}
selectedServerIds={selectedMCPServerIds ?? []}
onSelectionChange={onMCPSelectionChange}
onAuthComplete={onMCPAuthComplete}
disabled={isDisabled}
/>
)}
{leftActions}
{inputStatusText && (
<span className="hidden text-xs text-content-secondary sm:inline">
@@ -37,6 +37,7 @@ import {
isUsageLimitData,
} from "../utils/usageLimitMessage";
import { AgentChatInput } from "./AgentChatInput";
import { getDefaultMCPSelection } from "./MCPServerPicker";
/** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input";
@@ -50,6 +51,7 @@ export type CreateChatOptions = {
fileIDs?: string[];
workspaceId?: string;
model?: string;
mcpServerIds?: string[];
};
/**
@@ -116,6 +118,8 @@ interface AgentCreateFormProps {
modelConfigs: readonly TypesGen.ChatModelConfig[];
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onMCPAuthComplete?: (serverId: string) => void;
}
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
@@ -128,6 +132,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
mcpServers,
onMCPAuthComplete,
}) => {
const { organizations } = useDashboard();
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
@@ -242,11 +248,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// the shared input component re-rendering on every change.
const selectedWorkspaceIdRef = useRef(selectedWorkspaceId);
const selectedModelRef = useRef(selectedModel);
const [userMCPServerIds, setUserMCPServerIds] = useState<string[] | null>(
null,
);
const effectiveMCPServerIds =
userMCPServerIds ?? getDefaultMCPSelection(mcpServers ?? []);
const selectedMCPServerIdsRef = useRef(effectiveMCPServerIds);
useEffect(() => {
selectedWorkspaceIdRef.current = selectedWorkspaceId;
selectedModelRef.current = selectedModel;
selectedMCPServerIdsRef.current = effectiveMCPServerIds;
});
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
setSelectedWorkspaceId(null);
@@ -273,6 +285,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
fileIDs,
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
model: selectedModelRef.current || undefined,
mcpServerIds:
selectedMCPServerIdsRef.current.length > 0
? [...selectedMCPServerIdsRef.current]
: undefined,
}).catch(() => {
// Re-enable draft persistence so the user can edit
// and retry after a failed send attempt.
@@ -368,6 +384,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onRemoveAttachment={handleRemoveAttachment}
uploadStates={uploadStates}
previewUrls={previewUrls}
mcpServers={mcpServers}
selectedMCPServerIds={effectiveMCPServerIds}
onMCPSelectionChange={setUserMCPServerIds}
onMCPAuthComplete={onMCPAuthComplete}
leftActions={
<Popover
open={workspacePopoverOpen}
@@ -209,6 +209,10 @@ interface AgentDetailInputProps {
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
mcpServers?: readonly TypesGen.MCPServerConfig[];
selectedMCPServerIds?: readonly string[];
onMCPSelectionChange?: (ids: string[]) => void;
onMCPAuthComplete?: (serverId: string) => void;
}
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
@@ -237,6 +241,10 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
isEditingHistoryMessage,
onCancelHistoryEdit,
editingFileBlocks,
mcpServers,
selectedMCPServerIds,
onMCPSelectionChange,
onMCPAuthComplete,
}) => {
const messagesByID = useChatSelector(store, selectMessagesByID);
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
@@ -374,6 +382,10 @@ export const AgentDetailInput: FC<AgentDetailInputProps> = ({
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
/>
);
};
@@ -135,6 +135,10 @@ const meta: Meta<typeof AgentDetailView> = {
handleUnarchiveAgentAction: fn(),
handleArchiveAndDeleteWorkspaceAction: fn(),
scrollContainerRef: { current: null },
mcpServers: [],
selectedMCPServerIds: [],
onMCPSelectionChange: fn(),
onMCPAuthComplete: fn(),
},
};
@@ -126,6 +126,12 @@ interface AgentDetailViewProps {
urlTransform?: UrlTransform;
// MCP server state.
mcpServers: readonly TypesGen.MCPServerConfig[];
selectedMCPServerIds: readonly string[];
onMCPSelectionChange: (ids: string[]) => void;
onMCPAuthComplete: (serverId: string) => void;
// Desktop chat ID (optional).
desktopChatId?: string;
}
@@ -177,6 +183,10 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
isFetchingMoreMessages,
onFetchMoreMessages,
urlTransform,
mcpServers,
selectedMCPServerIds,
onMCPSelectionChange,
onMCPAuthComplete,
desktopChatId,
}) => {
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
@@ -302,6 +312,10 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
onMCPSelectionChange={onMCPSelectionChange}
onMCPAuthComplete={onMCPAuthComplete}
/>
</div>
</div>
@@ -0,0 +1,285 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type * as TypesGen from "api/typesGenerated";
import { fn } from "storybook/test";
import { getDefaultMCPSelection, MCPServerPicker } from "./MCPServerPicker";
// ── Helpers ────────────────────────────────────────────────────
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,
tool_allow_list: overrides.tool_allow_list ?? [],
tool_deny_list: overrides.tool_deny_list ?? [],
availability: overrides.availability ?? "default_on",
enabled: overrides.enabled ?? true,
created_at: overrides.created_at ?? now,
updated_at: overrides.updated_at ?? now,
auth_connected: overrides.auth_connected ?? false,
});
// ── Fixtures ───────────────────────────────────────────────────
const sentryServer = createServerConfig({
id: "mcp-sentry",
display_name: "Sentry",
slug: "sentry",
description: "Error tracking and monitoring",
icon_url: "/icon/widgets.svg",
url: "https://mcp.sentry.io/sse",
transport: "sse",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "force_on",
enabled: true,
auth_connected: true,
});
const linearServer = createServerConfig({
id: "mcp-linear",
display_name: "Linear",
slug: "linear",
description: "Project management and issue tracking",
url: "https://mcp.linear.app/v1",
transport: "streamable_http",
auth_type: "api_key",
has_api_key: true,
availability: "default_on",
enabled: true,
auth_connected: false,
});
const githubServer = createServerConfig({
id: "mcp-github",
display_name: "GitHub",
slug: "github",
description: "Code hosting and collaboration",
icon_url: "/icon/github.svg",
url: "https://api.githubcopilot.com/mcp/",
transport: "streamable_http",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "default_on",
enabled: true,
auth_connected: false,
});
const githubServerConnected = {
...githubServer,
auth_connected: true,
};
const slackServer = createServerConfig({
id: "mcp-slack",
display_name: "Slack",
slug: "slack",
description: "Team messaging and notifications",
url: "https://mcp.slack.com/v1",
transport: "streamable_http",
auth_type: "oauth2",
has_oauth2_secret: true,
availability: "default_off",
enabled: true,
auth_connected: false,
});
const datadogServer = createServerConfig({
id: "mcp-datadog",
display_name: "Datadog",
slug: "datadog",
description: "Infrastructure monitoring and APM",
url: "https://mcp.datadog.com/v1",
transport: "streamable_http",
auth_type: "none",
availability: "default_off",
enabled: true,
auth_connected: false,
});
const disabledServer = createServerConfig({
id: "mcp-disabled",
display_name: "Disabled Server",
slug: "disabled",
url: "https://mcp.disabled.com/v1",
enabled: false,
});
const allServers = [
sentryServer,
linearServer,
githubServer,
slackServer,
datadogServer,
];
// ── Meta ───────────────────────────────────────────────────────
const meta: Meta<typeof MCPServerPicker> = {
title: "pages/AgentsPage/MCPServerPicker",
component: MCPServerPicker,
args: {
onSelectionChange: fn(),
onAuthComplete: fn(),
},
decorators: [
(Story) => (
<div className="p-10">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof MCPServerPicker>;
// ── Stories ────────────────────────────────────────────────────
/** No servers available — picker should not render. */
export const NoServers: Story = {
args: {
servers: [],
selectedServerIds: [],
},
};
/** All disabled servers — picker should not render. */
export const AllDisabled: Story = {
args: {
servers: [disabledServer],
selectedServerIds: [],
},
};
/** Single force-on server. Toggle should be disabled. */
export const SingleForceOn: Story = {
args: {
servers: [sentryServer],
selectedServerIds: [sentryServer.id],
},
};
/** Single default-on server with no auth required. */
export const SingleDefaultOnNoAuth: Story = {
args: {
servers: [linearServer],
selectedServerIds: [linearServer.id],
},
};
/** Single default-off optional server. */
export const SingleDefaultOff: Story = {
args: {
servers: [datadogServer],
selectedServerIds: [],
},
};
/** OAuth2 server needing authentication — shows Auth button. */
export const OAuthNeedsAuth: Story = {
args: {
servers: [githubServer],
selectedServerIds: [githubServer.id],
},
};
/** OAuth2 server already authenticated — shows check icon. */
export const OAuthConnected: Story = {
args: {
servers: [githubServerConnected],
selectedServerIds: [githubServerConnected.id],
},
};
/** Multiple servers with mixed availability and auth states. */
export const MixedServers: Story = {
args: {
servers: allServers,
selectedServerIds: getDefaultMCPSelection(allServers),
},
};
/** All servers with connected OAuth2 (GitHub connected). */
export const AllConnected: Story = {
args: {
servers: [sentryServer, linearServer, githubServerConnected, datadogServer],
selectedServerIds: getDefaultMCPSelection([
sentryServer,
linearServer,
githubServerConnected,
datadogServer,
]),
},
};
/** Disabled state — all toggles disabled. */
export const Disabled: Story = {
args: {
servers: allServers,
selectedServerIds: getDefaultMCPSelection(allServers),
disabled: true,
},
};
/** Servers including a disabled one — disabled should be filtered out. */
export const WithDisabledServer: Story = {
args: {
servers: [...allServers, disabledServer],
selectedServerIds: getDefaultMCPSelection(allServers),
},
};
/** All servers opted out — only force_on remains active. */
export const AllOptedOut: Story = {
args: {
servers: allServers,
selectedServerIds: [sentryServer.id],
},
};
/** Default-off server with OAuth needing auth (opted in, Auth button shows). */
export const OptionalOAuthNeedsAuth: Story = {
args: {
servers: [slackServer],
selectedServerIds: [slackServer.id],
},
};
/** Trigger shows overlapping icon stack when multiple servers are active. */
export const MultipleActiveIcons: Story = {
args: {
servers: [sentryServer, linearServer, githubServerConnected, datadogServer],
selectedServerIds: [
sentryServer.id,
linearServer.id,
githubServerConnected.id,
datadogServer.id,
],
},
};
/** More than 3 active servers shows +N overflow badge. */
export const IconStackOverflow: Story = {
args: {
servers: allServers,
selectedServerIds: allServers.map((s) => s.id),
},
};
@@ -0,0 +1,312 @@
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ChevronDownIcon, LockIcon, ServerIcon } from "lucide-react";
import { type FC, useEffect, useRef, useState } from "react";
import { cn } from "utils/cn";
// ── Types ──────────────────────────────────────────────────────
interface MCPServerPickerProps {
/** All MCP server configs from the API. Will be filtered to enabled only. */
servers: readonly TypesGen.MCPServerConfig[];
/** Currently selected server IDs. */
selectedServerIds: readonly string[];
/** Called when the user toggles a server. */
onSelectionChange: (ids: string[]) => void;
/** Called when an OAuth2 auth flow completes (server should be refetched). */
onAuthComplete: (serverId: string) => void;
/** Whether the picker is disabled (e.g. during submission). */
disabled?: boolean;
}
// ── Helpers ────────────────────────────────────────────────────
const availabilityLabel = (a: string) => {
switch (a) {
case "force_on":
return "Always on";
case "default_on":
return "On by default";
case "default_off":
return "Optional";
default:
return a;
}
};
const MCPIcon: FC<{ iconUrl: string; name: string; className?: string }> = ({
iconUrl,
name,
className,
}) => {
const icon = iconUrl ? (
<ExternalImage src={iconUrl} alt={`${name} icon`} className="h-3/5 w-3/5" />
) : (
<ServerIcon className="h-3/5 w-3/5 text-content-secondary" />
);
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
className,
)}
>
{icon}
</div>
);
};
/**
* Compute the default selection based on server availability policies.
* force_on and default_on servers are selected by default.
*/
export const getDefaultMCPSelection = (
servers: readonly TypesGen.MCPServerConfig[],
): string[] => {
return servers
.filter(
(s) =>
s.enabled &&
(s.availability === "force_on" || s.availability === "default_on"),
)
.map((s) => s.id);
};
// ── Overlapping icon stack for the trigger ─────────────────────
const ICON_STACK_MAX = 3;
const TriggerIconStack: FC<{
servers: readonly TypesGen.MCPServerConfig[];
}> = ({ servers }) => {
const visible = servers.slice(0, ICON_STACK_MAX);
return (
<span className="inline-flex items-center">
{visible.map((s, i) => (
<span
key={s.id}
className={cn(
"inline-flex rounded-full ring-1 ring-surface-primary",
i > 0 && "-ml-1.5",
)}
>
<MCPIcon
iconUrl={s.icon_url}
name={s.display_name}
className="h-4 w-4"
/>
</span>
))}
{servers.length > ICON_STACK_MAX && (
<span className="-ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-secondary text-[9px] font-medium text-content-secondary ring-1 ring-surface-primary">
+{servers.length - ICON_STACK_MAX}
</span>
)}
</span>
);
};
// ── Component ──────────────────────────────────────────────────
export const MCPServerPicker: FC<MCPServerPickerProps> = ({
servers,
selectedServerIds,
onSelectionChange,
onAuthComplete,
disabled = false,
}) => {
const [open, setOpen] = useState(false);
const [connectingServerId, setConnectingServerId] = useState<string | null>(
null,
);
const popupRef = useRef<Window | null>(null);
// Filter to enabled servers only.
const enabledServers = servers.filter((s) => s.enabled);
// Servers shown in the trigger icon stack: selected and
// fully ready (no outstanding auth required).
const activeServers = enabledServers.filter(
(s) =>
(s.availability === "force_on" || selectedServerIds.includes(s.id)) &&
!(s.auth_type === "oauth2" && !s.auth_connected),
);
// Listen for OAuth2 completion postMessage from popup.
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (
event.data?.type === "mcp-oauth2-complete" &&
typeof event.data.serverID === "string"
) {
setConnectingServerId(null);
onAuthComplete(event.data.serverID);
popupRef.current = null;
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [onAuthComplete]);
// Poll for popup close and clean up on unmount.
useEffect(() => {
if (!connectingServerId || !popupRef.current) return;
const interval = setInterval(() => {
if (popupRef.current?.closed) {
setConnectingServerId(null);
popupRef.current = null;
}
}, 500);
return () => {
clearInterval(interval);
// Close the popup if the component unmounts while
// an auth flow is still in progress.
if (popupRef.current && !popupRef.current.closed) {
popupRef.current.close();
popupRef.current = null;
}
};
}, [connectingServerId]);
const handleToggle = (serverId: string, checked: boolean) => {
if (checked) {
onSelectionChange([...selectedServerIds, serverId]);
} else {
onSelectionChange(selectedServerIds.filter((id) => id !== serverId));
}
};
const handleConnect = (server: TypesGen.MCPServerConfig) => {
setConnectingServerId(server.id);
const connectUrl = `/api/experimental/mcp/servers/${encodeURIComponent(server.id)}/oauth2/connect`;
popupRef.current = window.open(
connectUrl,
"_blank",
"width=900,height=600",
);
};
if (enabledServers.length === 0) {
return null;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label="MCP Servers"
className="group flex h-8 cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="hidden sm:inline">MCP</span>
{activeServers.length > 0 && (
<TriggerIconStack servers={activeServers} />
)}
<ChevronDownIcon className="h-3.5 w-3.5 text-content-secondary transition-colors group-hover:text-content-primary" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-52 p-0">
<TooltipProvider delayDuration={300}>
<div className="max-h-64 overflow-y-auto py-1 [scrollbar-width:thin]">
{enabledServers.map((server) => {
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 (
<Tooltip key={server.id}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 px-2.5 py-1.5">
<MCPIcon
iconUrl={server.icon_url}
name={server.display_name}
className="h-5 w-5"
/>
<span className="min-w-0 flex-1 truncate text-xs text-content-primary">
{server.display_name}
</span>
{isForceOn && (
<LockIcon className="h-3 w-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}`}
/>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-[220px] px-2.5 py-1.5"
>
<span className="block font-semibold leading-tight text-content-primary">
{server.display_name}
</span>
{server.description && (
<span className="block leading-tight text-content-secondary">
{server.description}
</span>
)}
<span className="mt-1 block text-content-secondary leading-tight">
{availabilityLabel(server.availability)}
</span>
{server.auth_type !== "none" && (
<span className="block text-content-secondary leading-tight">
{server.auth_connected
? "Authenticated"
: "Not authenticated"}
</span>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</PopoverContent>
</Popover>
);
};