Skip to repository content1312 lines · 48.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:04:36.392Z 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
configure_context_server_modal.rs
1use anyhow::{Context as _, Result};
2use collections::HashMap;
3use context_server::{ContextServerCommand, ContextServerId};
4use editor::{Editor, EditorElement, EditorStyle};
5
6use extension_host::ExtensionStore;
7use gpui::{
8 AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle,
9 Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
10};
11use language::{Language, LanguageRegistry};
12use markdown::{Markdown, MarkdownElement, MarkdownStyle};
13use notifications::status_toast::StatusToast;
14use parking_lot::Mutex;
15use project::{
16 context_server_store::{
17 ContextServerStatus, ContextServerStore, ServerStatusChangedEvent,
18 registry::ContextServerDescriptorRegistry,
19 },
20 project_settings::{ContextServerSettings, OAuthClientSettings, ProjectSettings},
21 worktree_store::WorktreeStore,
22};
23use serde::Deserialize;
24use settings::{Settings as _, update_settings_file};
25use std::sync::Arc;
26use theme_settings::ThemeSettings;
27use ui::{
28 CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip,
29 WithScrollbar, prelude::*,
30};
31use util::ResultExt as _;
32use workspace::{ModalView, Workspace};
33
34enum ConfigurationTarget {
35 Existing {
36 id: ContextServerId,
37 command: ContextServerCommand,
38 },
39 ExistingHttp {
40 id: ContextServerId,
41 url: String,
42 headers: HashMap<String, String>,
43 oauth: Option<OAuthClientSettings>,
44 },
45
46 Extension {
47 id: ContextServerId,
48 repository_url: Option<SharedString>,
49 installation: Option<extension::ContextServerConfiguration>,
50 },
51}
52
53enum ExistingServerType {
54 Local,
55 Remote,
56}
57
58enum ConfigurationSource {
59 Existing {
60 editor: Entity<Editor>,
61 server_type: ExistingServerType,
62 },
63 Extension {
64 id: ContextServerId,
65 editor: Option<Entity<Editor>>,
66 repository_url: Option<SharedString>,
67 installation_instructions: Option<Entity<markdown::Markdown>>,
68 settings_validator: Option<jsonschema::Validator>,
69 },
70}
71
72impl ConfigurationSource {
73 fn has_configuration_options(&self) -> bool {
74 !matches!(self, ConfigurationSource::Extension { editor: None, .. })
75 }
76
77 fn from_target(
78 target: ConfigurationTarget,
79 language_registry: Arc<LanguageRegistry>,
80 jsonc_language: Option<Arc<Language>>,
81 window: &mut Window,
82 cx: &mut App,
83 ) -> Self {
84 fn create_editor(
85 json: String,
86 jsonc_language: Option<Arc<Language>>,
87 window: &mut Window,
88 cx: &mut App,
89 ) -> Entity<Editor> {
90 cx.new(|cx| {
91 let mut editor = Editor::auto_height(4, 16, window, cx);
92 editor.set_text(json, window, cx);
93 editor.set_show_gutter(false, cx);
94 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
95 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
96 buffer.update(cx, |buffer, cx| buffer.set_language(jsonc_language, cx))
97 }
98 editor
99 })
100 }
101
102 match target {
103 ConfigurationTarget::Existing { id, command } => ConfigurationSource::Existing {
104 editor: create_editor(
105 context_server_input(Some((id, command))),
106 jsonc_language,
107 window,
108 cx,
109 ),
110 server_type: ExistingServerType::Local,
111 },
112 ConfigurationTarget::ExistingHttp {
113 id,
114 url,
115 headers: auth,
116 oauth,
117 } => ConfigurationSource::Existing {
118 editor: create_editor(
119 context_server_http_input(Some((id, url, auth, oauth))),
120 jsonc_language,
121 window,
122 cx,
123 ),
124 server_type: ExistingServerType::Remote,
125 },
126
127 ConfigurationTarget::Extension {
128 id,
129 repository_url,
130 installation,
131 } => {
132 let settings_validator = installation.as_ref().and_then(|installation| {
133 jsonschema::validator_for(&installation.settings_schema)
134 .context("Failed to load JSON schema for context server settings")
135 .log_err()
136 });
137 let installation_instructions = installation.as_ref().map(|installation| {
138 cx.new(|cx| {
139 Markdown::new(
140 installation.installation_instructions.clone().into(),
141 Some(language_registry.clone()),
142 None,
143 cx,
144 )
145 })
146 });
147 ConfigurationSource::Extension {
148 id,
149 repository_url,
150 installation_instructions,
151 settings_validator,
152 editor: installation.map(|installation| {
153 create_editor(installation.default_settings, jsonc_language, window, cx)
154 }),
155 }
156 }
157 }
158 }
159
160 fn output(&self, cx: &mut App) -> Result<(ContextServerId, ContextServerSettings)> {
161 match self {
162 ConfigurationSource::Existing {
163 editor,
164 server_type,
165 } => match *server_type {
166 ExistingServerType::Remote => {
167 parse_http_input(&editor.read(cx).text(cx)).map(|(id, url, auth, oauth)| {
168 (
169 id,
170 ContextServerSettings::Http {
171 enabled: true,
172 url,
173 headers: auth,
174 timeout: None,
175 oauth,
176 },
177 )
178 })
179 }
180 ExistingServerType::Local => {
181 parse_input(&editor.read(cx).text(cx)).map(|(id, command)| {
182 (
183 id,
184 ContextServerSettings::Stdio {
185 enabled: true,
186 remote: false,
187 command,
188 },
189 )
190 })
191 }
192 },
193 ConfigurationSource::Extension {
194 id,
195 editor,
196 settings_validator,
197 ..
198 } => {
199 let text = editor
200 .as_ref()
201 .context("No output available")?
202 .read(cx)
203 .text(cx);
204 let settings = serde_json_lenient::from_str::<serde_json::Value>(&text)?;
205 if let Some(settings_validator) = settings_validator
206 && let Err(error) = settings_validator.validate(&settings)
207 {
208 return Err(anyhow::anyhow!(error.to_string()));
209 }
210 Ok((
211 id.clone(),
212 ContextServerSettings::Extension {
213 enabled: true,
214 remote: false,
215 settings,
216 },
217 ))
218 }
219 }
220 }
221}
222
223fn context_server_input(existing: Option<(ContextServerId, ContextServerCommand)>) -> String {
224 let (name, command, args, env) = match existing {
225 Some((id, cmd)) => {
226 let args = serde_json::to_string(&cmd.args).unwrap();
227 let env = serde_json::to_string(&cmd.env.unwrap_or_default()).unwrap();
228 let cmd_path = serde_json::to_string(&cmd.path).unwrap();
229 (id.0.to_string(), cmd_path, args, env)
230 }
231 None => (
232 "some-mcp-server".to_string(),
233 "".to_string(),
234 "[]".to_string(),
235 "{}".to_string(),
236 ),
237 };
238
239 format!(
240 r#"{{
241 /// Configure an MCP server that runs locally via stdin/stdout
242 ///
243 /// The name of your MCP server
244 "{name}": {{
245 /// The command which runs the MCP server
246 "command": {command},
247 /// The arguments to pass to the MCP server
248 "args": {args},
249 /// The environment variables to set
250 "env": {env}
251 }}
252}}"#
253 )
254}
255
256fn context_server_http_input(
257 existing: Option<(
258 ContextServerId,
259 String,
260 HashMap<String, String>,
261 Option<OAuthClientSettings>,
262 )>,
263) -> String {
264 let (name, url, headers, oauth) = match existing {
265 Some((id, url, headers, oauth)) => {
266 let headers = if headers.is_empty() {
267 r#"// "Authorization": "Bearer <token>"#.to_string()
268 } else {
269 let json = serde_json::to_string_pretty(&headers).unwrap();
270 let mut lines = json.split("\n").collect::<Vec<_>>();
271 if lines.len() > 1 {
272 lines.remove(0);
273 lines.pop();
274 }
275 lines
276 .into_iter()
277 .map(|line| format!(" {}", line))
278 .collect::<String>()
279 };
280 (id.0.to_string(), url, headers, oauth)
281 }
282 None => (
283 "some-remote-server".to_string(),
284 "https://example.com/mcp".to_string(),
285 r#"// "Authorization": "Bearer <token>"#.to_string(),
286 None,
287 ),
288 };
289
290 let oauth = oauth.map_or_else(
291 || {
292 r#"
293 /// Uncomment to use a pre-registered OAuth client. You can include the client secret here as well, otherwise it will be prompted interactively and saved in the system keychain.
294 // "oauth": {
295 // "client_id": "your-client-id",
296 // },"#
297 .to_string()
298 },
299
300 |oauth| {
301 let mut lines = vec![
302 String::from("\n \"oauth\": {"),
303
304 format!(" \"client_id\": {},", serde_json::to_string(&oauth.client_id).unwrap()),
305 ];
306 if let Some(client_secret) = oauth.client_secret {
307 lines.push(format!(
308 " \"client_secret\": {}",
309 serde_json::to_string(&client_secret).unwrap()
310 ));
311 } else {
312 lines.push(String::from(
313 " /// Optional client secret for confidential clients\n // \"client_secret\": \"your-client-secret\"",
314 ));
315 }
316 lines.push(String::from(" },"));
317
318 lines.join("\n")
319 },
320 );
321
322 format!(
323 r#"{{
324 /// Configure an MCP server that you connect to over HTTP
325 ///
326 /// The name of your remote MCP server
327 "{name}": {{
328 /// The URL of the remote MCP server
329 "url": "{url}",{oauth}
330 "headers": {{
331 /// Any headers to send along
332 {headers}
333 }}
334 }}
335}}"#
336 )
337}
338
339fn parse_http_input(
340 text: &str,
341) -> Result<(
342 ContextServerId,
343 String,
344 HashMap<String, String>,
345 Option<OAuthClientSettings>,
346)> {
347 #[derive(Deserialize)]
348 struct Temp {
349 url: String,
350 #[serde(default)]
351 headers: HashMap<String, String>,
352 #[serde(default)]
353 oauth: Option<OAuthClientSettings>,
354 }
355 let value: HashMap<String, Temp> = serde_json_lenient::from_str(text)?;
356 if value.len() != 1 {
357 anyhow::bail!("Expected exactly one context server configuration");
358 }
359
360 let (key, value) = value.into_iter().next().unwrap();
361
362 Ok((
363 ContextServerId(key.into()),
364 value.url,
365 value.headers,
366 value.oauth,
367 ))
368}
369
370fn resolve_context_server_extension(
371 id: ContextServerId,
372 worktree_store: Entity<WorktreeStore>,
373 cx: &mut App,
374) -> Task<Option<ConfigurationTarget>> {
375 let registry = ContextServerDescriptorRegistry::default_global(cx).read(cx);
376
377 let Some(descriptor) = registry.context_server_descriptor(&id.0) else {
378 return Task::ready(None);
379 };
380
381 let extension = ExtensionStore::global(cx)
382 .read(cx)
383 .installed_extensions()
384 .iter()
385 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
386 .map(|(id, entry)| (id.clone(), entry.manifest.clone()));
387 cx.spawn(async move |cx| {
388 let installation = descriptor
389 .configuration(worktree_store, cx)
390 .await
391 .context("Failed to resolve context server configuration")
392 .log_err()
393 .flatten();
394
395 Some(ConfigurationTarget::Extension {
396 id,
397 repository_url: extension
398 .and_then(|(_, manifest)| manifest.repository.clone().map(SharedString::from)),
399 installation,
400 })
401 })
402}
403
404enum State {
405 Idle,
406 Waiting,
407 AuthRequired {
408 server_id: ContextServerId,
409 },
410 ClientSecretRequired {
411 server_id: ContextServerId,
412 error: Option<SharedString>,
413 },
414 Authenticating {
415 server_id: ContextServerId,
416 },
417 Error(SharedString),
418}
419
420pub struct ConfigureContextServerModal {
421 context_server_store: Entity<ContextServerStore>,
422 workspace: WeakEntity<Workspace>,
423 source: ConfigurationSource,
424 state: State,
425 original_server_id: Option<ContextServerId>,
426 scroll_handle: ScrollHandle,
427 secret_editor: Entity<Editor>,
428 _auth_subscription: Option<Subscription>,
429}
430
431impl ConfigureContextServerModal {
432 fn initial_state(
433 context_server_store: &Entity<ContextServerStore>,
434 target: &ConfigurationTarget,
435 cx: &App,
436 ) -> State {
437 let server_id = match target {
438 ConfigurationTarget::Existing { id, .. }
439 | ConfigurationTarget::ExistingHttp { id, .. }
440 | ConfigurationTarget::Extension { id, .. } => id,
441 };
442
443 match context_server_store.read(cx).status_for_server(server_id) {
444 Some(ContextServerStatus::AuthRequired) => State::AuthRequired {
445 server_id: server_id.clone(),
446 },
447 Some(ContextServerStatus::ClientSecretRequired { error }) => {
448 State::ClientSecretRequired {
449 server_id: server_id.clone(),
450 error: error.map(SharedString::from),
451 }
452 }
453 Some(ContextServerStatus::Authenticating) => State::Authenticating {
454 server_id: server_id.clone(),
455 },
456 Some(ContextServerStatus::Error(error)) => State::Error(error.into()),
457
458 Some(ContextServerStatus::Starting)
459 | Some(ContextServerStatus::Running)
460 | Some(ContextServerStatus::Stopped)
461 | None => State::Idle,
462 }
463 }
464
465 pub fn show_modal_for_existing_server(
466 server_id: ContextServerId,
467 language_registry: Arc<LanguageRegistry>,
468 workspace: WeakEntity<Workspace>,
469 window: &mut Window,
470 cx: &mut App,
471 ) -> Task<Result<()>> {
472 let Some(settings) = ProjectSettings::get_global(cx)
473 .context_servers
474 .get(&server_id.0)
475 .cloned()
476 .or_else(|| {
477 ContextServerDescriptorRegistry::default_global(cx)
478 .read(cx)
479 .context_server_descriptor(&server_id.0)
480 .map(|_| ContextServerSettings::default_extension())
481 })
482 else {
483 return Task::ready(Err(anyhow::anyhow!("Context server not found")));
484 };
485
486 window.spawn(cx, async move |cx| {
487 let target = match settings {
488 ContextServerSettings::Stdio {
489 enabled: _,
490 command,
491 ..
492 } => Some(ConfigurationTarget::Existing {
493 id: server_id,
494 command,
495 }),
496 ContextServerSettings::Http {
497 enabled: _,
498 url,
499 headers,
500 timeout: _,
501 oauth,
502 } => Some(ConfigurationTarget::ExistingHttp {
503 id: server_id,
504 url,
505 headers,
506 oauth,
507 }),
508
509 ContextServerSettings::Extension { .. } => {
510 match workspace
511 .update(cx, |workspace, cx| {
512 resolve_context_server_extension(
513 server_id,
514 workspace.project().read(cx).worktree_store(),
515 cx,
516 )
517 })
518 .ok()
519 {
520 Some(task) => task.await,
521 None => None,
522 }
523 }
524 };
525
526 match target {
527 Some(target) => Self::show_modal(target, language_registry, workspace, cx).await,
528 None => Err(anyhow::anyhow!("Failed to resolve context server")),
529 }
530 })
531 }
532
533 fn show_modal(
534 target: ConfigurationTarget,
535 language_registry: Arc<LanguageRegistry>,
536 workspace: WeakEntity<Workspace>,
537 cx: &mut AsyncWindowContext,
538 ) -> Task<Result<()>> {
539 cx.spawn(async move |cx| {
540 let jsonc_language = language_registry.language_for_name("jsonc").await.ok();
541 workspace.update_in(cx, |workspace, window, cx| {
542 let workspace_handle = cx.weak_entity();
543 let context_server_store = workspace.project().read(cx).context_server_store();
544 workspace.toggle_modal(window, cx, |window, cx| Self {
545 context_server_store: context_server_store.clone(),
546 workspace: workspace_handle,
547 state: Self::initial_state(&context_server_store, &target, cx),
548
549 original_server_id: Some(match &target {
550 ConfigurationTarget::Existing { id, .. }
551 | ConfigurationTarget::ExistingHttp { id, .. }
552 | ConfigurationTarget::Extension { id, .. } => id.clone(),
553 }),
554 source: ConfigurationSource::from_target(
555 target,
556 language_registry,
557 jsonc_language,
558 window,
559 cx,
560 ),
561 scroll_handle: ScrollHandle::new(),
562 secret_editor: cx.new(|cx| {
563 let mut editor = Editor::single_line(window, cx);
564 editor.set_placeholder_text(
565 "Enter client secret (leave empty for public clients)",
566 window,
567 cx,
568 );
569 editor.set_masked(true, cx);
570 editor
571 }),
572 _auth_subscription: None,
573 })
574 })
575 })
576 }
577
578 fn set_error(&mut self, err: impl Into<SharedString>, cx: &mut Context<Self>) {
579 self.state = State::Error(err.into());
580 cx.notify();
581 }
582
583 fn confirm(&mut self, _: &menu::Confirm, cx: &mut Context<Self>) {
584 if matches!(self.state, State::Waiting | State::Authenticating { .. }) {
585 return;
586 }
587
588 self._auth_subscription = None;
589
590 self.state = State::Idle;
591 let Some(workspace) = self.workspace.upgrade() else {
592 return;
593 };
594
595 let (id, settings) = match self.source.output(cx) {
596 Ok(val) => val,
597 Err(error) => {
598 self.set_error(error.to_string(), cx);
599 return;
600 }
601 };
602
603 self.state = State::Waiting;
604
605 let existing_server = self.context_server_store.read(cx).get_server(&id);
606 if existing_server.is_some() {
607 self.context_server_store.update(cx, |store, cx| {
608 store.stop_server(&id, cx).log_err();
609 });
610 }
611
612 let wait_for_context_server_task =
613 wait_for_context_server(&self.context_server_store, id.clone(), cx);
614 cx.spawn({
615 let id = id.clone();
616 async move |this, cx| {
617 let result = wait_for_context_server_task.await;
618 this.update(cx, |this, cx| match result {
619 Ok(ContextServerStatus::Running) => {
620 this.state = State::Idle;
621 this.show_configured_context_server_toast(id, cx);
622 cx.emit(DismissEvent);
623 }
624 Ok(ContextServerStatus::AuthRequired) => {
625 this.state = State::AuthRequired { server_id: id };
626 cx.notify();
627 }
628 Ok(ContextServerStatus::ClientSecretRequired { error }) => {
629 this.state = State::ClientSecretRequired {
630 server_id: id,
631 error: error.map(SharedString::from),
632 };
633 cx.notify();
634 }
635 Err(err) => {
636 this.set_error(err, cx);
637 }
638 Ok(_) => {}
639 })
640 }
641 })
642 .detach();
643
644 let settings_changed =
645 ProjectSettings::get_global(cx).context_servers.get(&id.0) != Some(&settings);
646
647 if settings_changed {
648 // When we write the settings to the file, the context server will be restarted.
649 workspace.update(cx, |workspace, cx| {
650 let fs = workspace.app_state().fs.clone();
651 let original_server_id = self.original_server_id.clone();
652 update_settings_file(fs.clone(), cx, move |current, _| {
653 if let Some(original_id) = original_server_id {
654 if original_id != id {
655 current.project.context_servers.remove(&original_id.0);
656 }
657 }
658 current
659 .project
660 .context_servers
661 .insert(id.0, settings.into());
662 });
663 });
664 } else if let Some(existing_server) = existing_server {
665 self.context_server_store
666 .update(cx, |store, cx| store.start_server(existing_server, cx));
667 }
668 }
669
670 fn cancel(&mut self, _: &menu::Cancel, cx: &mut Context<Self>) {
671 cx.emit(DismissEvent);
672 }
673
674 fn cancel_authentication(&mut self, server_id: &ContextServerId, cx: &mut Context<Self>) {
675 self._auth_subscription = None;
676 self.context_server_store.update(cx, |store, cx| {
677 store.stop_server(server_id, cx).log_err();
678 });
679 self.state = State::Idle;
680 cx.notify();
681 }
682
683 fn authenticate(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
684 self.context_server_store.update(cx, |store, cx| {
685 store.authenticate_server(&server_id, cx).log_err();
686 });
687 self.await_auth_outcome(server_id, cx);
688 }
689
690 fn submit_client_secret(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
691 let secret = self.secret_editor.read(cx).text(cx);
692 self.context_server_store.update(cx, |store, cx| {
693 store.submit_client_secret(&server_id, secret, cx).log_err();
694 });
695 self.await_auth_outcome(server_id, cx);
696 }
697
698 fn await_auth_outcome(&mut self, server_id: ContextServerId, cx: &mut Context<Self>) {
699 self.state = State::Authenticating {
700 server_id: server_id.clone(),
701 };
702
703 self._auth_subscription = Some(cx.subscribe(
704 &self.context_server_store,
705 move |this, _, event: &ServerStatusChangedEvent, cx| {
706 if event.server_id != server_id {
707 return;
708 }
709 match &event.status {
710 ContextServerStatus::Running => {
711 this._auth_subscription = None;
712 this.state = State::Idle;
713 this.show_configured_context_server_toast(event.server_id.clone(), cx);
714 cx.emit(DismissEvent);
715 }
716 ContextServerStatus::AuthRequired => {
717 this._auth_subscription = None;
718 this.state = State::AuthRequired {
719 server_id: event.server_id.clone(),
720 };
721 cx.notify();
722 }
723 ContextServerStatus::ClientSecretRequired { error } => {
724 this._auth_subscription = None;
725 this.state = State::ClientSecretRequired {
726 server_id: event.server_id.clone(),
727 error: error.clone().map(SharedString::from),
728 };
729 cx.notify();
730 }
731 ContextServerStatus::Error(error) => {
732 this._auth_subscription = None;
733 this.set_error(error.clone(), cx);
734 }
735 ContextServerStatus::Authenticating
736 | ContextServerStatus::Starting
737 | ContextServerStatus::Stopped => {}
738 }
739 },
740 ));
741
742 cx.notify();
743 }
744
745 fn show_configured_context_server_toast(&self, id: ContextServerId, cx: &mut App) {
746 self.workspace
747 .update(cx, {
748 |workspace, cx| {
749 let status_toast = StatusToast::new(
750 format!("{} configured successfully.", id.0),
751 cx,
752 |this, _cx| {
753 this.icon(
754 Icon::new(IconName::ToolHammer)
755 .size(IconSize::Small)
756 .color(Color::Muted),
757 )
758 .action("Dismiss", |_, _| {})
759 },
760 );
761
762 workspace.toggle_status_toast(status_toast, cx);
763 }
764 })
765 .log_err();
766 }
767}
768
769fn parse_input(text: &str) -> Result<(ContextServerId, ContextServerCommand)> {
770 let value: serde_json::Value = serde_json_lenient::from_str(text)?;
771 let object = value.as_object().context("Expected object")?;
772 anyhow::ensure!(object.len() == 1, "Expected exactly one key-value pair");
773 let (context_server_name, value) = object.into_iter().next().unwrap();
774 let command: ContextServerCommand = serde_json::from_value(value.clone())?;
775 Ok((ContextServerId(context_server_name.clone().into()), command))
776}
777
778impl ModalView for ConfigureContextServerModal {}
779
780impl Focusable for ConfigureContextServerModal {
781 fn focus_handle(&self, cx: &App) -> FocusHandle {
782 match &self.source {
783 ConfigurationSource::Existing { editor, .. } => editor.focus_handle(cx),
784 ConfigurationSource::Extension { editor, .. } => editor
785 .as_ref()
786 .map(|editor| editor.focus_handle(cx))
787 .unwrap_or_else(|| cx.focus_handle()),
788 }
789 }
790}
791
792impl EventEmitter<DismissEvent> for ConfigureContextServerModal {}
793
794impl ConfigureContextServerModal {
795 fn render_modal_header(&self) -> ModalHeader {
796 let text: SharedString = match &self.source {
797 ConfigurationSource::Existing { .. } => "Configure MCP Server".into(),
798 ConfigurationSource::Extension { id, .. } => format!("Configure {}", id.0).into(),
799 };
800 ModalHeader::new().headline(text)
801 }
802
803 fn render_modal_description(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
804 const MODAL_DESCRIPTION: &str =
805 "Check the server docs for required arguments and environment variables.";
806
807 if let ConfigurationSource::Extension {
808 installation_instructions: Some(installation_instructions),
809 ..
810 } = &self.source
811 {
812 div()
813 .pb_2()
814 .text_sm()
815 .child(MarkdownElement::new(
816 installation_instructions.clone(),
817 default_markdown_style(window, cx),
818 ))
819 .into_any_element()
820 } else {
821 Label::new(MODAL_DESCRIPTION)
822 .color(Color::Muted)
823 .into_any_element()
824 }
825 }
826
827 fn render_modal_content(&self, cx: &App) -> AnyElement {
828 let editor = match &self.source {
829 ConfigurationSource::Existing { editor, .. } => editor,
830 ConfigurationSource::Extension { editor, .. } => {
831 let Some(editor) = editor else {
832 return div().into_any_element();
833 };
834 editor
835 }
836 };
837
838 div()
839 .p_2()
840 .rounded_md()
841 .border_1()
842 .border_color(cx.theme().colors().border_variant)
843 .bg(cx.theme().colors().editor_background)
844 .child({
845 let settings = ThemeSettings::get_global(cx);
846 let text_style = TextStyle {
847 color: cx.theme().colors().text,
848 font_family: settings.buffer_font.family.clone(),
849 font_fallbacks: settings.buffer_font.fallbacks.clone(),
850 font_size: settings.buffer_font_size(cx).into(),
851 font_weight: settings.buffer_font.weight,
852 line_height: relative(settings.buffer_line_height.value()),
853 ..Default::default()
854 };
855 EditorElement::new(
856 editor,
857 EditorStyle {
858 background: cx.theme().colors().editor_background,
859 local_player: cx.theme().players().local(),
860 text: text_style,
861 syntax: cx.theme().syntax().clone(),
862 ..Default::default()
863 },
864 )
865 })
866 .into_any_element()
867 }
868
869 fn render_modal_footer(&self, cx: &mut Context<Self>) -> ModalFooter {
870 let focus_handle = self.focus_handle(cx);
871 let is_busy = matches!(self.state, State::Waiting | State::Authenticating { .. });
872
873 ModalFooter::new()
874 .start_slot::<Button>(
875 if let ConfigurationSource::Extension {
876 repository_url: Some(repository_url),
877 ..
878 } = &self.source
879 {
880 Some(
881 Button::new("open-repository", "Open Repository")
882 .end_icon(
883 Icon::new(IconName::ArrowUpRight)
884 .size(IconSize::Small)
885 .color(Color::Muted),
886 )
887 .tooltip({
888 let repository_url = repository_url.clone();
889 move |_window, cx| {
890 Tooltip::with_meta(
891 "Open Repository",
892 None,
893 repository_url.clone(),
894 cx,
895 )
896 }
897 })
898 .on_click({
899 let repository_url = repository_url.clone();
900 move |_, _, cx| cx.open_url(&repository_url)
901 }),
902 )
903 } else {
904 None
905 },
906 )
907 .end_slot(
908 h_flex()
909 .gap_2()
910 .child(
911 Button::new(
912 "cancel",
913 if self.source.has_configuration_options() {
914 "Cancel"
915 } else {
916 "Dismiss"
917 },
918 )
919 .key_binding(
920 KeyBinding::for_action_in(&menu::Cancel, &focus_handle, cx)
921 .map(|kb| kb.size(rems_from_px(12.))),
922 )
923 .on_click(
924 cx.listener(|this, _event, _window, cx| this.cancel(&menu::Cancel, cx)),
925 ),
926 )
927 .children(self.source.has_configuration_options().then(|| {
928 Button::new("configure-server", "Configure Server")
929 .disabled(is_busy)
930 .key_binding(
931 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
932 .map(|kb| kb.size(rems_from_px(12.))),
933 )
934 .on_click(cx.listener(|this, _event, _window, cx| {
935 this.confirm(&menu::Confirm, cx)
936 }))
937 })),
938 )
939 }
940
941 fn render_loading(&self, label: impl Into<SharedString>) -> Div {
942 h_flex()
943 .h_8()
944 .gap_1p5()
945 .justify_center()
946 .child(
947 Icon::new(IconName::LoadCircle)
948 .size(IconSize::XSmall)
949 .color(Color::Muted)
950 .with_rotate_animation(3),
951 )
952 .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
953 }
954
955 fn render_auth_required(&self, server_id: &ContextServerId, cx: &mut Context<Self>) -> Div {
956 h_flex()
957 .h_8()
958 .min_w_0()
959 .w_full()
960 .gap_2()
961 .justify_center()
962 .child(
963 h_flex()
964 .gap_1p5()
965 .child(
966 Icon::new(IconName::Info)
967 .size(IconSize::Small)
968 .color(Color::Muted),
969 )
970 .child(
971 Label::new("Authenticate to connect this server")
972 .size(LabelSize::Small)
973 .color(Color::Muted),
974 ),
975 )
976 .child(
977 Button::new("authenticate-server", "Authenticate")
978 .style(ButtonStyle::Outlined)
979 .label_size(LabelSize::Small)
980 .on_click({
981 let server_id = server_id.clone();
982 cx.listener(move |this, _event, _window, cx| {
983 this.authenticate(server_id.clone(), cx);
984 })
985 }),
986 )
987 }
988
989 fn render_client_secret_required(
990 &self,
991 server_id: &ContextServerId,
992 error: Option<SharedString>,
993 cx: &mut Context<Self>,
994 ) -> Div {
995 let settings = ThemeSettings::get_global(cx);
996 let text_style = TextStyle {
997 color: cx.theme().colors().text,
998 font_family: settings.buffer_font.family.clone(),
999 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1000 font_size: settings.buffer_font_size(cx).into(),
1001 font_weight: settings.buffer_font.weight,
1002 line_height: relative(settings.buffer_line_height.value()),
1003 ..Default::default()
1004 };
1005
1006 v_flex()
1007 .w_full()
1008 .gap_2()
1009 .when_some(error, |this, error| {
1010 this.child(Self::render_modal_error(error))
1011 })
1012 .child(
1013 h_flex()
1014 .gap_1p5()
1015 .child(
1016 Icon::new(IconName::Info)
1017 .size(IconSize::Small)
1018 .color(Color::Muted),
1019 )
1020 .child(
1021 Label::new(
1022 "Enter your OAuth client secret, or leave empty for public clients",
1023 )
1024 .size(LabelSize::Small)
1025 .color(Color::Muted),
1026 ),
1027 )
1028 .child(
1029 h_flex()
1030 .w_full()
1031 .gap_2()
1032 .capture_action({
1033 let server_id = server_id.clone();
1034 cx.listener(move |this, _: &editor::actions::Newline, _window, cx| {
1035 this.submit_client_secret(server_id.clone(), cx);
1036 })
1037 })
1038 .child(div().flex_1().child(EditorElement::new(
1039 &self.secret_editor,
1040 EditorStyle {
1041 background: cx.theme().colors().editor_background,
1042 local_player: cx.theme().players().local(),
1043 text: text_style,
1044 syntax: cx.theme().syntax().clone(),
1045 ..Default::default()
1046 },
1047 )))
1048 .child(
1049 Button::new("submit-client-secret", "Submit")
1050 .style(ButtonStyle::Outlined)
1051 .label_size(LabelSize::Small)
1052 .on_click({
1053 let server_id = server_id.clone();
1054 cx.listener(move |this, _event, _window, cx| {
1055 this.submit_client_secret(server_id.clone(), cx);
1056 })
1057 }),
1058 ),
1059 )
1060 }
1061
1062 fn render_authenticating(&self, server_id: &ContextServerId, cx: &mut Context<Self>) -> Div {
1063 h_flex()
1064 .h_8()
1065 .gap_2()
1066 .justify_center()
1067 .child(
1068 h_flex()
1069 .gap_1p5()
1070 .child(
1071 Icon::new(IconName::LoadCircle)
1072 .size(IconSize::XSmall)
1073 .color(Color::Muted)
1074 .with_rotate_animation(3),
1075 )
1076 .child(
1077 Label::new("Authenticating…")
1078 .size(LabelSize::Small)
1079 .color(Color::Muted),
1080 ),
1081 )
1082 .child(
1083 Button::new("cancel-authentication", "Cancel")
1084 .style(ButtonStyle::Outlined)
1085 .label_size(LabelSize::Small)
1086 .on_click({
1087 let server_id = server_id.clone();
1088 cx.listener(move |this, _event, _window, cx| {
1089 this.cancel_authentication(&server_id, cx);
1090 })
1091 }),
1092 )
1093 }
1094
1095 fn render_modal_error(error: SharedString) -> Div {
1096 h_flex()
1097 .h_8()
1098 .gap_1p5()
1099 .justify_center()
1100 .child(
1101 Icon::new(IconName::Warning)
1102 .size(IconSize::Small)
1103 .color(Color::Warning),
1104 )
1105 .child(
1106 div()
1107 .w_full()
1108 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
1109 )
1110 }
1111}
1112
1113impl Render for ConfigureContextServerModal {
1114 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1115 div()
1116 .elevation_3(cx)
1117 .w(rems(40.))
1118 .key_context("ConfigureContextServerModal")
1119 .on_action(
1120 cx.listener(|this, _: &menu::Cancel, _window, cx| this.cancel(&menu::Cancel, cx)),
1121 )
1122 .on_action(
1123 cx.listener(|this, _: &menu::Confirm, _window, cx| {
1124 this.confirm(&menu::Confirm, cx)
1125 }),
1126 )
1127 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
1128 this.focus_handle(cx).focus(window, cx);
1129 }))
1130 .child(
1131 Modal::new("configure-context-server", None)
1132 .header(self.render_modal_header())
1133 .section(
1134 Section::new().child(
1135 div()
1136 .size_full()
1137 .child(
1138 div()
1139 .id("modal-content")
1140 .max_h(vh(0.7, window))
1141 .overflow_y_scroll()
1142 .track_scroll(&self.scroll_handle)
1143 .child(self.render_modal_description(window, cx))
1144 .child(self.render_modal_content(cx))
1145 .child(match &self.state {
1146 State::Idle => div(),
1147 State::Waiting => {
1148 self.render_loading("Connecting Server…")
1149 }
1150 State::AuthRequired { server_id } => {
1151 self.render_auth_required(&server_id.clone(), cx)
1152 }
1153 State::ClientSecretRequired { server_id, error } => {
1154 self.render_client_secret_required(
1155 &server_id.clone(),
1156 error.clone(),
1157 cx,
1158 )
1159 }
1160 State::Authenticating { server_id } => {
1161 self.render_authenticating(&server_id.clone(), cx)
1162 }
1163 State::Error(error) => {
1164 Self::render_modal_error(error.clone())
1165 }
1166 }),
1167 )
1168 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
1169 ),
1170 )
1171 .footer(self.render_modal_footer(cx)),
1172 )
1173 }
1174}
1175
1176fn wait_for_context_server(
1177 context_server_store: &Entity<ContextServerStore>,
1178 context_server_id: ContextServerId,
1179 cx: &mut App,
1180) -> Task<Result<ContextServerStatus, Arc<str>>> {
1181 use std::time::Duration;
1182
1183 const WAIT_TIMEOUT: Duration = Duration::from_secs(120);
1184
1185 let (tx, rx) = futures::channel::oneshot::channel();
1186 let tx = Arc::new(Mutex::new(Some(tx)));
1187
1188 let context_server_id_for_timeout = context_server_id.clone();
1189 let subscription = cx.subscribe(context_server_store, move |_, event, _cx| {
1190 let ServerStatusChangedEvent { server_id, status } = event;
1191
1192 if server_id != &context_server_id {
1193 return;
1194 }
1195
1196 match status {
1197 ContextServerStatus::Running
1198 | ContextServerStatus::AuthRequired
1199 | ContextServerStatus::ClientSecretRequired { .. } => {
1200 if let Some(tx) = tx.lock().take() {
1201 let _ = tx.send(Ok(status.clone()));
1202 }
1203 }
1204 ContextServerStatus::Stopped => {
1205 if let Some(tx) = tx.lock().take() {
1206 let _ = tx.send(Err("Context server stopped running".into()));
1207 }
1208 }
1209 ContextServerStatus::Error(error) => {
1210 if let Some(tx) = tx.lock().take() {
1211 let _ = tx.send(Err(error.clone()));
1212 }
1213 }
1214 ContextServerStatus::Starting | ContextServerStatus::Authenticating => {}
1215 }
1216 });
1217
1218 cx.spawn(async move |cx| {
1219 let timeout = cx.background_executor().timer(WAIT_TIMEOUT);
1220 let result = futures::future::select(rx, timeout).await;
1221 drop(subscription);
1222 match result {
1223 futures::future::Either::Left((Ok(inner), _)) => inner,
1224 futures::future::Either::Left((Err(_), _)) => {
1225 Err(Arc::from("Context server store was dropped"))
1226 }
1227 futures::future::Either::Right(_) => Err(Arc::from(format!(
1228 "Timed out waiting for context server `{}` to start. Check the Omega log for details.",
1229 context_server_id_for_timeout
1230 ))),
1231 }
1232 })
1233}
1234
1235pub(crate) fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1236 let theme_settings = ThemeSettings::get_global(cx);
1237 let colors = cx.theme().colors();
1238 let mut text_style = window.text_style();
1239 text_style.refine(&TextStyleRefinement {
1240 font_family: Some(theme_settings.ui_font.family.clone()),
1241 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1242 font_features: Some(theme_settings.ui_font.features.clone()),
1243 font_size: Some(TextSize::XSmall.rems(cx).into()),
1244 color: Some(colors.text_muted),
1245 ..Default::default()
1246 });
1247
1248 MarkdownStyle {
1249 base_text_style: text_style.clone(),
1250 selection_background_color: colors.element_selection_background,
1251 link: TextStyleRefinement {
1252 background_color: Some(colors.editor_foreground.opacity(0.025)),
1253 underline: Some(UnderlineStyle {
1254 color: Some(colors.text_accent.opacity(0.5)),
1255 thickness: px(1.),
1256 ..Default::default()
1257 }),
1258 ..Default::default()
1259 },
1260 ..Default::default()
1261 }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267
1268 #[test]
1269 fn parse_http_input_reads_oauth_settings() {
1270 let (id, url, headers, oauth) = parse_http_input(
1271 r#"{
1272 "figma": {
1273 "url": "https://mcp.figma.com/mcp",
1274 "oauth": {
1275 "client_id": "client-id",
1276 "client_secret": "client-secret"
1277 },
1278 "headers": {
1279 "X-Test": "test"
1280 }
1281 }
1282}"#,
1283 )
1284 .unwrap();
1285
1286 assert_eq!(id, ContextServerId("figma".into()));
1287 assert_eq!(url, "https://mcp.figma.com/mcp");
1288 assert_eq!(headers.get("X-Test"), Some(&String::from("test")));
1289 let oauth = oauth.expect("oauth should be present");
1290 assert_eq!(oauth.client_id, "client-id");
1291 assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
1292 }
1293
1294 #[test]
1295 fn context_server_http_input_preserves_existing_oauth_settings() {
1296 let text = context_server_http_input(Some((
1297 ContextServerId("figma".into()),
1298 String::from("https://mcp.figma.com/mcp"),
1299 HashMap::default(),
1300 Some(OAuthClientSettings {
1301 client_id: String::from("client-id"),
1302 client_secret: Some(String::from("client-secret")),
1303 }),
1304 )));
1305
1306 let (_, _, _, oauth) = parse_http_input(&text).unwrap();
1307 let oauth = oauth.expect("oauth should be present");
1308 assert_eq!(oauth.client_id, "client-id");
1309 assert_eq!(oauth.client_secret.as_deref(), Some("client-secret"));
1310 }
1311}
1312