feat(site): add MCP server picker to agent chat UI#23470
Conversation
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. Key behaviors: - force_on servers: Always active, toggle disabled, lock icon shown - default_on servers: Pre-selected by default, user can opt out - default_off servers: Not selected by default, user can opt in - OAuth2 servers: Shows auth status and 'Connect' button that opens a popup window (same pattern as Create Workspace external auth). Listens for postMessage from the OAuth2 callback. - mcp_server_ids are now sent with CreateChatMessageRequest and CreateChatRequest Components: - MCPServerPicker: Popover-based picker with server toggles - Integration into AgentChatInput toolbar (next to model selector) - Wired through AgentDetailView -> AgentDetailContent -> AgentChatInput - AgentDetail manages MCP state and sends IDs with messages - AgentCreatePage/Form passes MCP selection for new chats Includes 13 Storybook stories covering: - No servers / all disabled - Single force_on / default_on / default_off - OAuth2 needing auth vs connected - Mixed servers with various states - Disabled picker state - All opted out
- Remove verbose 'Connect to authenticate' text link - Add compact 'Auth' button with LogIn icon on the right side - Reduce row height and icon size for denser layout - Wrap each server row in a Tooltip showing description, availability policy, and auth status on hover - Use TooltipProvider with 300ms delay matching model selector - Narrow popover from w-80 to w-72 - Simplify header to single line
- Match model selector style: borderless transparent trigger with chevron, h-8, text-xs - Show overlapping icon stack in trigger when multiple servers are active, with +N overflow badge for >3 - Show plain plug icon when no servers are active - Remove 'MCP Servers' header from popover - Auth button replaces the toggle switch when OAuth2 auth is needed (not shown side-by-side) - Narrow popover from w-72 to w-64 - Add MultipleActiveIcons and IconStackOverflow stories
- Remove useCallback wrappers (React Compiler handles memoization) - Add event.origin validation on postMessage handler - Disable all Auth buttons when any auth flow is in-flight - Remove stray whitespace node and scale-[0.8] on Switch - Fix MCP defaults not applying on create page (useState initializer runs before query resolves, added useEffect seed) - Fix deliberate opt-out being overwritten on reload (respect empty mcp_server_ids array from chat record) - Remove unnecessary array spread in AgentChatInput - Use Tailwind class in story decorator
- Reduce Auth button horizontal padding from px-1 to px-0.5 - Add WithMCPServers story: multiple servers selected, icon stack visible - Add WithMCPNeedingAuth story: OAuth server shows Auth button - Add WithMCPNoneActive story: no servers active, plain plug icon
Documentation CheckUpdates Needed
New Documentation Needed
Automated review via Coder Tasks |
Export mcpServerConfigsKey from chats queries and seed it as empty in the AgentDetail story query cache so the MCP picker doesn't attempt a real API call and cause visual diffs.
The IIFE computing effectiveMCPServerIds referenced chatRecord before its const declaration, causing a TDZ (Temporal Dead Zone) ReferenceError at runtime. Move the block after the chatRecord assignment on line 420. Also add getMCPServerConfigs API spy to the stories beforeEach to prevent unhandled query errors.
Remove the fallback PlugIcon from the MCP trigger button when no servers are active. Now only the "MCP" label and chevron are shown.
Override both servers to default_off so force_on Sentry does not appear in the trigger icon stack when no servers are selected.
Previously the Auth button only appeared when the server was both unauthenticated and selected. Now it shows whenever auth is needed, regardless of toggle state, so users can authenticate optional servers without toggling them on first.
Override Linear to oauth2 with auth_connected: false and Sentry to auth_connected: false so both rows display the Auth button instead of a toggle.
| /> | ||
| mcpServers={mcpServersQuery.data ?? []} | ||
| onMCPAuthComplete={() => void mcpServersQuery.refetch()} | ||
| />{" "} |
| // 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]; |
There was a problem hiding this comment.
How come we're doing a copy here btw
| model_config_id: selectedModelConfigID, | ||
| mcp_server_ids: | ||
| effectiveMCPServerIds.length > 0 | ||
| ? [...effectiveMCPServerIds] |
There was a problem hiding this comment.
And here (not saying we shouldn't to either, just curious)
| {isConnecting ? ( | ||
| <Spinner loading className="h-2.5 w-2.5" /> | ||
| ) : null} | ||
| Auth{" "} |
There was a problem hiding this comment.
stray whitespace intentional?
| disabled={disabled || isForceOn} | ||
| aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} | ||
| /> | ||
| )}{" "} |
There was a problem hiding this comment.
stray whitespace intentional?
| const [selectedMCPServerIds, setSelectedMCPServerIds] = useState<string[]>( | ||
| () => (mcpServers ? getDefaultMCPSelection(mcpServers) : []), | ||
| ); | ||
| // Seed default selection once the MCP server query resolves. | ||
| const mcpDefaultsApplied = useRef(false); | ||
| useEffect(() => { | ||
| if (!mcpDefaultsApplied.current && mcpServers && mcpServers.length > 0) { | ||
| mcpDefaultsApplied.current = true; | ||
| setSelectedMCPServerIds(getDefaultMCPSelection(mcpServers)); | ||
| } | ||
| }, [mcpServers]); |
There was a problem hiding this comment.
Can you noodle with your agent on this, I'm not convinced with this code.
We're lazy initializing selectedMCPServerIds only to useEffect sync it with mcpServers once loaded. My best guess is that we probably don't need this useEffect if designed properly.
| if (iconUrl) { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| "flex shrink-0 items-center justify-center rounded-full bg-surface-secondary", | ||
| className, | ||
| )} | ||
| > | ||
| <ExternalImage | ||
| src={iconUrl} | ||
| alt={`${name} icon`} | ||
| className="h-3/5 w-3/5" | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| "flex shrink-0 items-center justify-center rounded-full bg-surface-secondary", | ||
| className, | ||
| )} | ||
| > | ||
| <ServerIcon className="h-3/5 w-3/5 text-content-secondary" /> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
| if (iconUrl) { | |
| return ( | |
| <div | |
| className={cn( | |
| "flex shrink-0 items-center justify-center rounded-full bg-surface-secondary", | |
| className, | |
| )} | |
| > | |
| <ExternalImage | |
| src={iconUrl} | |
| alt={`${name} icon`} | |
| className="h-3/5 w-3/5" | |
| /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div | |
| className={cn( | |
| "flex shrink-0 items-center justify-center rounded-full bg-surface-secondary", | |
| className, | |
| )} | |
| > | |
| <ServerIcon className="h-3/5 w-3/5 text-content-secondary" /> | |
| </div> | |
| ); | |
| let icon; | |
| if (iconUrl) { | |
| icon = ( | |
| <ExternalImage | |
| src={iconUrl} | |
| alt={`${name} icon`} | |
| className="h-3/5 w-3/5" | |
| /> | |
| ); | |
| } else { | |
| icon = <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> | |
| ); |
| useEffect(() => { | ||
| if (!connectingServerId || !popupRef.current) return; | ||
| const interval = setInterval(() => { | ||
| if (popupRef.current?.closed) { | ||
| setConnectingServerId(null); | ||
| popupRef.current = null; | ||
| } | ||
| }, 500); | ||
| return () => clearInterval(interval); | ||
| }, [connectingServerId]); |
There was a problem hiding this comment.
My agent thinks we might be leaking a popup ref, + this code feels very sketchy
- Refactor MCPIcon to deduplicate wrapper div - Remove stray whitespace in AgentCreatePage, MCPServerPicker - Remove unnecessary array spread in AgentDetail (keep at API boundary where readonly->mutable conversion is needed) - Close popup on unmount to prevent leaked browser windows - Simplify AgentCreateForm MCP selection: replace useState + useEffect + useRef guard with null sentinel + derived state
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":/api/experimental/mcp/servers/{id}/oauth2/connectpostMessagewith{type: "mcp-oauth2-complete"}from the callback pageIntegration Points
AgentChatInput: MCP picker appears in the toolbar after the model selectorAgentDetail: Manages MCP selection state, initializes fromchat.mcp_server_idsor defaultsAgentDetailView/AgentDetailContent: Props plumbed through to inputAgentCreatePage/AgentCreateForm: MCP selection for new chatsmcp_server_idsnow sent withCreateChatMessageRequestandCreateChatRequestHelper
getDefaultMCPSelection(): Computes default selection from availability policies (force_on+default_on)Storybook Stories