Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:43:46.445Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

mcp_servers_page.rs

1592 lines · 54.9 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.