feat(site): add MCP server admin UI#23301
Conversation
Add admin settings panel at /agents/settings/mcp-servers for configuring external MCP servers that provide tools to AI chat sessions. This is the frontend counterpart to the backend merged in #23227. Features: - List view with server name, URL, auth type, and enabled status - Create/edit form with inline display name, slug auto-generation, icon picker, URL, transport, and availability settings - Enabled/disabled toggle in the form header with tooltip - OAuth2 auth type with client ID/secret, auth URL, token URL, and scopes fields (with guidance text for admin proxy flow) - API key auth type with header name and masked key value - Custom headers auth type with key-value pair editor - Tool governance via allow/deny list inputs - Delete with inline confirmation strip - 15 Storybook stories covering all interactive flows The chat-level MCP server picker is deferred to a separate change.
Documentation CheckNew Documentation Needed
The backend for this feature landed in #23227 and neither PR includes docs for the
Automated review via Coder Tasks |
- Wrap handleSave/handleDelete in try/catch to prevent unhandled promise rejections (matching ProviderForm pattern) - Add enabled/disabled status to server list button aria-labels for screen reader accessibility - Replace type="password" with WebkitTextSecurity + password manager suppression attributes (data-1p-ignore, data-lpignore, etc.) matching ProviderForm's approach - Extract named UpdateMCPServerConfigMutationArgs type in queries - Fix story button selectors to use regex for new aria-label format - Add submit + API assertion to EditServerWithCustomHeaders story
Remove the sidebar entry while keeping the route and panel component so the page is still reachable by direct URL.
| const [displayName, setDisplayName] = useState(server?.display_name ?? ""); | ||
| const [slug, setSlug] = useState(server?.slug ?? ""); | ||
| const [slugTouched, setSlugTouched] = useState(false); | ||
| const [description, setDescription] = useState(server?.description ?? ""); | ||
| const [iconURL, setIconURL] = useState(server?.icon_url ?? ""); | ||
| const [url, setURL] = useState(server?.url ?? ""); | ||
| const [transport, setTransport] = useState( | ||
| server?.transport ?? "streamable_http", | ||
| ); | ||
| const [authType, setAuthType] = useState(server?.auth_type ?? "none"); | ||
| const [oauth2ClientID, setOauth2ClientID] = useState( | ||
| server?.oauth2_client_id ?? "", | ||
| ); | ||
| const [oauth2ClientSecret, setOauth2ClientSecret] = useState( | ||
| server?.has_oauth2_secret ? SECRET_PLACEHOLDER : "", | ||
| ); | ||
| const [oauth2SecretTouched, setOauth2SecretTouched] = useState(false); | ||
| const [oauth2AuthURL, setOauth2AuthURL] = useState( | ||
| server?.oauth2_auth_url ?? "", | ||
| ); | ||
| const [oauth2TokenURL, setOauth2TokenURL] = useState( | ||
| server?.oauth2_token_url ?? "", | ||
| ); | ||
| const [oauth2Scopes, setOauth2Scopes] = useState(server?.oauth2_scopes ?? ""); | ||
| const [apiKeyHeader, setApiKeyHeader] = useState( | ||
| server?.api_key_header ?? "", | ||
| ); | ||
| const [apiKeyValue, setApiKeyValue] = useState( | ||
| server?.has_api_key ? SECRET_PLACEHOLDER : "", | ||
| ); | ||
| const [apiKeyTouched, setApiKeyTouched] = useState(false); | ||
| const [availability, setAvailability] = useState( | ||
| server?.availability ?? "default_off", | ||
| ); | ||
| const [enabled, setEnabled] = useState(server?.enabled ?? true); | ||
| const [toolAllowList, setToolAllowList] = useState( | ||
| joinList(server?.tool_allow_list), | ||
| ); | ||
| const [toolDenyList, setToolDenyList] = useState( | ||
| joinList(server?.tool_deny_list), | ||
| ); | ||
| const [customHeaders, setCustomHeaders] = useState< | ||
| Array<{ key: string; value: string }> | ||
| >([]); | ||
| const [customHeadersTouched, setCustomHeadersTouched] = useState(false); | ||
| const [confirmingDelete, setConfirmingDelete] = useState(false); |
There was a problem hiding this comment.
We should probably use formik for form management
There was a problem hiding this comment.
did the agent ignore me?
| if (serversQuery.isLoading) { | ||
| return ( | ||
| <div className="flex items-center gap-1.5 text-xs text-content-secondary"> | ||
| <Spinner className="h-4 w-4" loading /> | ||
| Loading | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Spinner then Loading text? Would look kinda funky
| type View = | ||
| | { mode: "list" } | ||
| | { mode: "form"; server: TypesGen.MCPServerConfig | null }; | ||
| const [view, setView] = useState<View>({ mode: "list" }); |
There was a problem hiding this comment.
We might want to move this state to the URL like I did in a prior PR. Allows using the browser back button as expected
There was a problem hiding this comment.
agent ignored me i think, PR for reference #23277
| style={ | ||
| { WebkitTextSecurity: "disc" } as React.CSSProperties | ||
| } |
There was a problem hiding this comment.
should be tailwind
- Replace inline WebkitTextSecurity style with Tailwind arbitrary property class [-webkit-text-security:disc] - Simplify loading state to just a spinner (remove redundant text) - Fix duplicate className attributes on secret inputs
| interface FieldProps { | ||
| label: string; | ||
| htmlFor?: string; | ||
| required?: boolean; | ||
| description?: string; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| const Field: FC<FieldProps> = ({ | ||
| label, | ||
| htmlFor, | ||
| required, | ||
| description, | ||
| children, | ||
| }) => ( | ||
| <div className="grid gap-1.5"> | ||
| <div className="flex items-baseline gap-1.5"> | ||
| <label | ||
| htmlFor={htmlFor} | ||
| className="text-sm font-medium text-content-primary" | ||
| > | ||
| {label} | ||
| </label> | ||
| {required && ( | ||
| <span className="text-xs font-bold text-content-destructive">*</span> | ||
| )} | ||
| </div> | ||
| {description && ( | ||
| <p className="m-0 text-xs text-content-secondary">{description}</p> | ||
| )} | ||
| {children} | ||
| </div> | ||
| ); | ||
|
|
There was a problem hiding this comment.
Any reason to not use the existing Field component btw?
| type View = | ||
| | { mode: "list" } | ||
| | { mode: "form"; server: TypesGen.MCPServerConfig | null }; | ||
| const [view, setView] = useState<View>({ mode: "list" }); |
There was a problem hiding this comment.
agent ignored me i think, PR for reference #23277
| const [displayName, setDisplayName] = useState(server?.display_name ?? ""); | ||
| const [slug, setSlug] = useState(server?.slug ?? ""); | ||
| const [slugTouched, setSlugTouched] = useState(false); | ||
| const [description, setDescription] = useState(server?.description ?? ""); | ||
| const [iconURL, setIconURL] = useState(server?.icon_url ?? ""); | ||
| const [url, setURL] = useState(server?.url ?? ""); | ||
| const [transport, setTransport] = useState( | ||
| server?.transport ?? "streamable_http", | ||
| ); | ||
| const [authType, setAuthType] = useState(server?.auth_type ?? "none"); | ||
| const [oauth2ClientID, setOauth2ClientID] = useState( | ||
| server?.oauth2_client_id ?? "", | ||
| ); | ||
| const [oauth2ClientSecret, setOauth2ClientSecret] = useState( | ||
| server?.has_oauth2_secret ? SECRET_PLACEHOLDER : "", | ||
| ); | ||
| const [oauth2SecretTouched, setOauth2SecretTouched] = useState(false); | ||
| const [oauth2AuthURL, setOauth2AuthURL] = useState( | ||
| server?.oauth2_auth_url ?? "", | ||
| ); | ||
| const [oauth2TokenURL, setOauth2TokenURL] = useState( | ||
| server?.oauth2_token_url ?? "", | ||
| ); | ||
| const [oauth2Scopes, setOauth2Scopes] = useState(server?.oauth2_scopes ?? ""); | ||
| const [apiKeyHeader, setApiKeyHeader] = useState( | ||
| server?.api_key_header ?? "", | ||
| ); | ||
| const [apiKeyValue, setApiKeyValue] = useState( | ||
| server?.has_api_key ? SECRET_PLACEHOLDER : "", | ||
| ); | ||
| const [apiKeyTouched, setApiKeyTouched] = useState(false); | ||
| const [availability, setAvailability] = useState( | ||
| server?.availability ?? "default_off", | ||
| ); | ||
| const [enabled, setEnabled] = useState(server?.enabled ?? true); | ||
| const [toolAllowList, setToolAllowList] = useState( | ||
| joinList(server?.tool_allow_list), | ||
| ); | ||
| const [toolDenyList, setToolDenyList] = useState( | ||
| joinList(server?.tool_deny_list), | ||
| ); | ||
| const [customHeaders, setCustomHeaders] = useState< | ||
| Array<{ key: string; value: string }> | ||
| >([]); | ||
| const [customHeadersTouched, setCustomHeadersTouched] = useState(false); | ||
| const [confirmingDelete, setConfirmingDelete] = useState(false); |
There was a problem hiding this comment.
did the agent ignore me?
Address PR review feedback from @DanielleMaywood: - Replace ~20 individual useState calls with useFormik for form management, matching the ModelForm pattern - Replace useState<View> with useSearchParams for list/form navigation, enabling browser back button support - Add reactRouterParameters to stories for router context
DanielleMaywood
left a comment
There was a problem hiding this comment.
We should definitely iterate on this but for now ![]()
Export ProviderField from ProviderForm.tsx and import it in MCPServerAdminPanel, removing the duplicate Field definition. Make htmlFor optional on ProviderFieldProps since some usages (like IconField) don't have a target input id.
After clicking 'Add Server' or a server row, the component re-renders from list view to form view. Synchronous getBy* queries fail because the new view hasn't rendered yet. Switching to async findBy* queries lets the test runner poll until the element appears.
This adds the UI but does not add it to the Settings sidebar. Until it's actually functional and usable (which will come in future PRs) it will remain hidden.
Next step is wiring this up to chats and actually testing the full flow end-to-end, but we aren't there yet.