1use std::{
2 path::PathBuf,
3 sync::{Arc, Mutex},
4};
5
6use anyhow::{Context as _, Result};
7use context_server::{ContextServerCommand, ContextServerId};
8use editor::{Editor, EditorElement, EditorStyle};
9use gpui::{
10 AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task,
11 TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
12};
13use language::{Language, LanguageRegistry};
14use markdown::{Markdown, MarkdownElement, MarkdownStyle};
15use notifications::status_toast::{StatusToast, ToastIcon};
16use project::{
17 context_server_store::{
18 ContextServerStatus, ContextServerStore, registry::ContextServerDescriptorRegistry,
19 },
20 project_settings::{ContextServerSettings, ProjectSettings},
21 worktree_store::WorktreeStore,
22};
23use settings::{Settings as _, update_settings_file};
24use theme::ThemeSettings;
25use ui::{
26 CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*,
27};
28use util::ResultExt as _;
29use workspace::{ModalView, Workspace};
30
31use crate::AddContextServer;
32
33enum ConfigurationTarget {
34 New,
35 Existing {
36 id: ContextServerId,
37 command: ContextServerCommand,
38 },
39 Extension {
40 id: ContextServerId,
41 repository_url: Option<SharedString>,
42 installation: Option<extension::ContextServerConfiguration>,
43 },
44}
45
46enum ConfigurationSource {
47 New {
48 editor: Entity<Editor>,
49 },
50 Existing {
51 editor: Entity<Editor>,
52 },
53 Extension {
54 id: ContextServerId,
55 editor: Option<Entity<Editor>>,
56 repository_url: Option<SharedString>,
57 installation_instructions: Option<Entity<markdown::Markdown>>,
58 settings_validator: Option<jsonschema::Validator>,
59 },
60}
61
62impl ConfigurationSource {
63 fn has_configuration_options(&self) -> bool {
64 !matches!(self, ConfigurationSource::Extension { editor: None, .. })
65 }
66
67 fn is_new(&self) -> bool {
68 matches!(self, ConfigurationSource::New { .. })
69 }
70
71 fn from_target(
72 target: ConfigurationTarget,
73 language_registry: Arc<LanguageRegistry>,
74 jsonc_language: Option<Arc<Language>>,
75 window: &mut Window,
76 cx: &mut App,
77 ) -> Self {
78 fn create_editor(
79 json: String,
80 jsonc_language: Option<Arc<Language>>,
81 window: &mut Window,
82 cx: &mut App,
83 ) -> Entity<Editor> {
84 cx.new(|cx| {
85 let mut editor = Editor::auto_height(4, 16, window, cx);
86 editor.set_text(json, window, cx);
87 editor.set_show_gutter(false, cx);
88 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
89 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
90 buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx))
91 }
92 editor
93 })
94 }
95
96 match target {
97 ConfigurationTarget::New => ConfigurationSource::New {
98 editor: create_editor(context_server_input(None), jsonc_language, window, cx),
99 },
100 ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing {
101 editor: create_editor(
102 context_server_input(Some((id, command))),
103 jsonc_language,
104 window,
105 cx,
106 ),
107 },
108 ConfigurationTarget::Extension {
109 id,
110 repository_url,
111 installation,
112 } => {
113 let settings_validator = installation.as_ref().and_then(|installation| {
114 jsonschema::validator_for(&installation.settings_schema)
115 .context("Failed to load JSON schema for context server settings")
116 .log_err()
117 });
118 let installation_instructions = installation.as_ref().map(|installation| {
119 cx.new(|cx| {
120 Markdown::new(
121 installation.installation_instructions.clone().into(),
122 Some(language_registry.clone()),
123 None,
124 cx,
125 )
126 })
127 });
128 ConfigurationSource::Extension {
129 id,
130 repository_url,
131 installation_instructions,
132 settings_validator,
133 editor: installation.map(|installation| {
134 create_editor(installation.default_settings, jsonc_language, window, cx)
135 }),
136 }
137 }
138 }
139 }
140
141 fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> {
142 match self {
143 ConfigurationSource::New { editor } | ConfigurationSource::Existing { editor } => {
144 parse_input(&editor.read(cx).text(cx)).map(|(id, command)| {
145 (
146 id,
147 ContextServerSettings::Custom {
148 enabled: true,
149 command,
150 },
151 )
152 })
153 }
154 ConfigurationSource::Extension {
155 id,
156 editor,
157 settings_validator,
158 ..
159 } => {
160 let text = editor
161 .as_ref()
162 .context("No output available")?
163 .read(cx)
164 .text(cx);
165 let settings = serde_json_lenient::from_str::<serde_json::Value>(&text)?;
166 if let Some(settings_validator) = settings_validator
167 && let Err(error) = settings_validator.validate(&settings)
168 {
169 return Err(anyhow::anyhow!(error.to_string()));
170 }
171 Ok((
172 id.clone(),
173 ContextServerSettings::Extension {
174 enabled: true,
175 settings,
176 },
177 ))
178 }
179 }
180 }
181}
182
183fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String {
184 let (name, command, args, env) = match existing {
185 Some((id, cmd)) => {
186 let args = serde_json::to_string(&cmd.args).unwrap();
187 let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap();
188 (id.0.to_string(), cmd.path, args, env)
189 }
190 None => (
191 "some-mcp-server".to_string(),
192 PathBuf::new(),
193 "[]".to_string(),
194 "{}".to_string(),
195 ),
196 };
197
198 format!(
199 r#"{{
200 /// The name of your MCP server
201 "{name}": {{
202 /// The command which runs the MCP server
203 "command": "{}",
204 /// The arguments to pass to the MCP server
205 "args": {args},
206 /// The environment variables to set
207 "env": {env}
208 }}
209}}"#,
210 command.display()
211 )
212}
213
214fn resolve_context_server_extension(
215 id: ContextServerId,
216 worktree_store: Entity<WorktreeStore>,
217 cx: &mut App,
218) -> Task<Option<ConfigurationTarget>> {
219 let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
220
221 let Some(descriptor) = registry.context_server_descriptor(&id.0) else {
222 return Task::ready(None);
223 };
224
225 let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx);
226 cx.spawn(async move |cx| {
227 let installation = descriptor
228 .configuration(worktree_store, cx)
229 .await
230 .context("Failed to resolve context server configuration")
231 .log_err()
232 .flatten();
233
234 Some(ConfigurationTarget::Extension {
235 id,
236 repository_url: extension
237 .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)),
238 installation,
239 })
240 })
241}
242
243enum State {
244 Idle,
245 Waiting,
246 Error(SharedString),
247}
248
249pub struct ConfigureContextServerModal {
250 context_server_store: Entity<ContextServerStore>,
251 workspace: WeakEntity<Workspace>,
252 source: ConfigurationSource,
253 state: State,
254 original_server_id: Option<ContextServerId>,
255}
256
257impl ConfigureContextServerModal {
258 pub fn register(
259 workspace: &mut Workspace,
260 language_registry: Arc<LanguageRegistry>,
261 _window: Option<&mut Window>,
262 _cx: &mut Context<Workspace>,
263 ) {
264 workspace.register_action({
265 move |_workspace, _: &AddContextServer, window, cx| {
266 let workspace_handle = cx.weak_entity();
267 let language_registry = language_registry.clone();
268 window
269 .spawn(cx, async move |cx| {
270 Self::show_modal(
271 ConfigurationTarget::New,
272 language_registry,
273 workspace_handle,
274 cx,
275 )
276 .await
277 })
278 .detach_and_log_err(cx);
279 }
280 });
281 }
282
283 pub fn show_modal_for_existing_server(
284 server_id: ContextServerId,
285 language_registry: Arc<LanguageRegistry>,
286 workspace: WeakEntity<Workspace>,
287 window: &mut Window,
288 cx: &mut App,
289 ) -> Task<Result<()>> {
290 let Some(settings) = ProjectSettings::get_global(cx)
291 .context_servers
292 .get(&server_id.0)
293 .cloned()
294 .or_else(|| {
295 ContextServerDescriptorRegistry::default_global(cx)
296 .read(cx)
297 .context_server_descriptor(&server_id.0)
298 .map(|_| ContextServerSettings::default_extension())
299 })
300 else {
301 return Task::ready(Err(anyhow::anyhow!("Context server not found")));
302 };
303
304 window.spawn(cx, async move |cx| {
305 let target = match settings {
306 ContextServerSettings::Custom {
307 enabled: _,
308 command,
309 } => Some(ConfigurationTarget::Existing {
310 id: server_id,
311 command,
312 }),
313 ContextServerSettings::Extension { .. } => {
314 match workspace
315 .update(cx, |workspace, cx| {
316 resolve_context_server_extension(
317 server_id,
318 workspace.project().read(cx).worktree_store(),
319 cx,
320 )
321 })
322 .ok()
323 {
324 Some(task) => task.await,
325 None => None,
326 }
327 }
328 };
329
330 match target {
331 Some(target) => Self::show_modal(target, language_registry, workspace, cx).await,
332 None => Err(anyhow::anyhow!("Failed to resolve context server")),
333 }
334 })
335 }
336
337 fn show_modal(
338 target: ConfigurationTarget,
339 language_registry: Arc<LanguageRegistry>,
340 workspace: WeakEntity<Workspace>,
341 cx: &mut AsyncWindowContext,
342 ) -> Task<Result<()>> {
343 cx.spawn(async move |cx| {
344 let jsonc_language = language_registry.language_for_name("jsonc").await.ok();
345 workspace.update_in(cx, |workspace, window, cx| {
346 let workspace_handle = cx.weak_entity();
347 let context_server_store = workspace.project().read(cx).context_server_store();
348 workspace.toggle_modal(window, cx, |window, cx| Self {
349 context_server_store,
350 workspace: workspace_handle,
351 state: State::Idle,
352 original_server_id: match &target {
353 ConfigurationTarget::Existing { id, .. } => Some(id.clone()),
354 ConfigurationTarget::Extension { id, .. } => Some(id.clone()),
355 ConfigurationTarget::New => None,
356 },
357 source: ConfigurationSource::from_target(
358 target,
359 language_registry,
360 jsonc_language,
361 window,
362 cx,
363 ),
364 })
365 })
366 })
367 }
368
369 fn set_error(&mut self, err: impl Into<SharedString>, cx: &mut Context<Self>) {
370 self.state = State::Error(err.into());
371 cx.notify();
372 }
373
374 fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
375 self.state = State::Idle;
376 let Some(workspace) = self.workspace.upgrade() else {
377 return;
378 };
379
380 let (id, settings) = match self.source.output(cx) {
381 Ok(val) => val,
382 Err(error) => {
383 self.set_error(error.to_string(), cx);
384 return;
385 }
386 };
387
388 self.state = State::Waiting;
389
390 let existing_server = self.context_server_store.read(cx).get_running_server(&id);
391 if existing_server.is_some() {
392 self.context_server_store.update(cx, |store, cx| {
393 store.stop_server(&id, cx).log_err();
394 });
395 }
396
397 let wait_for_context_server_task =
398 wait_for_context_server(&self.context_server_store, id.clone(), cx);
399 cx.spawn({
400 let id = id.clone();
401 async move |this, cx| {
402 let result = wait_for_context_server_task.await;
403 this.update(cx, |this, cx| match result {
404 Ok(_) => {
405 this.state = State::Idle;
406 this.show_configured_context_server_toast(id, cx);
407 cx.emit(DismissEvent);
408 }
409 Err(err) => {
410 this.set_error(err, cx);
411 }
412 })
413 }
414 })
415 .detach();
416
417 let settings_changed =
418 ProjectSettings::get_global(cx).context_servers.get(&id.0) != Some(&settings);
419
420 if settings_changed {
421 // When we write the settings to the file, the context server will be restarted.
422 workspace.update(cx, |workspace, cx| {
423 let fs = workspace.app_state().fs.clone();
424 let original_server_id = self.original_server_id.clone();
425 update_settings_file::<ProjectSettings>(
426 fs.clone(),
427 cx,
428 move |project_settings, _| {
429 if let Some(original_id) = original_server_id {
430 if original_id != id {
431 project_settings.context_servers.remove(&original_id.0);
432 }
433 }
434 project_settings.context_servers.insert(id.0, settings);
435 },
436 );
437 });
438 } else if let Some(existing_server) = existing_server {
439 self.context_server_store
440 .update(cx, |store, cx| store.start_server(existing_server, cx));
441 }
442 }
443
444 fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
445 cx.emit(DismissEvent);
446 }
447
448 fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) {
449 self.workspace
450 .update(cx, {
451 |workspace, cx| {
452 let status_toast = StatusToast::new(
453 format!("{} configured successfully.", id.0),
454 cx,
455 |this, _cx| {
456 this.icon(ToastIcon::new(IconName::ToolHammer).color(Color::Muted))
457 .action("Dismiss", |_, _| {})
458 },
459 );
460
461 workspace.toggle_status_toast(status_toast, cx);
462 }
463 })
464 .log_err();
465 }
466}
467
468fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> {
469 let value: serde_json::Value = serde_json_lenient::from_str(text)?;
470 let object = value.as_object().context("Expected object")?;
471 anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair");
472 let (context_server_name, value) = object.into_iter().next().unwrap();
473 let command: ContextServerCommand = serde_json::from_value(value.clone())?;
474 Ok((ContextServerId(context_server_name.clone().into()), command))
475}
476
477impl ModalView for ConfigureContextServerModal {}
478
479impl Focusable for ConfigureContextServerModal {
480 fn focus_handle(&self, cx: &App) -> FocusHandle {
481 match &self.source {
482 ConfigurationSource::New { editor } => editor.focus_handle(cx),
483 ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx),
484 ConfigurationSource::Extension { editor, .. } => editor
485 .as_ref()
486 .map(|editor| editor.focus_handle(cx))
487 .unwrap_or_else(|| cx.focus_handle()),
488 }
489 }
490}
491
492impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
493
494impl ConfigureContextServerModal {
495 fn render_modal_header(&self) -> ModalHeader {
496 let text: SharedString = match &self.source {
497 ConfigurationSource::New { .. } => "Add MCP Server".into(),
498 ConfigurationSource::Existing { .. } => "Configure MCP Server".into(),
499 ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(),
500 };
501 ModalHeader::new().headline(text)
502 }
503
504 fn render_modal_description(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
505 const MODAL_DESCRIPTION: &str = "Visit the MCP server configuration docs to find all necessary arguments and environment variables.";
506
507 if let ConfigurationSource::Extension {
508 installation_instructions: Some(installation_instructions),
509 ..
510 } = &self.source
511 {
512 div()
513 .pb_2()
514 .text_sm()
515 .child(MarkdownElement::new(
516 installation_instructions.clone(),
517 default_markdown_style(window, cx),
518 ))
519 .into_any_element()
520 } else {
521 Label::new(MODAL_DESCRIPTION)
522 .color(Color::Muted)
523 .into_any_element()
524 }
525 }
526
527 fn render_modal_content(&self, cx: &App) -> AnyElement {
528 let editor = match &self.source {
529 ConfigurationSource::New { editor } => editor,
530 ConfigurationSource::Existing { editor } => editor,
531 ConfigurationSource::Extension { editor, .. } => {
532 let Some(editor) = editor else {
533 return div().into_any_element();
534 };
535 editor
536 }
537 };
538
539 div()
540 .p_2()
541 .rounded_md()
542 .border_1()
543 .border_color(cx.theme().colors().border_variant)
544 .bg(cx.theme().colors().editor_background)
545 .child({
546 let settings = ThemeSettings::get_global(cx);
547 let text_style = TextStyle {
548 color: cx.theme().colors().text,
549 font_family: settings.buffer_font.family.clone(),
550 font_fallbacks: settings.buffer_font.fallbacks.clone(),
551 font_size: settings.buffer_font_size(cx).into(),
552 font_weight: settings.buffer_font.weight,
553 line_height: relative(settings.buffer_line_height.value()),
554 ..Default::default()
555 };
556 EditorElement::new(
557 editor,
558 EditorStyle {
559 background: cx.theme().colors().editor_background,
560 local_player: cx.theme().players().local(),
561 text: text_style,
562 syntax: cx.theme().syntax().clone(),
563 ..Default::default()
564 },
565 )
566 })
567 .into_any_element()
568 }
569
570 fn render_modal_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> ModalFooter {
571 let focus_handle = self.focus_handle(cx);
572 let is_connecting = matches!(self.state, State::Waiting);
573
574 ModalFooter::new()
575 .start_slot::<Button>(
576 if let ConfigurationSource::Extension {
577 repository_url: Some(repository_url),
578 ..
579 } = &self.source
580 {
581 Some(
582 Button::new("open-repository", "Open Repository")
583 .icon(IconName::ArrowUpRight)
584 .icon_color(Color::Muted)
585 .icon_size(IconSize::Small)
586 .tooltip({
587 let repository_url = repository_url.clone();
588 move |window, cx| {
589 Tooltip::with_meta(
590 "Open Repository",
591 None,
592 repository_url.clone(),
593 window,
594 cx,
595 )
596 }
597 })
598 .on_click({
599 let repository_url = repository_url.clone();
600 move |_, _, cx| cx.open_url(&repository_url)
601 }),
602 )
603 } else {
604 None
605 },
606 )
607 .end_slot(
608 h_flex()
609 .gap_2()
610 .child(
611 Button::new(
612 "cancel",
613 if self.source.has_configuration_options() {
614 "Cancel"
615 } else {
616 "Dismiss"
617 },
618 )
619 .key_binding(
620 KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx)
621 .map(|kb| kb.size(rems_from_px(12.))),
622 )
623 .on_click(
624 cx.listener(|this, _event, _window, cx| this.cancel(&menu::Cancel, cx)),
625 ),
626 )
627 .children(self.source.has_configuration_options().then(|| {
628 Button::new(
629 "add-server",
630 if self.source.is_new() {
631 "Add Server"
632 } else {
633 "Configure Server"
634 },
635 )
636 .disabled(is_connecting)
637 .key_binding(
638 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx)
639 .map(|kb| kb.size(rems_from_px(12.))),
640 )
641 .on_click(
642 cx.listener(|this, _event, _window, cx| {
643 this.confirm(&menu::Confirm, cx)
644 }),
645 )
646 })),
647 )
648 }
649
650 fn render_waiting_for_context_server() -> Div {
651 h_flex()
652 .gap_2()
653 .child(
654 Icon::new(IconName::ArrowCircle)
655 .size(IconSize::XSmall)
656 .color(Color::Info)
657 .with_rotate_animation(2)
658 .into_any_element(),
659 )
660 .child(
661 Label::new("Waiting for Context Server")
662 .size(LabelSize::Small)
663 .color(Color::Muted),
664 )
665 }
666
667 fn render_modal_error(error: SharedString) -> Div {
668 h_flex()
669 .gap_2()
670 .child(
671 Icon::new(IconName::Warning)
672 .size(IconSize::XSmall)
673 .color(Color::Warning),
674 )
675 .child(
676 div()
677 .w_full()
678 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
679 )
680 }
681}
682
683impl Render for ConfigureContextServerModal {
684 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
685 div()
686 .elevation_3(cx)
687 .w(rems(34.))
688 .key_context("ConfigureContextServerModal")
689 .on_action(
690 cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
691 )
692 .on_action(
693 cx.listener(|this, _: &menu::Confirm, _window, cx| {
694 this.confirm(&menu::Confirm, cx)
695 }),
696 )
697 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
698 this.focus_handle(cx).focus(window);
699 }))
700 .child(
701 Modal::new("configure-context-server", None)
702 .header(self.render_modal_header())
703 .section(
704 Section::new()
705 .child(self.render_modal_description(window, cx))
706 .child(self.render_modal_content(cx))
707 .child(match &self.state {
708 State::Idle => div(),
709 State::Waiting => Self::render_waiting_for_context_server(),
710 State::Error(error) => Self::render_modal_error(error.clone()),
711 }),
712 )
713 .footer(self.render_modal_footer(window, cx)),
714 )
715 }
716}
717
718fn wait_for_context_server(
719 context_server_store: &Entity<ContextServerStore>,
720 context_server_id: ContextServerId,
721 cx: &mut App,
722) -> Task<Result<(), Arc<str>>> {
723 let (tx, rx) = futures::channel::oneshot::channel();
724 let tx = Arc::new(Mutex::new(Some(tx)));
725
726 let subscription = cx.subscribe(context_server_store, move |_, event, _cx| match event {
727 project::context_server_store::Event::ServerStatusChanged { server_id, status } => {
728 match status {
729 ContextServerStatus::Running => {
730 if server_id == &context_server_id
731 && let Some(tx) = tx.lock().unwrap().take()
732 {
733 let _ = tx.send(Ok(()));
734 }
735 }
736 ContextServerStatus::Stopped => {
737 if server_id == &context_server_id
738 && let Some(tx) = tx.lock().unwrap().take()
739 {
740 let _ = tx.send(Err("Context server stopped running".into()));
741 }
742 }
743 ContextServerStatus::Error(error) => {
744 if server_id == &context_server_id
745 && let Some(tx) = tx.lock().unwrap().take()
746 {
747 let _ = tx.send(Err(error.clone()));
748 }
749 }
750 _ => {}
751 }
752 }
753 });
754
755 cx.spawn(async move |_cx| {
756 let result = rx
757 .await
758 .map_err(|_| Arc::from("Context server store was dropped"))?;
759 drop(subscription);
760 result
761 })
762}
763
764pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
765 let theme_settings = ThemeSettings::get_global(cx);
766 let colors = cx.theme().colors();
767 let mut text_style = window.text_style();
768 text_style.refine(&TextStyleRefinement {
769 font_family: Some(theme_settings.ui_font.family.clone()),
770 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
771 font_features: Some(theme_settings.ui_font.features.clone()),
772 font_size: Some(TextSize::XSmall.rems(cx).into()),
773 color: Some(colors.text_muted),
774 ..Default::default()
775 });
776
777 MarkdownStyle {
778 base_text_style: text_style.clone(),
779 selection_background_color: colors.element_selection_background,
780 link: TextStyleRefinement {
781 background_color: Some(colors.editor_foreground.opacity(0.025)),
782 underline: Some(UnderlineStyle {
783 color: Some(colors.text_accent.opacity(0.5)),
784 thickness: px(1.),
785 ..Default::default()
786 }),
787 ..Default::default()
788 },
789 ..Default::default()
790 }
791}