Skip to content

feat(site): add MCP server admin UI#23301

Merged
kylecarbs merged 11 commits intomainfrom
mcp-server-admin-ui
Mar 19, 2026
Merged

feat(site): add MCP server admin UI#23301
kylecarbs merged 11 commits intomainfrom
mcp-server-admin-ui

Conversation

@kylecarbs
Copy link
Copy Markdown
Member

@kylecarbs kylecarbs commented Mar 19, 2026

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.

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.
@coder-tasks
Copy link
Copy Markdown
Contributor

coder-tasks bot commented Mar 19, 2026

Documentation Check

New Documentation Needed

  • docs/ai-coder/ai-bridge/mcp.md (or a new docs/ai-coder/mcp-server-admin.md) — Document the new admin UI for managing MCP server configurations at /agents/settings/mcp-servers. The existing mcp.md covers the deprecated env-var based approach; the new UI-based workflow (create/edit/delete MCP servers, auth types, availability policies, tool allow/deny lists) needs documentation.
  • docs/ai-coder/ai-bridge/index.md — Update "Configure MCP servers" link/section to reference the new admin panel instead of (or in addition to) the deprecated env-var approach now that a UI exists.

The backend for this feature landed in #23227 and neither PR includes docs for the /agents/settings/mcp-servers admin panel.

Note: The MCP Servers nav entry is intentionally hidden until the feature is fully functional (future PRs). Documentation may be deferred until then, but the items above should be addressed before the feature ships.


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.
Comment on lines +279 to +324
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use formik for form management

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did the agent ignore me?

Comment on lines +997 to +1004
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>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spinner then Loading text? Would look kinda funky

Comment on lines +947 to +950
type View =
| { mode: "list" }
| { mode: "form"; server: TypesGen.MCPServerConfig | null };
const [view, setView] = useState<View>({ mode: "list" });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent ignored me i think, PR for reference #23277

Comment on lines +764 to +766
style={
{ WebkitTextSecurity: "disc" } as React.CSSProperties
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment on lines +106 to +139
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>
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not use the existing Field component btw?

Comment on lines +947 to +950
type View =
| { mode: "list" }
| { mode: "form"; server: TypesGen.MCPServerConfig | null };
const [view, setView] = useState<View>({ mode: "list" });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent ignored me i think, PR for reference #23277

Comment on lines +279 to +324
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

@DanielleMaywood DanielleMaywood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely iterate on this but for now :shipit:

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.
@kylecarbs kylecarbs enabled auto-merge (squash) March 19, 2026 18:46
@kylecarbs kylecarbs merged commit 7db77bb into main Mar 19, 2026
27 checks passed
@kylecarbs kylecarbs deleted the mcp-server-admin-ui branch March 19, 2026 18:53
@github-actions github-actions bot locked and limited conversation to collaborators Mar 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants