Skip to repository content1592 lines · 54.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:43:46.445Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
mcp_servers_page.rs
1use std::sync::Arc;
2
3use collections::HashMap;
4use context_server::ContextServerId;
5use editor::Editor;
6use extension_host::ExtensionStore;
7use gpui::{Action as _, Entity, Focusable as _, ScrollHandle, WeakEntity, prelude::*};
8use project::context_server_store::{
9 ContextServerConfiguration, ContextServerStatus, ContextServerStore,
10};
11use project::project_settings::ContextServerSettings;
12use settings::{ContextServerCommand, ContextServerSettingsContent, OAuthClientSettings};
13use ui::{
14 AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, Divider, PopoverMenu,
15 Switch, ToggleState, Tooltip, prelude::*,
16};
17use util::ResultExt as _;
18
19use zed_actions::ExtensionCategoryFilter;
20
21use crate::{PROJECT, SettingField, SettingItem, SettingsPageItem, SettingsWindow, USER};
22
23pub(crate) fn render_mcp_servers_page(
24 settings_window: &SettingsWindow,
25 scroll_handle: &ScrollHandle,
26 window: &mut Window,
27 cx: &mut Context<SettingsWindow>,
28) -> AnyElement {
29 let context_server_store = get_context_server_store(settings_window, cx);
30
31 let server_list = if let Some(store) = context_server_store.as_ref() {
32 let server_ids = store.read(cx).server_ids().to_vec();
33
34 if server_ids.is_empty() {
35 render_empty_state(cx)
36 } else {
37 render_server_list(&server_ids, store, cx)
38 }
39 } else {
40 render_no_project_state(cx)
41 };
42
43 let timeout_setting = render_context_server_timeout(settings_window, window, cx);
44
45 v_flex()
46 .id("mcp-servers-page")
47 .track_scroll(scroll_handle)
48 .size_full()
49 .pt_2p5()
50 .pb_16()
51 .overflow_y_scroll()
52 .child(
53 v_flex()
54 .w_full()
55 .px_8()
56 .gap_2()
57 .child(
58 v_flex().child(Label::new("Configured Servers")).child(
59 Label::new("Manage servers connected directly or via extensions.")
60 .size(LabelSize::Small)
61 .color(Color::Muted),
62 ),
63 )
64 .child(server_list)
65 .child(Divider::horizontal()),
66 )
67 .child(timeout_setting)
68 .into_any_element()
69}
70
71fn render_context_server_timeout(
72 settings_window: &SettingsWindow,
73 window: &mut Window,
74 cx: &mut Context<SettingsWindow>,
75) -> AnyElement {
76 let item = SettingsPageItem::SettingItem(SettingItem {
77 title: "MCP Server Timeout",
78 description: "Default timeout in seconds for MCP server tool calls.",
79 field: Box::new(SettingField {
80 organization_override: None,
81 json_path: Some("context_server_timeout"),
82 pick: |settings_content| settings_content.project.context_server_timeout.as_ref(),
83 write: |settings_content, value, _| {
84 settings_content.project.context_server_timeout = value;
85 },
86 }),
87 metadata: None,
88 files: USER | PROJECT,
89 });
90
91 item.render(settings_window, 0, false, false, window, cx)
92}
93
94fn get_context_server_store(
95 settings_window: &SettingsWindow,
96 cx: &App,
97) -> Option<Entity<ContextServerStore>> {
98 let original_window = settings_window.original_window.as_ref()?;
99 let multi_workspace = original_window.read(cx).ok()?;
100 let workspace = multi_workspace.workspaces().next()?;
101 let project = workspace.read(cx).project().clone();
102 Some(project.read(cx).context_server_store())
103}
104
105fn render_empty_state(cx: &App) -> AnyElement {
106 h_flex()
107 .p_4()
108 .justify_center()
109 .border_1()
110 .border_dashed()
111 .border_color(cx.theme().colors().border.opacity(0.6))
112 .rounded_sm()
113 .child(
114 Label::new("No MCP servers added yet. Click \"Add Server\" to get started.")
115 .color(Color::Muted)
116 .size(LabelSize::Small),
117 )
118 .into_any_element()
119}
120
121fn render_no_project_state(cx: &App) -> AnyElement {
122 h_flex()
123 .p_4()
124 .justify_center()
125 .border_1()
126 .border_dashed()
127 .border_color(cx.theme().colors().border.opacity(0.6))
128 .rounded_sm()
129 .child(
130 Label::new("No active project found. Open a workspace to manage MCP servers.")
131 .color(Color::Muted)
132 .size(LabelSize::Small),
133 )
134 .into_any_element()
135}
136
137fn render_server_list(
138 server_ids: &[ContextServerId],
139 store: &Entity<ContextServerStore>,
140 cx: &mut Context<SettingsWindow>,
141) -> AnyElement {
142 v_flex()
143 .w_full()
144 .gap_1()
145 .children(itertools::intersperse_with(
146 server_ids
147 .iter()
148 .map(|server_id| render_context_server(server_id, store, cx).into_any_element()),
149 || Divider::horizontal_dashed().into_any_element(),
150 ))
151 .into_any_element()
152}
153
154fn render_context_server(
155 context_server_id: &ContextServerId,
156 store: &Entity<ContextServerStore>,
157 cx: &mut Context<SettingsWindow>,
158) -> impl IntoElement {
159 let server_status = store
160 .read(cx)
161 .status_for_server(context_server_id)
162 .unwrap_or(ContextServerStatus::Stopped);
163 let server_configuration = store.read(cx).configuration_for_server(context_server_id);
164
165 let is_running = matches!(server_status, ContextServerStatus::Running);
166 let is_enabled = store.read(cx).is_server_enabled(context_server_id, cx);
167 let is_transitioning = matches!(
168 server_status,
169 ContextServerStatus::Starting | ContextServerStatus::Authenticating
170 );
171 let item_id = SharedString::from(context_server_id.0.to_string());
172
173 // Determine the source from the configured settings rather than the runtime
174 // configuration: a custom (Stdio/HTTP) server that is disabled or not yet
175 // started has no runtime configuration, and must not be mistaken for an
176 // extension-provided server.
177 let provided_by_extension = store.read(cx).is_extension_provided(context_server_id, cx);
178 let display_name = if provided_by_extension {
179 resolve_extension_display_name(context_server_id, cx).unwrap_or_else(|| item_id.clone())
180 } else {
181 item_id.clone()
182 };
183
184 let source = if provided_by_extension {
185 AiSettingItemSource::Extension
186 } else {
187 AiSettingItemSource::Custom
188 };
189
190 let status = map_server_status(&server_status);
191
192 let should_show_logout = server_configuration.as_ref().is_some_and(|config| {
193 matches!(config.as_ref(), ContextServerConfiguration::Http { .. })
194 && !config.has_static_auth_header()
195 });
196
197 // ContextServerRegistry is per-project (not a global), so we skip tool count
198 // in the settings UI for now.
199 let tool_count = 0usize;
200
201 let tool_label = if is_running && tool_count > 0 {
202 Some(if tool_count == 1 {
203 SharedString::from("1 tool")
204 } else {
205 SharedString::from(format!("{} tools", tool_count))
206 })
207 } else {
208 None
209 };
210
211 let server_settings = store
212 .read(cx)
213 .settings_for_server(context_server_id)
214 .cloned();
215 let configure_button = (!provided_by_extension).then(|| {
216 render_configure_button(
217 context_server_id,
218 cx.entity().downgrade(),
219 server_settings.clone(),
220 )
221 });
222 let uninstall_button = render_uninstall_button(context_server_id, provided_by_extension);
223
224 // Build toggle switch
225 let toggle_switch =
226 render_toggle_switch(context_server_id, store, is_enabled, is_transitioning);
227
228 // Surface invalid settings (which prevent the server from starting at all)
229 // ahead of runtime status feedback, so the misconfiguration is visible.
230 let details = match settings_validation_error(server_settings.as_ref()) {
231 Some(error) => Some(render_form_error(error).into_any_element()),
232 None => render_status_details(&server_status, context_server_id, store, should_show_logout),
233 };
234
235 AiSettingItem::new(item_id, display_name, status, source)
236 .when_some(configure_button, |this, button| this.action(button))
237 .action(uninstall_button)
238 .action(toggle_switch)
239 .when_some(tool_label, |this, label| this.detail_label(label))
240 .when_some(details, |this, details| this.details(details))
241}
242
243fn map_server_status(status: &ContextServerStatus) -> AiSettingItemStatus {
244 match status {
245 ContextServerStatus::Starting => AiSettingItemStatus::Starting,
246 ContextServerStatus::Running => AiSettingItemStatus::Running,
247 ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
248 ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
249 ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
250 ContextServerStatus::ClientSecretRequired { .. } => {
251 AiSettingItemStatus::ClientSecretRequired
252 }
253 ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
254 }
255}
256
257fn resolve_extension_display_name(id: &ContextServerId, cx: &App) -> Option<SharedString> {
258 ExtensionStore::global(cx)
259 .read(cx)
260 .installed_extensions()
261 .iter()
262 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
263 .map(|(_, entry)| {
264 let name = entry.manifest.name.as_str();
265 let stripped = name
266 .strip_suffix(" MCP Server")
267 .or_else(|| name.strip_suffix(" MCP"))
268 .or_else(|| name.strip_suffix(" Context Server"))
269 .unwrap_or(name);
270 SharedString::from(stripped.to_string())
271 })
272}
273
274fn render_configure_button(
275 context_server_id: &ContextServerId,
276 settings_window: WeakEntity<SettingsWindow>,
277 server_settings: Option<ContextServerSettings>,
278) -> impl IntoElement {
279 let context_server_id = context_server_id.clone();
280
281 IconButton::new(
282 format!("mcp-configure-btn-{}", context_server_id.0),
283 IconName::Settings,
284 )
285 .icon_size(IconSize::Small)
286 .tab_index(0isize)
287 .tooltip(Tooltip::text("Configure MCP Server"))
288 .on_click(move |_event, window, cx| {
289 let transport = match &server_settings {
290 Some(ContextServerSettings::Http { .. }) => McpTransport::Http,
291 _ => McpTransport::Stdio,
292 };
293 let existing = server_settings
294 .clone()
295 .map(|settings| (context_server_id.clone(), settings));
296 settings_window
297 .update(cx, |this, cx| {
298 open_mcp_server_form(this, transport, existing, window, cx);
299 })
300 .log_err();
301 })
302}
303
304fn render_uninstall_button(
305 context_server_id: &ContextServerId,
306 provided_by_extension: bool,
307) -> impl IntoElement {
308 let context_server_id = context_server_id.clone();
309
310 IconButton::new(
311 format!("mcp-uninstall-btn-{}", context_server_id.0),
312 IconName::Trash,
313 )
314 .icon_size(IconSize::Small)
315 .tab_index(0isize)
316 .tooltip(Tooltip::text("Uninstall MCP Server"))
317 .on_click(move |_event, _window, cx| {
318 uninstall_server(&context_server_id, provided_by_extension, cx);
319 })
320}
321
322fn render_toggle_switch(
323 context_server_id: &ContextServerId,
324 store: &Entity<ContextServerStore>,
325 is_enabled: bool,
326 is_transitioning: bool,
327) -> impl IntoElement {
328 let context_server_id = context_server_id.clone();
329 let store = store.clone();
330
331 Switch::new(
332 SharedString::from(format!("mcp-toggle-{}", context_server_id.0)),
333 if is_enabled {
334 ToggleState::Selected
335 } else {
336 ToggleState::Unselected
337 },
338 )
339 .disabled(is_transitioning)
340 .tab_index(0isize)
341 .on_click({
342 move |state, _window, cx| {
343 let is_enabled = match state {
344 ToggleState::Unselected | ToggleState::Indeterminate => {
345 store.update(cx, |this, cx| {
346 this.stop_server(&context_server_id, cx).log_err();
347 });
348 false
349 }
350 ToggleState::Selected => {
351 store.update(cx, |this, cx| {
352 if let Some(server) = this.get_server(&context_server_id) {
353 this.start_server(server, cx);
354 }
355 });
356 true
357 }
358 };
359
360 let fs = <dyn fs::Fs>::global(cx);
361 settings::update_settings_file(fs, cx, {
362 let context_server_id = context_server_id.clone();
363 move |settings, _| {
364 settings
365 .project
366 .context_servers
367 .entry(context_server_id.0.clone())
368 .or_insert_with(|| ContextServerSettingsContent::Extension {
369 enabled: is_enabled,
370 remote: false,
371 settings: serde_json::json!({}),
372 })
373 .set_enabled(is_enabled);
374 }
375 });
376 }
377 })
378}
379
380fn render_status_details(
381 server_status: &ContextServerStatus,
382 context_server_id: &ContextServerId,
383 store: &Entity<ContextServerStore>,
384 should_show_logout: bool,
385) -> Option<AnyElement> {
386 let feedback_base = || h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
387
388 match server_status {
389 ContextServerStatus::Error(error) => {
390 let store = store.clone();
391 let context_server_id = context_server_id.clone();
392 Some(
393 feedback_base()
394 .child(
395 h_flex()
396 .min_w_0()
397 .w_full()
398 .gap_2()
399 .child(
400 Icon::new(IconName::XCircle)
401 .size(IconSize::XSmall)
402 .color(Color::Error),
403 )
404 .child(
405 div().min_w_0().flex_1().child(
406 Label::new(error.to_string())
407 .color(Color::Muted)
408 .size(LabelSize::Small),
409 ),
410 ),
411 )
412 .when(should_show_logout, |this| {
413 this.child(
414 Button::new("error-logout", "Log Out")
415 .style(ButtonStyle::Outlined)
416 .label_size(LabelSize::Small)
417 .on_click({
418 let store = store.clone();
419 let context_server_id = context_server_id.clone();
420 move |_event, _window, cx| {
421 store.update(cx, |s, cx| {
422 s.logout_server(&context_server_id, cx).log_err();
423 });
424 }
425 }),
426 )
427 })
428 .into_any_element(),
429 )
430 }
431 ContextServerStatus::AuthRequired => {
432 let store = store.clone();
433 let context_server_id = context_server_id.clone();
434 Some(
435 feedback_base()
436 .child(
437 h_flex()
438 .min_w_0()
439 .w_full()
440 .gap_2()
441 .child(
442 Icon::new(IconName::Info)
443 .size(IconSize::XSmall)
444 .color(Color::Muted),
445 )
446 .child(
447 Label::new("Authenticate to connect this server")
448 .color(Color::Muted)
449 .size(LabelSize::Small),
450 ),
451 )
452 .child(
453 Button::new("authenticate-server", "Authenticate")
454 .style(ButtonStyle::Outlined)
455 .label_size(LabelSize::Small)
456 .on_click({
457 move |_event, _window, cx| {
458 store.update(cx, |s, cx| {
459 s.authenticate_server(&context_server_id, cx).log_err();
460 });
461 }
462 }),
463 )
464 .into_any_element(),
465 )
466 }
467 ContextServerStatus::ClientSecretRequired { .. } => Some(
468 feedback_base()
469 .child(
470 h_flex()
471 .min_w_0()
472 .w_full()
473 .gap_2()
474 .child(
475 Icon::new(IconName::Info)
476 .size(IconSize::XSmall)
477 .color(Color::Muted),
478 )
479 .child(
480 Label::new("A client secret is required to connect this server")
481 .color(Color::Muted)
482 .size(LabelSize::Small),
483 ),
484 )
485 .into_any_element(),
486 ),
487 ContextServerStatus::Authenticating => Some(
488 h_flex()
489 .mt_1()
490 .min_w_0()
491 .w_full()
492 .gap_2()
493 .child(div().size_3().flex_shrink_0())
494 .child(
495 Label::new("Authenticating…")
496 .color(Color::Muted)
497 .size(LabelSize::Small),
498 )
499 .into_any_element(),
500 ),
501 ContextServerStatus::Running if should_show_logout => {
502 let store = store.clone();
503 let context_server_id = context_server_id.clone();
504 Some(
505 h_flex()
506 .py_1()
507 .w_full()
508 .justify_end()
509 .child(
510 Button::new("running-logout", "Log Out")
511 .style(ButtonStyle::Outlined)
512 .label_size(LabelSize::Small)
513 .on_click(move |_event, _window, cx| {
514 store.update(cx, |s, cx| {
515 s.logout_server(&context_server_id, cx).log_err();
516 });
517 }),
518 )
519 .into_any_element(),
520 )
521 }
522 _ => None,
523 }
524}
525
526pub(crate) fn render_add_server_popover(
527 settings_window: &SettingsWindow,
528 window: &mut Window,
529 cx: &mut Context<SettingsWindow>,
530) -> impl IntoElement {
531 let original_window = settings_window.original_window;
532 // Stable handle so the button keeps focus state across renders and can show a
533 // focus ring even when the page is opened (and the button auto-focused) via a
534 // mouse click, where `focus_visible` styling is suppressed.
535 let focus_handle = settings_window
536 .mcp_add_server_focus_handle
537 .clone()
538 .tab_index(0)
539 .tab_stop(true);
540 let is_focused = focus_handle.is_focused(window);
541 let border_color = if is_focused {
542 cx.theme().colors().border_focused
543 } else {
544 gpui::transparent_black()
545 };
546 let settings_window = cx.entity().downgrade();
547
548 let popover = PopoverMenu::new("add-mcp-server-popover")
549 .trigger(
550 Button::new("add-mcp-server", "Add Server")
551 .style(ButtonStyle::Outlined)
552 .track_focus(&focus_handle)
553 .start_icon(
554 Icon::new(IconName::Plus)
555 .size(IconSize::Small)
556 .color(Color::Muted),
557 )
558 .label_size(LabelSize::Small),
559 )
560 .anchor(gpui::Anchor::TopRight)
561 .menu({
562 move |window, cx| {
563 let settings_window = settings_window.clone();
564 Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
565 menu.entry("Add Local Server", None, {
566 let settings_window = settings_window.clone();
567 move |window, cx| {
568 settings_window
569 .update(cx, |this, cx| {
570 open_mcp_server_form(
571 this,
572 McpTransport::Stdio,
573 None,
574 window,
575 cx,
576 );
577 })
578 .log_err();
579 }
580 })
581 .entry("Add Remote Server", None, {
582 let settings_window = settings_window.clone();
583 move |window, cx| {
584 settings_window
585 .update(cx, |this, cx| {
586 open_mcp_server_form(
587 this,
588 McpTransport::Http,
589 None,
590 window,
591 cx,
592 );
593 })
594 .log_err();
595 }
596 })
597 .separator()
598 .entry("Install from Extensions", None, {
599 move |_window, cx| {
600 if let Some(original_window) = original_window.as_ref() {
601 cx.activate(true);
602 original_window
603 .update(cx, |_, window, cx| {
604 window.activate_window();
605 window.dispatch_action(
606 zed_actions::Extensions {
607 category_filter: Some(
608 ExtensionCategoryFilter::ContextServers,
609 ),
610 id: None,
611 }
612 .boxed_clone(),
613 cx,
614 );
615 })
616 .log_err();
617 }
618 }
619 })
620 }))
621 }
622 });
623
624 div()
625 .rounded_md()
626 .border_1()
627 .border_color(border_color)
628 .child(popover)
629}
630
631fn uninstall_server(
632 context_server_id: &ContextServerId,
633 provided_by_extension: bool,
634 cx: &mut App,
635) {
636 if provided_by_extension {
637 if let Some((ext_id, manifest)) =
638 resolve_extension_for_context_server(context_server_id, cx)
639 {
640 if extension_only_provides_context_server(&manifest) {
641 ExtensionStore::global(cx)
642 .update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
643 .detach_and_log_err(cx);
644 }
645 }
646 }
647
648 let fs = <dyn fs::Fs>::global(cx);
649 let context_server_id = context_server_id.clone();
650 settings::update_settings_file(fs, cx, move |settings, _| {
651 settings
652 .project
653 .context_servers
654 .remove(&context_server_id.0);
655 });
656}
657
658fn resolve_extension_for_context_server(
659 id: &ContextServerId,
660 cx: &App,
661) -> Option<(Arc<str>, Arc<extension::ExtensionManifest>)> {
662 ExtensionStore::global(cx)
663 .read(cx)
664 .installed_extensions()
665 .iter()
666 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
667 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
668}
669
670fn extension_only_provides_context_server(manifest: &extension::ExtensionManifest) -> bool {
671 manifest.context_servers.len() == 1
672 && manifest.themes.is_empty()
673 && manifest.icon_themes.is_empty()
674 && manifest.languages.is_empty()
675 && manifest.grammars.is_empty()
676 && manifest.language_servers.is_empty()
677 && manifest.slash_commands.is_empty()
678 && manifest.snippets.is_none()
679 && manifest.debug_locators.is_empty()
680}
681
682// === Custom (Stdio/HTTP) MCP server add/edit form ===
683
684#[derive(Clone, Copy, PartialEq, Eq)]
685pub(crate) enum McpTransport {
686 /// Local server launched via stdin/stdout.
687 Stdio,
688 /// Remote server connected over HTTP.
689 Http,
690}
691
692#[derive(Clone, Copy)]
693enum McpKvKind {
694 Env,
695 Header,
696}
697
698impl McpKvKind {
699 fn rows_mut(self, form: &mut McpServerForm) -> &mut Vec<KeyValueRow> {
700 match self {
701 McpKvKind::Env => &mut form.env,
702 McpKvKind::Header => &mut form.headers,
703 }
704 }
705
706 fn remove_id(self) -> &'static str {
707 match self {
708 McpKvKind::Env => "mcp-env-remove",
709 McpKvKind::Header => "mcp-header-remove",
710 }
711 }
712
713 fn add_id(self) -> &'static str {
714 match self {
715 McpKvKind::Env => "mcp-env-add",
716 McpKvKind::Header => "mcp-header-add",
717 }
718 }
719}
720
721struct KeyValueRow {
722 key: Entity<Editor>,
723 value: Entity<Editor>,
724}
725
726/// Editor-backed state for the custom MCP server add/edit form.
727pub(crate) struct McpServerForm {
728 transport: McpTransport,
729 /// `Some` when editing an existing server (used to remove the old entry on rename).
730 original_id: Option<ContextServerId>,
731 name: Entity<Editor>,
732 command: Entity<Editor>,
733 args: Entity<Editor>,
734 url: Entity<Editor>,
735 timeout: Entity<Editor>,
736 oauth_client_id: Entity<Editor>,
737 env: Vec<KeyValueRow>,
738 headers: Vec<KeyValueRow>,
739 error: Option<SharedString>,
740}
741
742impl McpServerForm {
743 fn new(
744 transport: McpTransport,
745 existing: Option<(ContextServerId, ContextServerSettings)>,
746 window: &mut Window,
747 cx: &mut Context<SettingsWindow>,
748 ) -> Self {
749 let original_id = existing.as_ref().map(|(id, _)| id.clone());
750 let settings = existing.map(|(_, settings)| settings);
751 let name_initial = original_id.as_ref().map(|id| id.0.to_string());
752
753 let mut command_initial = None;
754 let mut args_initial = None;
755 let mut url_initial = None;
756 let mut timeout_initial = None;
757 let mut oauth_initial = None;
758 let mut env = Vec::new();
759 let mut headers = Vec::new();
760
761 // Pre-fill from the raw settings so invalid values (e.g. a malformed URL
762 // the user typed directly into settings.json) still load into the form
763 // for correction, rather than being dropped during resolution.
764 if let Some(settings) = settings.as_ref() {
765 match settings {
766 ContextServerSettings::Stdio { command, .. } => {
767 command_initial = Some(command.path.to_string_lossy().to_string());
768 if !command.args.is_empty() {
769 args_initial = Some(command.args.join(" "));
770 }
771 timeout_initial = command.timeout.map(|timeout| timeout.to_string());
772 if let Some(env_map) = &command.env {
773 for (key, value) in sorted_pairs(env_map) {
774 env.push(new_kv_row(Some(&key), Some(&value), window, cx));
775 }
776 }
777 }
778 ContextServerSettings::Http {
779 url,
780 headers: header_map,
781 timeout,
782 oauth,
783 ..
784 } => {
785 url_initial = Some(url.clone());
786 timeout_initial = timeout.map(|timeout| timeout.to_string());
787 for (key, value) in sorted_pairs(header_map) {
788 headers.push(new_kv_row(Some(&key), Some(&value), window, cx));
789 }
790 oauth_initial = oauth.as_ref().map(|oauth| oauth.client_id.clone());
791 }
792 ContextServerSettings::Extension { .. } => {}
793 }
794 }
795
796 Self {
797 transport,
798 original_id,
799 name: new_input("my-mcp-server", name_initial.as_deref(), window, cx),
800 command: new_input("/path/to/server", command_initial.as_deref(), window, cx),
801 args: new_input("--flag value", args_initial.as_deref(), window, cx),
802 url: new_input(
803 "https://example.com/mcp",
804 url_initial.as_deref(),
805 window,
806 cx,
807 ),
808 timeout: new_input("60", timeout_initial.as_deref(), window, cx),
809 oauth_client_id: new_input(
810 "Optional OAuth client ID",
811 oauth_initial.as_deref(),
812 window,
813 cx,
814 ),
815 env,
816 headers,
817 error: None,
818 }
819 }
820}
821
822fn sorted_pairs(map: &HashMap<String, String>) -> Vec<(String, String)> {
823 let mut pairs: Vec<(String, String)> = map
824 .iter()
825 .map(|(key, value)| (key.clone(), value.clone()))
826 .collect();
827 pairs.sort_by(|a, b| a.0.cmp(&b.0));
828 pairs
829}
830
831fn new_input(
832 placeholder: &str,
833 initial: Option<&str>,
834 window: &mut Window,
835 cx: &mut Context<SettingsWindow>,
836) -> Entity<Editor> {
837 let placeholder = placeholder.to_string();
838 let initial = initial.map(|text| text.to_string());
839 cx.new(|cx| {
840 let mut editor = Editor::single_line(window, cx);
841 editor.set_placeholder_text(placeholder.as_str(), window, cx);
842 if let Some(text) = initial {
843 editor.set_text(text, window, cx);
844 }
845 editor
846 })
847}
848
849fn new_kv_row(
850 key: Option<&str>,
851 value: Option<&str>,
852 window: &mut Window,
853 cx: &mut Context<SettingsWindow>,
854) -> KeyValueRow {
855 KeyValueRow {
856 key: new_input("Key", key, window, cx),
857 value: new_input("Value", value, window, cx),
858 }
859}
860
861/// Creates the form state and pushes the form sub-page onto the stack.
862pub(crate) fn open_mcp_server_form(
863 settings_window: &mut SettingsWindow,
864 transport: McpTransport,
865 existing: Option<(ContextServerId, ContextServerSettings)>,
866 window: &mut Window,
867 cx: &mut Context<SettingsWindow>,
868) {
869 let is_edit = existing.is_some();
870 settings_window.mcp_server_form = Some(McpServerForm::new(transport, existing, window, cx));
871
872 let title = if is_edit {
873 "Configure MCP Server"
874 } else {
875 match transport {
876 McpTransport::Stdio => "Add Local MCP Server",
877 McpTransport::Http => "Add Remote MCP Server",
878 }
879 };
880
881 settings_window.push_dynamic_sub_page(
882 title,
883 "Agent Configuration",
884 Some("context_servers"),
885 false,
886 render_mcp_server_form_page,
887 window,
888 cx,
889 );
890}
891
892fn render_mcp_server_form_page(
893 settings_window: &SettingsWindow,
894 scroll_handle: &ScrollHandle,
895 _window: &mut Window,
896 cx: &mut Context<SettingsWindow>,
897) -> AnyElement {
898 let Some(form) = settings_window.mcp_server_form.as_ref() else {
899 return div().into_any_element();
900 };
901 let transport = form.transport;
902 let error = form.error.clone();
903
904 let fields = v_flex()
905 .w_full()
906 .gap_4()
907 .child(render_form_field(
908 settings_window,
909 "Server Name",
910 "Required. A unique name used to identify this MCP server.",
911 &form.name,
912 cx,
913 ))
914 .map(|this| match transport {
915 McpTransport::Stdio => this
916 .child(render_form_field(
917 settings_window,
918 "Command",
919 "Required. Path to the executable that launches the server.",
920 &form.command,
921 cx,
922 ))
923 .child(render_form_field(
924 settings_window,
925 "Arguments",
926 "Space-separated arguments passed to the command.",
927 &form.args,
928 cx,
929 ))
930 .child(render_kv_section(
931 settings_window,
932 "Environment Variables",
933 "Environment variables provided to the server process.",
934 &form.env,
935 McpKvKind::Env,
936 cx,
937 ))
938 .child(render_form_field(
939 settings_window,
940 "Timeout (seconds)",
941 "How long to wait for the server to respond before timing out.",
942 &form.timeout,
943 cx,
944 )),
945 McpTransport::Http => this
946 .child(render_form_field(
947 settings_window,
948 "URL",
949 "Required. The base URL of the remote MCP server.",
950 &form.url,
951 cx,
952 ))
953 .child(render_kv_section(
954 settings_window,
955 "Headers",
956 "HTTP headers sent with each request to the server.",
957 &form.headers,
958 McpKvKind::Header,
959 cx,
960 ))
961 .child(render_form_field(
962 settings_window,
963 "Timeout (seconds)",
964 "How long to wait for the server to respond before timing out.",
965 &form.timeout,
966 cx,
967 ))
968 .child(render_form_field(
969 settings_window,
970 "OAuth Client ID",
971 "Optional OAuth client ID used to authenticate with the server.",
972 &form.oauth_client_id,
973 cx,
974 )),
975 })
976 .when_some(error, |this, error| this.child(render_form_error(error)))
977 .child(render_form_actions(cx));
978
979 v_flex()
980 .id("mcp-server-form-page")
981 .size_full()
982 .pt_2p5()
983 .px_8()
984 .pb_16()
985 .track_scroll(scroll_handle)
986 .overflow_y_scroll()
987 .child(fields)
988 .into_any_element()
989}
990
991fn input_box(editor: &Entity<Editor>, cx: &App) -> impl IntoElement {
992 let colors = cx.theme().colors();
993 // All form inputs share tab index 0, so tab order follows render (insertion)
994 // order. Tracking the editor's focus handle makes the field a tab stop and
995 // routes keyboard focus into the editor when tabbed to.
996 let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
997 h_flex()
998 .min_w_64()
999 .py_1()
1000 .px_2()
1001 .h_8()
1002 .rounded_md()
1003 .border_1()
1004 .border_color(colors.border)
1005 .bg(colors.editor_background)
1006 .track_focus(&focus_handle)
1007 .focus(|style| style.border_color(colors.border_focused))
1008 .child(editor.clone())
1009}
1010
1011fn render_form_field(
1012 settings_window: &SettingsWindow,
1013 title: &'static str,
1014 description: &'static str,
1015 editor: &Entity<Editor>,
1016 cx: &mut Context<SettingsWindow>,
1017) -> AnyElement {
1018 let control = input_box(editor, cx).into_any_element();
1019 crate::render_settings_item_layout(
1020 settings_window,
1021 title,
1022 description,
1023 control,
1024 None,
1025 None,
1026 None,
1027 false,
1028 cx,
1029 )
1030 .into_any_element()
1031}
1032
1033fn render_kv_section(
1034 settings_window: &SettingsWindow,
1035 title: &'static str,
1036 description: &'static str,
1037 rows: &[KeyValueRow],
1038 kind: McpKvKind,
1039 cx: &mut Context<SettingsWindow>,
1040) -> AnyElement {
1041 let control = v_flex()
1042 .min_w_64()
1043 .gap_2()
1044 .children(rows.iter().enumerate().map(|(ix, row)| {
1045 v_flex()
1046 .gap_1()
1047 .child(
1048 h_flex()
1049 .gap_1()
1050 .items_center()
1051 .child(input_box(&row.key, cx))
1052 .child(
1053 IconButton::new((kind.remove_id(), ix), IconName::Close)
1054 .icon_size(IconSize::Small)
1055 .icon_color(Color::Muted)
1056 .tooltip(Tooltip::text("Remove"))
1057 .on_click(cx.listener(move |this, _, _window, cx| {
1058 if let Some(form) = this.mcp_server_form.as_mut() {
1059 let rows = kind.rows_mut(form);
1060 if ix < rows.len() {
1061 rows.remove(ix);
1062 }
1063 }
1064 cx.notify();
1065 })),
1066 ),
1067 )
1068 .child(input_box(&row.value, cx))
1069 }))
1070 .child(
1071 Button::new(kind.add_id(), "Add")
1072 .style(ButtonStyle::Outlined)
1073 .label_size(LabelSize::Small)
1074 .start_icon(
1075 Icon::new(IconName::Plus)
1076 .size(IconSize::Small)
1077 .color(Color::Muted),
1078 )
1079 .on_click(cx.listener(move |this, _, window, cx| {
1080 let row = new_kv_row(None, None, window, cx);
1081 if let Some(form) = this.mcp_server_form.as_mut() {
1082 kind.rows_mut(form).push(row);
1083 }
1084 cx.notify();
1085 })),
1086 )
1087 .into_any_element();
1088
1089 crate::render_settings_item_layout(
1090 settings_window,
1091 title,
1092 description,
1093 control,
1094 None,
1095 None,
1096 None,
1097 false,
1098 cx,
1099 )
1100 .into_any_element()
1101}
1102
1103fn render_form_error(error: SharedString) -> impl IntoElement {
1104 h_flex()
1105 .w_full()
1106 .gap_2()
1107 .items_start()
1108 .child(
1109 Icon::new(IconName::XCircle)
1110 .size(IconSize::Small)
1111 .color(Color::Error),
1112 )
1113 .child(Label::new(error).size(LabelSize::Small).color(Color::Error))
1114}
1115
1116fn render_form_actions(cx: &mut Context<SettingsWindow>) -> impl IntoElement {
1117 h_flex()
1118 .w_full()
1119 .gap_2()
1120 .justify_end()
1121 .pt_2()
1122 .child(
1123 Button::new("mcp-form-cancel", "Cancel")
1124 .style(ButtonStyle::Subtle)
1125 .on_click(cx.listener(|this, _, window, cx| {
1126 this.mcp_server_form = None;
1127 this.pop_sub_page(window, cx);
1128 })),
1129 )
1130 .child(
1131 Button::new("mcp-form-save", "Save")
1132 .style(ButtonStyle::Filled)
1133 .on_click(cx.listener(|this, _, window, cx| {
1134 save_mcp_server_form(this, window, cx);
1135 })),
1136 )
1137}
1138
1139fn save_mcp_server_form(
1140 settings_window: &mut SettingsWindow,
1141 window: &mut Window,
1142 cx: &mut Context<SettingsWindow>,
1143) {
1144 let built = {
1145 let Some(form) = settings_window.mcp_server_form.as_ref() else {
1146 return;
1147 };
1148 build_settings_from_form(form, cx)
1149 };
1150
1151 let (id, original_id, content) = match built {
1152 Ok(value) => value,
1153 Err(error) => {
1154 if let Some(form) = settings_window.mcp_server_form.as_mut() {
1155 form.error = Some(error);
1156 }
1157 cx.notify();
1158 return;
1159 }
1160 };
1161
1162 // Reject names that would collide with a *different* existing server. This
1163 // covers both adding a new server and renaming an existing one (where the new
1164 // name must not clobber another server's configuration).
1165 let collides_with_other_server =
1166 get_context_server_store(settings_window, cx).is_some_and(|store| {
1167 name_collides_with_other_server(&id, original_id.as_ref(), store.read(cx).server_ids())
1168 });
1169 if collides_with_other_server {
1170 if let Some(form) = settings_window.mcp_server_form.as_mut() {
1171 form.error = Some(format!("A server named \"{}\" already exists.", id.0).into());
1172 }
1173 cx.notify();
1174 return;
1175 }
1176
1177 let fs = <dyn fs::Fs>::global(cx);
1178 settings::update_settings_file(fs, cx, move |settings, _| {
1179 if let Some(original_id) = &original_id
1180 && original_id.0 != id.0
1181 {
1182 settings.project.context_servers.remove(&original_id.0);
1183 }
1184 settings
1185 .project
1186 .context_servers
1187 .insert(id.0.clone(), content);
1188 });
1189
1190 settings_window.mcp_server_form = None;
1191 settings_window.pop_sub_page(window, cx);
1192}
1193
1194/// Plain (editor-free) snapshot of the form's contents, so the validation /
1195/// build logic can be exercised without a GPUI context.
1196struct McpServerFormValues {
1197 transport: McpTransport,
1198 original_id: Option<ContextServerId>,
1199 name: String,
1200 command: String,
1201 args: String,
1202 url: String,
1203 timeout: String,
1204 oauth_client_id: String,
1205 env: Vec<(String, String)>,
1206 headers: Vec<(String, String)>,
1207}
1208
1209fn build_settings_from_form(
1210 form: &McpServerForm,
1211 cx: &App,
1212) -> Result<
1213 (
1214 ContextServerId,
1215 Option<ContextServerId>,
1216 ContextServerSettingsContent,
1217 ),
1218 SharedString,
1219> {
1220 let values = McpServerFormValues {
1221 transport: form.transport,
1222 original_id: form.original_id.clone(),
1223 name: form.name.read(cx).text(cx),
1224 command: form.command.read(cx).text(cx),
1225 args: form.args.read(cx).text(cx),
1226 url: form.url.read(cx).text(cx),
1227 timeout: form.timeout.read(cx).text(cx),
1228 oauth_client_id: form.oauth_client_id.read(cx).text(cx),
1229 env: read_kv(&form.env, cx),
1230 headers: read_kv(&form.headers, cx),
1231 };
1232 build_settings_from_values(&values)
1233}
1234
1235fn read_kv(rows: &[KeyValueRow], cx: &App) -> Vec<(String, String)> {
1236 rows.iter()
1237 .map(|row| (row.key.read(cx).text(cx), row.value.read(cx).text(cx)))
1238 .collect()
1239}
1240
1241fn build_settings_from_values(
1242 values: &McpServerFormValues,
1243) -> Result<
1244 (
1245 ContextServerId,
1246 Option<ContextServerId>,
1247 ContextServerSettingsContent,
1248 ),
1249 SharedString,
1250> {
1251 let name = values.name.trim().to_string();
1252 if name.is_empty() {
1253 return Err("Server name is required.".into());
1254 }
1255
1256 let timeout = parse_timeout(&values.timeout)?;
1257
1258 let content = match values.transport {
1259 McpTransport::Stdio => {
1260 let command = values.command.trim().to_string();
1261 if command.is_empty() {
1262 return Err("Command is required.".into());
1263 }
1264 let args = values
1265 .args
1266 .split_whitespace()
1267 .map(|arg| arg.to_string())
1268 .collect::<Vec<_>>();
1269 let env = collect_kv(&values.env, "environment variable")?;
1270 ContextServerSettingsContent::Stdio {
1271 enabled: true,
1272 remote: false,
1273 command: ContextServerCommand {
1274 path: command.into(),
1275 args,
1276 env: (!env.is_empty()).then_some(env),
1277 timeout,
1278 },
1279 }
1280 }
1281 McpTransport::Http => {
1282 let url = values.url.trim().to_string();
1283 if url.is_empty() {
1284 return Err("URL is required.".into());
1285 }
1286 // Validate the URL on save (a deliberate action) rather than on every
1287 // render, so a clearly invalid URL is reported to the user instead of
1288 // being silently written and failing later when the server starts.
1289 if let Err(error) = url::Url::parse(&url) {
1290 return Err(format!("Invalid URL: {error}").into());
1291 }
1292 let headers = collect_kv(&values.headers, "header")?;
1293 let oauth_client_id = values.oauth_client_id.trim().to_string();
1294 let oauth = (!oauth_client_id.is_empty()).then(|| OAuthClientSettings {
1295 client_id: oauth_client_id,
1296 client_secret: None,
1297 });
1298 ContextServerSettingsContent::Http {
1299 enabled: true,
1300 url,
1301 headers,
1302 timeout,
1303 oauth,
1304 }
1305 }
1306 };
1307
1308 Ok((
1309 ContextServerId(name.into()),
1310 values.original_id.clone(),
1311 content,
1312 ))
1313}
1314
1315/// Returns a human-readable error when a server's configured settings are
1316/// invalid in a way that prevents it from starting (currently: an HTTP server
1317/// whose URL cannot be parsed). Used to surface misconfiguration in the list.
1318fn settings_validation_error(settings: Option<&ContextServerSettings>) -> Option<SharedString> {
1319 match settings? {
1320 ContextServerSettings::Http { url, .. } if url::Url::parse(url).is_err() => {
1321 Some("Invalid URL in settings.".into())
1322 }
1323 _ => None,
1324 }
1325}
1326
1327/// Returns whether saving under `id` would overwrite a *different* existing
1328/// server. Editing a server in place (`id == original_id`) is allowed.
1329fn name_collides_with_other_server(
1330 id: &ContextServerId,
1331 original_id: Option<&ContextServerId>,
1332 existing_ids: &[ContextServerId],
1333) -> bool {
1334 original_id.is_none_or(|original| original.0 != id.0)
1335 && existing_ids.iter().any(|existing| existing.0 == id.0)
1336}
1337
1338fn parse_timeout(text: &str) -> Result<Option<u64>, SharedString> {
1339 let text = text.trim();
1340 if text.is_empty() {
1341 return Ok(None);
1342 }
1343 text.parse::<u64>()
1344 .map(Some)
1345 .map_err(|_| "Timeout must be a positive whole number of seconds.".into())
1346}
1347
1348fn collect_kv(
1349 rows: &[(String, String)],
1350 label: &str,
1351) -> Result<HashMap<String, String>, SharedString> {
1352 let mut map = HashMap::default();
1353 for (key, value) in rows {
1354 let key = key.trim().to_string();
1355 if key.is_empty() {
1356 continue;
1357 }
1358 if map.contains_key(&key) {
1359 return Err(format!("Duplicate {label} \"{key}\".").into());
1360 }
1361 map.insert(key, value.clone());
1362 }
1363 Ok(map)
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369
1370 fn values(transport: McpTransport) -> McpServerFormValues {
1371 McpServerFormValues {
1372 transport,
1373 original_id: None,
1374 name: "my-server".into(),
1375 command: String::new(),
1376 args: String::new(),
1377 url: String::new(),
1378 timeout: String::new(),
1379 oauth_client_id: String::new(),
1380 env: Vec::new(),
1381 headers: Vec::new(),
1382 }
1383 }
1384
1385 fn id(name: &str) -> ContextServerId {
1386 ContextServerId(name.into())
1387 }
1388
1389 #[test]
1390 fn parse_timeout_handles_empty_and_invalid() {
1391 assert_eq!(parse_timeout(""), Ok(None));
1392 assert_eq!(parse_timeout(" "), Ok(None));
1393 assert_eq!(parse_timeout("60"), Ok(Some(60)));
1394 assert_eq!(parse_timeout(" 90 "), Ok(Some(90)));
1395 assert!(parse_timeout("abc").is_err());
1396 assert!(parse_timeout("-5").is_err());
1397 assert!(parse_timeout("1.5").is_err());
1398 }
1399
1400 #[test]
1401 fn requires_server_name() {
1402 let mut values = values(McpTransport::Stdio);
1403 values.name = " ".into();
1404 values.command = "/bin/server".into();
1405 assert_eq!(
1406 build_settings_from_values(&values).unwrap_err().as_ref(),
1407 "Server name is required."
1408 );
1409 }
1410
1411 #[test]
1412 fn requires_command_for_local_server() {
1413 let values = values(McpTransport::Stdio);
1414 assert_eq!(
1415 build_settings_from_values(&values).unwrap_err().as_ref(),
1416 "Command is required."
1417 );
1418 }
1419
1420 #[test]
1421 fn requires_url_for_remote_server() {
1422 let values = values(McpTransport::Http);
1423 assert_eq!(
1424 build_settings_from_values(&values).unwrap_err().as_ref(),
1425 "URL is required."
1426 );
1427 }
1428
1429 #[test]
1430 fn rejects_invalid_url() {
1431 let mut values = values(McpTransport::Http);
1432 values.url = "not a url".into();
1433 let error = build_settings_from_values(&values).unwrap_err();
1434 assert!(
1435 error.starts_with("Invalid URL"),
1436 "unexpected error: {error}"
1437 );
1438 }
1439
1440 #[test]
1441 fn rejects_invalid_timeout() {
1442 let mut values = values(McpTransport::Stdio);
1443 values.command = "/bin/server".into();
1444 values.timeout = "soon".into();
1445 assert_eq!(
1446 build_settings_from_values(&values).unwrap_err().as_ref(),
1447 "Timeout must be a positive whole number of seconds."
1448 );
1449 }
1450
1451 #[test]
1452 fn rejects_duplicate_environment_variables() {
1453 let mut values = values(McpTransport::Stdio);
1454 values.command = "/bin/server".into();
1455 values.env = vec![("FOO".into(), "1".into()), ("FOO".into(), "2".into())];
1456 assert_eq!(
1457 build_settings_from_values(&values).unwrap_err().as_ref(),
1458 "Duplicate environment variable \"FOO\"."
1459 );
1460 }
1461
1462 #[test]
1463 fn rejects_duplicate_headers() {
1464 let mut values = values(McpTransport::Http);
1465 values.url = "https://example.com/mcp".into();
1466 values.headers = vec![
1467 ("Authorization".into(), "a".into()),
1468 ("Authorization".into(), "b".into()),
1469 ];
1470 assert_eq!(
1471 build_settings_from_values(&values).unwrap_err().as_ref(),
1472 "Duplicate header \"Authorization\"."
1473 );
1474 }
1475
1476 #[test]
1477 fn builds_local_server() {
1478 let mut values = values(McpTransport::Stdio);
1479 values.name = " local ".into();
1480 values.command = "/usr/bin/server".into();
1481 values.args = "--flag value".into();
1482 values.timeout = "30".into();
1483 // Empty values are kept, but rows with a blank key are ignored.
1484 values.env = vec![
1485 ("KEY".into(), "VALUE".into()),
1486 ("EMPTY".into(), String::new()),
1487 (" ".into(), "ignored".into()),
1488 ];
1489
1490 let (id, original_id, content) = build_settings_from_values(&values).unwrap();
1491 assert_eq!(id.0.as_ref(), "local");
1492 assert_eq!(original_id, None);
1493
1494 let expected_env = HashMap::from_iter([
1495 ("KEY".to_string(), "VALUE".to_string()),
1496 ("EMPTY".to_string(), String::new()),
1497 ]);
1498 assert_eq!(
1499 content,
1500 ContextServerSettingsContent::Stdio {
1501 enabled: true,
1502 remote: false,
1503 command: ContextServerCommand {
1504 path: "/usr/bin/server".into(),
1505 args: vec!["--flag".into(), "value".into()],
1506 env: Some(expected_env),
1507 timeout: Some(30),
1508 },
1509 }
1510 );
1511 }
1512
1513 #[test]
1514 fn builds_remote_server() {
1515 let mut values = values(McpTransport::Http);
1516 values.name = "remote".into();
1517 values.url = "https://example.com/mcp".into();
1518 values.oauth_client_id = "client-123".into();
1519 values.headers = vec![("Authorization".into(), "Bearer token".into())];
1520
1521 let (id, _, content) = build_settings_from_values(&values).unwrap();
1522 assert_eq!(id.0.as_ref(), "remote");
1523
1524 let expected_headers =
1525 HashMap::from_iter([("Authorization".to_string(), "Bearer token".to_string())]);
1526 assert_eq!(
1527 content,
1528 ContextServerSettingsContent::Http {
1529 enabled: true,
1530 url: "https://example.com/mcp".into(),
1531 headers: expected_headers,
1532 timeout: None,
1533 oauth: Some(OAuthClientSettings {
1534 client_id: "client-123".into(),
1535 client_secret: None,
1536 }),
1537 }
1538 );
1539 }
1540
1541 #[test]
1542 fn flags_invalid_url_in_settings() {
1543 let http = |url: &str| ContextServerSettings::Http {
1544 enabled: true,
1545 url: url.into(),
1546 headers: HashMap::default(),
1547 timeout: None,
1548 oauth: None,
1549 };
1550 assert_eq!(
1551 settings_validation_error(Some(&http("not a url")))
1552 .unwrap()
1553 .as_ref(),
1554 "Invalid URL in settings."
1555 );
1556 assert!(settings_validation_error(Some(&http("https://example.com/mcp"))).is_none());
1557 assert!(settings_validation_error(None).is_none());
1558 }
1559
1560 #[test]
1561 fn name_collision_covers_new_and_rename() {
1562 let existing = vec![id("foo"), id("bar")];
1563
1564 // New server taking an existing name collides.
1565 assert!(name_collides_with_other_server(&id("foo"), None, &existing));
1566 // New server with a free name is fine.
1567 assert!(!name_collides_with_other_server(
1568 &id("baz"),
1569 None,
1570 &existing
1571 ));
1572 // Editing a server in place is allowed even though the name "exists".
1573 assert!(!name_collides_with_other_server(
1574 &id("foo"),
1575 Some(&id("foo")),
1576 &existing
1577 ));
1578 // Renaming onto a different server's name collides.
1579 assert!(name_collides_with_other_server(
1580 &id("bar"),
1581 Some(&id("foo")),
1582 &existing
1583 ));
1584 // Renaming to a free name is fine.
1585 assert!(!name_collides_with_other_server(
1586 &id("baz"),
1587 Some(&id("foo")),
1588 &existing
1589 ));
1590 }
1591}
1592