diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 422591f79e..6b6e2ae591 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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, diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 9e56c9d301..550afc68a6 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -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()} /> ); diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 963313f580..bc31ed3a49 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -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 = { 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); }, }; diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 3671018c95..53144c5450 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -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} /> ); }; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index f57f286d81..f2c85545a9 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -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 & + Pick, +): 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: [], + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index a4320a1c9a..db00aad0df 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -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; previewUrls?: Map; + // 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 = ({ onRemoveAttachment, uploadStates, previewUrls, + mcpServers, + selectedMCPServerIds, + onMCPSelectionChange, + onMCPAuthComplete, }) => { const internalRef = useRef(null); const [previewImage, setPreviewImage] = useState(null); @@ -637,7 +648,6 @@ export const AgentChatInput: FC = ({
- {" "} = ({ dropdownSide="top" dropdownAlign="center" /> + {mcpServers && + mcpServers.length > 0 && + onMCPSelectionChange && + onMCPAuthComplete && ( + + )} {leftActions} {inputStatusText && ( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 3a7b443d28..b27d18f304 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -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 = ({ @@ -128,6 +132,8 @@ export const AgentCreateForm: FC = ({ isModelCatalogLoading, isModelConfigsLoading, modelCatalogError, + mcpServers, + onMCPAuthComplete, }) => { const { organizations } = useDashboard(); const { initialInputValue, handleContentChange, submitDraft, resetDraft } = @@ -242,11 +248,17 @@ export const AgentCreateForm: FC = ({ // the shared input component re-rendering on every change. const selectedWorkspaceIdRef = useRef(selectedWorkspaceId); const selectedModelRef = useRef(selectedModel); + const [userMCPServerIds, setUserMCPServerIds] = useState( + 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 = ({ 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 = ({ onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} previewUrls={previewUrls} + mcpServers={mcpServers} + selectedMCPServerIds={effectiveMCPServerIds} + onMCPSelectionChange={setUserMCPServerIds} + onMCPAuthComplete={onMCPAuthComplete} leftActions={ void; + onMCPAuthComplete?: (serverId: string) => void; } export const AgentDetailInput: FC = ({ @@ -237,6 +241,10 @@ export const AgentDetailInput: FC = ({ 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 = ({ modelSelectorPlaceholder={modelSelectorPlaceholder} inputStatusText={inputStatusText} modelCatalogStatusMessage={modelCatalogStatusMessage} + mcpServers={mcpServers} + selectedMCPServerIds={selectedMCPServerIds} + onMCPSelectionChange={onMCPSelectionChange} + onMCPAuthComplete={onMCPAuthComplete} /> ); }; diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx index 4ff735f99c..f8f56afddc 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.stories.tsx @@ -135,6 +135,10 @@ const meta: Meta = { handleUnarchiveAgentAction: fn(), handleArchiveAndDeleteWorkspaceAction: fn(), scrollContainerRef: { current: null }, + mcpServers: [], + selectedMCPServerIds: [], + onMCPSelectionChange: fn(), + onMCPAuthComplete: fn(), }, }; diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index bcb044dab3..00a088c357 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -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 = ({ isFetchingMoreMessages, onFetchMoreMessages, urlTransform, + mcpServers, + selectedMCPServerIds, + onMCPSelectionChange, + onMCPAuthComplete, desktopChatId, }) => { const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); @@ -302,6 +312,10 @@ export const AgentDetailView: FC = ({ isEditingHistoryMessage={editing.editingMessageId !== null} onCancelHistoryEdit={editing.handleCancelHistoryEdit} editingFileBlocks={editing.editingFileBlocks} + mcpServers={mcpServers} + selectedMCPServerIds={selectedMCPServerIds} + onMCPSelectionChange={onMCPSelectionChange} + onMCPAuthComplete={onMCPAuthComplete} />
diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx new file mode 100644 index 0000000000..060371268c --- /dev/null +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.stories.tsx @@ -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 & + Pick, +): 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 = { + title: "pages/AgentsPage/MCPServerPicker", + component: MCPServerPicker, + args: { + onSelectionChange: fn(), + onAuthComplete: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// ── 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), + }, +}; diff --git a/site/src/pages/AgentsPage/components/MCPServerPicker.tsx b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx new file mode 100644 index 0000000000..7d125338f6 --- /dev/null +++ b/site/src/pages/AgentsPage/components/MCPServerPicker.tsx @@ -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 ? ( + + ) : ( + + ); + + return ( +
+ {icon} +
+ ); +}; + +/** + * 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 ( + + {visible.map((s, i) => ( + 0 && "-ml-1.5", + )} + > + + + ))} + {servers.length > ICON_STACK_MAX && ( + + +{servers.length - ICON_STACK_MAX} + + )} + + ); +}; + +// ── Component ────────────────────────────────────────────────── + +export const MCPServerPicker: FC = ({ + servers, + selectedServerIds, + onSelectionChange, + onAuthComplete, + disabled = false, +}) => { + const [open, setOpen] = useState(false); + const [connectingServerId, setConnectingServerId] = useState( + null, + ); + const popupRef = useRef(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 ( + + + + + + +
+ {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 ( + + +
+ + + {server.display_name} + + {isForceOn && ( + + )} + {needsAuth ? ( + + ) : ( + + handleToggle(server.id, checked) + } + disabled={disabled || isForceOn} + aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} + /> + )} +
+
+ + + {server.display_name} + + {server.description && ( + + {server.description} + + )} + + {availabilityLabel(server.availability)} + + {server.auth_type !== "none" && ( + + {server.auth_connected + ? "Authenticated" + : "Not authenticated"} + + )} + +
+ ); + })} +
+
+
+
+ ); +};