1use std::collections::{HashMap, HashSet};
18use std::path::Path;
19use std::sync::{Arc, LazyLock, Mutex};
20use std::time::Duration;
21
22use futures_util::future::BoxFuture;
23use indexmap::IndexMap;
24use regex_lite::Regex;
25use serde_json::Value;
26use tokio::sync::{oneshot, watch};
27use yapi_types::autocomplete::{ArgumentCompletions, AutocompleteItem};
28use yapi_types::config::ConfigFile;
29use yapi_types::rpc::SourceInfo;
30use yapi_types::sync::lock;
31
32use super::config::{self, ConfigPatch, McpExposure, Scope, ServerEntry, namespace};
33use super::connection::{Connection, State};
34use super::http::ProviderToken;
35use super::jsonrpc::McpError;
36use super::sign_in::SignInPrompt;
37use super::tools::{
38 LIST_MCP_RESOURCE_TEMPLATES_TOOL, LIST_MCP_RESOURCES_TOOL, McpTool, READ_MCP_RESOURCE_TOOL,
39 resource_tools, tool_name,
40};
41use crate::extensions::codemode;
42use crate::extensions::tool_search::{TOOL_SEARCH_TOOL_NAME, is_tool_search};
43use crate::extensions::{
44 Command, Context, DialogOptions, Extension, ExtensionUi, Mode, NotifyKind, Tools,
45 builtin_source,
46};
47use crate::tools::{Exposure, Namespace, RegisteredTool};
48
49const STARTUP_WAIT: Duration = Duration::from_secs(10);
51pub const SERVERS_SECTION: &str = "mcp_servers";
53const MAX_SERVER_DESCRIPTION_CHARS: usize = 250;
54const MAX_SERVERS_SECTION_CHARS: usize = 4096;
55const USAGE: &str =
56 "Usage: /mcp, /mcp login [server], /mcp logout [server], /mcp reconnect [server]";
57const MENU_REFRESH: Duration = Duration::from_millis(100);
59const EXPOSURES: [(McpExposure, &str); 3] = [
61 (
62 McpExposure::Codemode,
63 "called from codemode scripts, which find them with searchTools()",
64 ),
65 (
66 McpExposure::Deferred,
67 "not declared until tool_search loads them, then called directly; no codemode needed",
68 ),
69 (
70 McpExposure::Direct,
71 "declared to the model like built-in tools",
72 ),
73];
74
75#[derive(Clone, Debug, Default, PartialEq)]
77pub struct McpMenu {
78 pub title: String,
80 pub details: Option<String>,
82 pub error: Option<String>,
84 pub items: Vec<AutocompleteItem>,
86 pub empty: String,
88 pub selected: Option<String>,
90 pub confirm_label: String,
92 pub cancel_label: String,
94}
95
96#[derive(Debug)]
98pub enum McpScreen {
99 Menu(watch::Receiver<McpMenu>, oneshot::Sender<Option<String>>),
103 Status(String, String),
105 RedirectUrl(String, String, oneshot::Sender<Option<String>>),
108 Close,
110}
111
112struct Server {
114 entry: ServerEntry,
115 connection: Option<Arc<Connection>>,
116 ready: Option<watch::Receiver<bool>>,
118 message: Option<String>,
120 registered: Option<Value>,
123}
124
125impl Server {
126 fn new(entry: ServerEntry, registered: Option<Value>) -> Server {
127 Server {
128 entry,
129 connection: None,
130 ready: None,
131 message: None,
132 registered,
133 }
134 }
135}
136
137fn registered_servers(ctx: &Context, configured: &[ServerEntry]) -> (Vec<Server>, Vec<String>) {
141 let registered = ctx
142 .session
143 .upgrade()
144 .map(|session| session.mcp_servers())
145 .unwrap_or_default();
146 let mut overridden = Vec::new();
147 let servers = registered
148 .into_iter()
149 .filter(|server| {
150 let Some(entry) = configured
151 .iter()
152 .find(|entry| namespace(&entry.name) == namespace(&server.name))
153 else {
154 return true;
155 };
156 overridden.push(format!(
157 "\"{}\" registered by {} is overridden by \"{}\" in {}",
158 server.name,
159 server.extension_path,
160 entry.name,
161 entry.source.display()
162 ));
163 false
164 })
165 .filter_map(|server| {
166 let config = config::validate_server(&server.name, &server.config).ok()?;
168 let entry = ServerEntry {
169 name: server.name,
170 config,
171 source: server.extension_path.into(),
172 scope: Scope::Extension,
173 };
174 Some(Server::new(entry, Some(server.config)))
175 })
176 .collect();
177 (servers, overridden)
178}
179
180#[derive(Default)]
181struct Shared {
182 servers: Vec<Server>,
183 config_errors: Vec<String>,
184 overridden: Vec<String>,
186 startup: Option<watch::Receiver<bool>>,
188 warned_unreachable: bool,
189 auto_enable_codemode: bool,
191 waited_for_startup: bool,
192 generation: u64,
193 tool_owners: HashMap<String, String>,
195 server_tools: HashMap<String, Vec<String>>,
197 definitions: HashMap<String, RegisteredTool>,
199 resource_tools_exposure: Option<McpExposure>,
201 tools: Option<Tools>,
202 active: bool,
205}
206
207#[derive(Clone, Default)]
209pub struct McpExtension {
210 shared: Arc<Mutex<Shared>>,
211}
212
213fn first_line(text: &str) -> &str {
214 text.split('\n').next().unwrap_or_default()
215}
216
217fn has_direct_tools(entry: &ServerEntry) -> bool {
218 entry.config.exposures().contains(&McpExposure::Direct)
219}
220
221fn has_indirect_tools(entry: &ServerEntry) -> bool {
222 let exposures = entry.config.exposures();
223 exposures.contains(&McpExposure::Codemode) || exposures.contains(&McpExposure::Deferred)
224}
225
226fn truncate(text: &str, max: usize) -> String {
227 if yapi_types::js::len(text) <= max {
228 return text.to_owned();
229 }
230 if max <= 1 {
231 return String::new();
232 }
233 format!("{}…", yapi_types::js::slice(text, 0, max - 1).trim_end())
234}
235
236fn render_servers_section(servers: &[(&ServerEntry, Option<String>)]) -> Option<String> {
239 let mut listed: Vec<&(&ServerEntry, Option<String>)> = servers
240 .iter()
241 .filter(|(entry, _)| entry.config.is_enabled() && has_indirect_tools(entry))
242 .collect();
243 if listed.is_empty() {
244 return None;
245 }
246 listed.sort_by(|a, b| yapi_types::collate::locale_compare(&a.0.name, &b.0.name));
247 let reaches: Vec<&str> = listed
248 .iter()
249 .map(|(entry, _)| {
250 if entry.config.exposures().contains(&McpExposure::Codemode) {
251 "codemode"
252 } else {
253 "tool_search"
254 }
255 })
256 .collect();
257 let mut intro = String::from("MCP servers whose tools are not declared to you.");
258 if reaches.contains(&"codemode") {
259 intro.push_str(" Call the tools of `codemode` servers from codemode scripts.");
260 }
261 if reaches.contains(&"tool_search") {
262 intro.push_str(" Load the tools of `tool_search` servers with `tool_search`.");
263 }
264 let heads: Vec<String> = listed
265 .iter()
266 .zip(&reaches)
267 .map(|((entry, _), reach)| format!("- {} ({reach})", namespace(&entry.name)))
268 .collect();
269 let omitted = |count: usize| -> Vec<String> {
270 if count == 0 {
271 return Vec::new();
272 }
273 vec![format!(
274 "- … {count} more server{}; find their tools with searchTools()",
275 if count == 1 { "" } else { "s" }
276 )]
277 };
278 let size = |kept: usize| -> usize {
279 let mut lines = vec![intro.clone()];
280 lines.extend(heads[..kept].iter().cloned());
281 lines.extend(omitted(listed.len() - kept));
282 yapi_types::js::len(&lines.join("\n"))
283 };
284 let mut kept = listed.len();
285 while kept > 0 && size(kept) > MAX_SERVERS_SECTION_CHARS {
286 kept -= 1;
287 }
288 let per_server = (MAX_SERVERS_SECTION_CHARS - size(kept))
290 .checked_div(kept)
291 .map_or(0, |room| {
292 room.saturating_sub(2).min(MAX_SERVER_DESCRIPTION_CHARS)
293 });
294 let mut lines = vec![intro.clone()];
295 for (index, (entry, instructions)) in listed[..kept].iter().enumerate() {
296 let source = entry
297 .config
298 .description
299 .as_deref()
300 .map(str::trim)
301 .filter(|text| !text.is_empty())
302 .or(instructions.as_deref())
303 .unwrap_or_default();
304 let summary = if per_server > 0 {
305 truncate(first_line(source).trim(), per_server)
306 } else {
307 String::new()
308 };
309 if summary.is_empty() {
310 lines.push(heads[index].clone());
311 } else {
312 lines.push(format!("{}: {summary}", heads[index]));
313 }
314 }
315 lines.extend(omitted(listed.len() - kept));
316 Some(lines.join("\n"))
317}
318
319static FINDS_TOOLS: LazyLock<Option<Regex>> = LazyLock::new(|| {
320 Regex::new(r"\b(searchTools|describeNamespace|describeTool|ALL_TOOLS)\b").ok()
321});
322
323fn script_needs_server(code: &str, server: &str) -> bool {
327 FINDS_TOOLS
328 .as_ref()
329 .is_some_and(|finds| finds.is_match(code))
330 || code.contains(&namespace(server))
331}
332
333fn describe_state(server: &Server, with_error: bool) -> String {
335 if !server.entry.config.is_enabled() {
336 return "disabled".into();
337 }
338 let Some(connection) = &server.connection else {
339 return "starting".into();
340 };
341 let snapshot = connection.snapshot();
342 match snapshot.state {
343 State::NeedsAuth => "needs sign-in".into(),
344 State::Failed if with_error => format!(
345 "failed: {}",
346 first_line(snapshot.error.as_deref().unwrap_or("unknown error"))
347 ),
348 State::Failed => "failed".into(),
349 State::Connected => {
350 let tools = snapshot.tools.len();
351 let count = snapshot.resources.len();
352 let resources = if count > 0 {
353 format!(" · {count} resource{}", if count == 1 { "" } else { "s" })
354 } else {
355 String::new()
356 };
357 format!(
358 "connected · {tools} tool{}{resources}",
359 if tools == 1 { "" } else { "s" }
360 )
361 }
362 State::Connecting => "connecting…".into(),
363 other => other.as_str().into(),
364 }
365}
366
367impl McpExtension {
368 pub fn new() -> McpExtension {
370 McpExtension::default()
371 }
372
373 fn tools(&self) -> Option<Tools> {
374 lock(&self.shared).tools.clone()
375 }
376
377 fn register_tools(&self, connection: &Arc<Connection>) {
380 let Some(tools) = self.tools() else {
381 return;
382 };
383 let snapshot = connection.snapshot();
384 let mut shared = lock(&self.shared);
385 let server = connection.name().to_owned();
386 let entry = shared
387 .servers
388 .iter()
389 .find(|candidate| candidate.entry.name == server)
390 .map_or_else(|| connection.entry().clone(), |found| found.entry.clone());
391 let namespace = Namespace {
392 name: namespace(&server),
393 description: entry
394 .config
395 .description
396 .as_deref()
397 .map(str::trim)
398 .filter(|text| !text.is_empty())
399 .map(str::to_owned),
400 instructions: snapshot.instructions.clone(),
401 };
402 let previous = shared
403 .server_tools
404 .get(&server)
405 .cloned()
406 .unwrap_or_default();
407 let mut current: Vec<String> = Vec::new();
408 let mut unique: Vec<&str> = Vec::new();
409 for tool in &snapshot.tools {
410 if !unique.contains(&tool.name.as_str()) {
411 unique.push(&tool.name);
412 }
413 }
414 let plain: Vec<String> = unique
415 .iter()
416 .map(|tool| tool_name(&server, tool, |_| false))
417 .collect();
418 let mut registered = Vec::new();
419 for tool in &snapshot.tools {
420 let owner = format!("{server}\0{}", tool.name);
421 let name = tool_name(&server, &tool.name, |candidate| {
422 shared
423 .tool_owners
424 .get(candidate)
425 .is_some_and(|existing| *existing != owner)
426 || current.iter().any(|taken| taken == candidate)
427 || plain.iter().filter(|name| *name == candidate).count() > 1
428 });
429 shared.tool_owners.insert(name.clone(), owner);
430 current.push(name.clone());
431 let definition = RegisteredTool {
432 tool: Arc::new(McpTool::new(Arc::clone(connection), tool, name.clone())),
433 snippet: None,
434 guidelines: Vec::new(),
435 exposure: entry.config.tool_exposure(&tool.name).tool_exposure(),
436 namespace: Some(namespace.clone()),
437 default_active: true,
438 annotations: super::tools::annotations(tool),
439 source: Some(builtin_source("mcp")),
440 };
441 shared.definitions.insert(name, definition.clone());
442 registered.push(definition);
443 }
444 for name in &previous {
445 if !current.contains(name)
446 && let Some(definition) = shared.definitions.get(name)
447 {
448 registered.push(RegisteredTool {
449 exposure: Exposure::Hidden,
450 ..definition.clone()
451 });
452 }
453 }
454 shared.server_tools.insert(server, current);
455 drop(shared);
456 for definition in registered {
457 tools.register(definition);
458 }
459 self.sync_resource_tools();
460 }
461
462 fn servers_with_resources(&self) -> Vec<(McpExposure, Arc<Connection>)> {
465 lock(&self.shared)
466 .servers
467 .iter()
468 .map(|server| (server.entry.config.exposure(), server))
469 .filter(|(exposure, server)| {
470 server.entry.config.is_enabled() && *exposure != McpExposure::Hidden
471 })
472 .filter_map(|(exposure, server)| Some((exposure, server.connection.clone()?)))
473 .filter(|(_, connection)| connection.snapshot().has_resources)
474 .collect()
475 }
476
477 fn resource_servers(&self) -> Vec<Arc<Connection>> {
478 self.servers_with_resources()
479 .into_iter()
480 .map(|(_, connection)| connection)
481 .collect()
482 }
483
484 fn sync_resource_tools(&self) {
487 let Some(tools) = self.tools() else {
488 return;
489 };
490 let exposures: HashSet<&'static str> = self
491 .servers_with_resources()
492 .iter()
493 .map(|(exposure, _)| exposure.as_str())
494 .collect();
495 let next = [
496 McpExposure::Direct,
497 McpExposure::Codemode,
498 McpExposure::Deferred,
499 ]
500 .into_iter()
501 .find(|exposure| exposures.contains(exposure.as_str()))
502 .unwrap_or(McpExposure::Hidden);
503 let previous = {
504 let mut shared = lock(&self.shared);
505 let previous = shared.resource_tools_exposure;
506 if previous == Some(next) || (previous.is_none() && next == McpExposure::Hidden) {
507 return;
508 }
509 shared.resource_tools_exposure = Some(next);
510 previous
511 };
512 let this = self.clone();
513 let servers: super::tools::ResourceServers = Arc::new(move || this.resource_servers());
514 let definitions = resource_tools(servers);
515 let names: Vec<String> = definitions
516 .iter()
517 .map(|tool| yapi_agent::Tool::declaration(tool).name.clone())
518 .collect();
519 for tool in definitions {
520 tools.register(RegisteredTool {
521 tool: Arc::new(tool),
522 snippet: None,
523 guidelines: Vec::new(),
524 exposure: next.tool_exposure(),
525 namespace: None,
526 default_active: true,
527 annotations: Some(serde_json::json!({"readOnlyHint": true})),
528 source: Some(builtin_source("mcp")),
529 });
530 }
531 if previous == Some(McpExposure::Direct) {
532 let active = tools.active();
533 tools.set_active(
534 active
535 .into_iter()
536 .filter(|name| !names.contains(name))
537 .collect(),
538 );
539 }
540 }
541
542 fn ensure_discovery_active(&self, ctx: &Context) {
546 let (needs_codemode, needs_tool_search, warned, auto_enable) = {
547 let shared = lock(&self.shared);
548 let mut exposures = HashSet::new();
549 for server in &shared.servers {
550 if server.entry.config.is_enabled() {
551 exposures.extend(
552 server
553 .entry
554 .config
555 .exposures()
556 .into_iter()
557 .map(McpExposure::as_str),
558 );
559 }
560 }
561 (
562 exposures.contains("codemode"),
563 exposures.contains("deferred"),
564 shared.warned_unreachable,
565 shared.auto_enable_codemode,
566 )
567 };
568 if !needs_codemode && !needs_tool_search {
569 return;
570 }
571 let all = ctx.tools.all();
573 let has_codemode = all.iter().any(codemode::is_codemode_tool);
574 let has_tool_search = all.iter().any(is_tool_search);
575 let active = ctx.tools.active();
576 let is_active = |name: &str| active.iter().any(|active| active == name);
577 let mut activate = Vec::new();
578 if needs_codemode && has_codemode && auto_enable && !is_active(codemode::NAME) {
579 activate.push(codemode::NAME.to_owned());
580 }
581 if needs_tool_search && has_tool_search && !is_active(TOOL_SEARCH_TOOL_NAME) {
582 activate.push(TOOL_SEARCH_TOOL_NAME.to_owned());
583 }
584 let reachable: Vec<String> = active.iter().cloned().chain(activate.clone()).collect();
585 if !activate.is_empty() {
586 ctx.tools.set_active(reachable.clone());
587 }
588 let reaches = |name: &str| reachable.iter().any(|active| active == name);
589 if (has_codemode && reaches(codemode::NAME))
590 || (has_tool_search && reaches(TOOL_SEARCH_TOOL_NAME))
591 || warned
592 {
593 return;
594 }
595 lock(&self.shared).warned_unreachable = true;
596 let reason = if needs_codemode && has_codemode && !auto_enable {
597 " (autoEnableCodemode is false)"
598 } else {
599 ""
600 };
601 ctx.ui.notify(
602 &format!("MCP tools are only reachable from the codemode or tool_search tool, but neither is active{reason}; they cannot be called."),
603 NotifyKind::Warning,
604 );
605 }
606
607 fn report_problems(&self, ui: &dyn ExtensionUi, only: Option<&[String]>) {
610 let lines: Vec<String> = {
611 let shared = lock(&self.shared);
612 let mut lines: Vec<String> = match only {
613 Some(_) => Vec::new(),
614 None => shared
615 .config_errors
616 .iter()
617 .map(|error| format!("config: {error}"))
618 .collect(),
619 };
620 let reported = shared
621 .servers
622 .iter()
623 .filter(|server| only.is_none_or(|names| names.contains(&server.entry.name)));
624 for server in reported {
625 let state = server
626 .connection
627 .as_ref()
628 .map(|connection| connection.snapshot().state);
629 if matches!(state, Some(State::NeedsAuth | State::Failed)) {
630 lines.push(format!(
631 "{}: {}",
632 server.entry.name,
633 describe_state(server, true)
634 ));
635 }
636 }
637 lines
638 };
639 if lines.is_empty() {
640 return;
641 }
642 let body: Vec<String> = lines.iter().map(|line| format!(" {line}")).collect();
643 ui.notify(
644 &format!(
645 "MCP servers need attention:\n{}\nRun /mcp to fix.",
646 body.join("\n")
647 ),
648 NotifyKind::Warning,
649 );
650 }
651
652 fn server_mut<'a>(shared: &'a mut Shared, name: &str) -> Option<&'a mut Server> {
653 shared
654 .servers
655 .iter_mut()
656 .find(|server| server.entry.name == name)
657 }
658
659 fn start(
661 &self,
662 name: &str,
663 ctx: &Context,
664 generation: u64,
665 ) -> Option<tokio::task::JoinHandle<()>> {
666 let (entry, ready_tx) = {
667 let mut shared = lock(&self.shared);
668 let server = Self::server_mut(&mut shared, name)?;
669 let (ready_tx, ready_rx) = watch::channel(false);
670 server.ready = Some(ready_rx);
671 (server.entry.clone(), ready_tx)
672 };
673 let this = self.clone();
674 let on_tools =
675 Arc::new(move |connection: &Arc<Connection>| this.register_tools(connection));
676 let this = self.clone();
677 let readable = Arc::new(move |name: &str| {
678 this.resource_servers()
679 .iter()
680 .any(|connection| connection.name() == name)
681 });
682 let session = ctx.session.clone();
683 let provider_token: ProviderToken = Arc::new(move |provider: String| {
685 let session = session.clone();
686 Box::pin(async move {
687 let registry = session.upgrade()?.registry();
688 registry.provider_token(&provider).await
689 })
690 });
691 let connection = Connection::new(
692 entry,
693 ctx.cwd.clone(),
694 ctx.agent_dir.clone(),
695 on_tools,
696 readable,
697 Some(provider_token),
698 );
699 {
700 let mut shared = lock(&self.shared);
701 if shared.generation != generation {
702 return None;
703 }
704 if let Some(server) = Self::server_mut(&mut shared, name) {
705 server.connection = Some(Arc::clone(&connection));
706 }
707 }
708 Some(tokio::spawn(async move {
709 let _ = connection.client().await;
710 let _ = ready_tx.send(true);
711 }))
712 }
713
714 async fn wait_for(
716 waiting: Vec<watch::Receiver<bool>>,
717 cancel: &tokio_util::sync::CancellationToken,
718 ) {
719 let all = async {
720 for mut ready in waiting {
721 let _ = ready.wait_for(|done| *done).await;
722 }
723 };
724 tokio::select! {
725 () = all => {}
726 () = cancel.cancelled() => {}
727 }
728 }
729
730 fn pending(&self, filter: impl Fn(&ServerEntry) -> bool) -> Vec<watch::Receiver<bool>> {
731 lock(&self.shared)
732 .servers
733 .iter()
734 .filter(|server| server.entry.config.is_enabled() && filter(&server.entry))
735 .filter(|server| {
736 server
737 .connection
738 .as_ref()
739 .is_none_or(|connection| connection.snapshot().state != State::Connected)
740 })
741 .filter_map(|server| server.ready.clone())
742 .filter(|ready| !*ready.borrow())
743 .collect()
744 }
745
746 fn format_status(&self, agent_dir: &Path) -> String {
748 let shared = lock(&self.shared);
749 if shared.servers.is_empty()
750 && shared.config_errors.is_empty()
751 && shared.overridden.is_empty()
752 {
753 return no_servers(agent_dir);
754 }
755 let mut lines: Vec<String> = shared
756 .servers
757 .iter()
758 .map(|server| {
759 let name = &server.entry.name;
760 let exposure = server.entry.config.exposure().as_str();
761 let snapshot = server
762 .connection
763 .as_ref()
764 .map(|connection| connection.snapshot());
765 if snapshot
766 .as_ref()
767 .is_some_and(|snapshot| snapshot.state == State::NeedsAuth)
768 {
769 return format!("{name}: needs sign-in, run /mcp login {name} ({exposure})");
770 }
771 let tools = match &snapshot {
772 Some(snapshot) if snapshot.state == State::Connected => {
773 format!(", {} tools", snapshot.tools.len())
774 }
775 _ => String::new(),
776 };
777 let state = if !server.entry.config.is_enabled() {
778 "disabled".to_owned()
779 } else {
780 match &snapshot {
781 Some(snapshot) if snapshot.state == State::Disconnected => {
782 "disconnected, reconnects on next call".to_owned()
783 }
784 Some(snapshot) => snapshot.state.as_str().to_owned(),
785 None => "starting".to_owned(),
786 }
787 };
788 let error = match &snapshot {
789 Some(snapshot) if snapshot.state != State::Connected => snapshot
790 .error
791 .as_ref()
792 .map(|error| format!("\n {}", error.replace('\n', "\n ")))
793 .unwrap_or_default(),
794 _ => String::new(),
795 };
796 format!("{name}: {state}{tools} ({exposure}){error}")
797 })
798 .collect();
799 lines.extend(
800 shared
801 .config_errors
802 .iter()
803 .map(|error| format!("config error: {error}")),
804 );
805 lines.extend(
806 shared
807 .overridden
808 .iter()
809 .map(|line| format!("overridden: {line}")),
810 );
811 lines.join("\n")
812 }
813
814 async fn pick(
816 &self,
817 name: Option<&str>,
818 ctx: &Context,
819 eligible: impl Fn(&Server) -> bool,
820 preferred: impl Fn(&Server) -> bool,
821 none: &str,
822 ) -> Option<String> {
823 let (names, preferred_names, found) = {
824 let shared = lock(&self.shared);
825 let found = name.map(|name| {
826 shared
827 .servers
828 .iter()
829 .find(|server| server.entry.name == name)
830 .map(&eligible)
831 });
832 let candidates: Vec<&Server> = shared
833 .servers
834 .iter()
835 .filter(|server| eligible(server))
836 .collect();
837 (
838 candidates
839 .iter()
840 .map(|server| server.entry.name.clone())
841 .collect::<Vec<_>>(),
842 candidates
843 .iter()
844 .filter(|server| preferred(server))
845 .map(|server| server.entry.name.clone())
846 .collect::<Vec<_>>(),
847 found,
848 )
849 };
850 if let Some(name) = name {
851 return match found.flatten() {
852 None => {
853 ctx.ui.notify(
854 &format!("No MCP server named \"{name}\"."),
855 NotifyKind::Error,
856 );
857 None
858 }
859 Some(false) => {
860 ctx.ui.notify(none, NotifyKind::Error);
861 None
862 }
863 Some(true) => Some(name.to_owned()),
864 };
865 }
866 match (names.as_slice(), preferred_names.as_slice()) {
867 ([], _) => {
868 ctx.ui.notify(none, NotifyKind::Info);
869 None
870 }
871 ([only], _) | (_, [only]) => Some(only.clone()),
872 _ => {
873 ctx.ui
874 .select("MCP server", names, DialogOptions::default())
875 .await
876 }
877 }
878 }
879
880 async fn sign_in(&self, name: &str, prompt: SignInPrompt) -> Option<String> {
883 let Some(connection) = self
884 .connection(name)
885 .filter(|connection| connection.oauth_url().is_some())
886 else {
887 return Some(format!("MCP server \"{name}\" does not use OAuth."));
888 };
889 match connection.sign_in(&prompt).await {
890 Err(yapi_ai::auth::AuthError::Cancelled) => return Some("Sign-in cancelled.".into()),
891 Err(error) => return Some(format!("Sign-in failed: {error}")),
892 Ok(()) => {}
893 }
894 match connection.reconnect().await {
895 Err(error) => Some(format!("Signed in, but {error}")),
896 Ok(()) => None,
897 }
898 }
899
900 async fn sign_out(&self, name: &str) -> Result<bool, McpError> {
903 let Some(connection) = self.connection(name) else {
904 return Ok(false);
905 };
906 let removed = connection.remove_credentials().await?;
907 connection.sign_out().await;
908 Ok(removed)
909 }
910
911 async fn manage(&self, ctx: &Context) {
914 let ui = ctx.ui.as_ref();
915 while let Some(Some(name)) = Self::menu(ui, || self.servers_menu(&ctx.agent_dir)).await {
916 loop {
917 let Some(action) = Self::menu(ui, || self.server_menu(&name)).await else {
918 return;
919 };
920 let Some(action) = action.filter(|_| self.server(&name, |_| ()).is_some()) else {
921 break;
922 };
923 if self.run_action(ctx, &name, &action).await.is_none() {
924 return;
925 }
926 }
927 }
928 }
929
930 async fn menu(ui: &dyn ExtensionUi, build: impl Fn() -> McpMenu) -> Option<Option<String>> {
934 let (menus, receiver) = watch::channel(build());
935 let (reply, mut answer) = oneshot::channel();
936 ui.mcp_manager(McpScreen::Menu(receiver, reply));
937 let mut refresh = tokio::time::interval(MENU_REFRESH);
940 loop {
941 tokio::select! {
942 answer = &mut answer => return answer.ok(),
943 _ = refresh.tick() => {
944 let next = build();
945 menus.send_if_modified(|menu| {
946 let changed = *menu != next;
947 *menu = next;
948 changed
949 });
950 }
951 }
952 }
953 }
954
955 fn server<T>(&self, name: &str, read: impl FnOnce(&Server) -> T) -> Option<T> {
957 lock(&self.shared)
958 .servers
959 .iter()
960 .find(|server| server.entry.name == name)
961 .map(read)
962 }
963
964 fn servers_menu(&self, agent_dir: &Path) -> McpMenu {
966 let shared = lock(&self.shared);
967 let mut servers: Vec<&Server> = shared.servers.iter().collect();
968 servers.sort_by(|a, b| {
969 attention_rank(a)
970 .cmp(&attention_rank(b))
971 .then_with(|| yapi_types::collate::locale_compare(&a.entry.name, &b.entry.name))
972 });
973 let notices: Vec<String> = shared
974 .config_errors
975 .iter()
976 .map(|error| format!("config: {error}"))
977 .chain(
978 shared
979 .overridden
980 .iter()
981 .map(|line| format!("overridden: {line}")),
982 )
983 .collect();
984 McpMenu {
985 title: "MCP servers".into(),
986 error: (!notices.is_empty()).then(|| notices.join("\n")),
987 items: servers
988 .iter()
989 .map(|server| {
990 let description = format!(
991 "{} · {} · {}",
992 describe_state(server, true),
993 server.entry.config.exposure().as_str(),
994 server.entry.scope.as_str()
995 );
996 item(&server.entry.name, &server.entry.name, Some(description))
997 })
998 .collect(),
999 empty: no_servers(agent_dir),
1000 confirm_label: "manage".into(),
1001 cancel_label: "close".into(),
1002 ..McpMenu::default()
1003 }
1004 }
1005
1006 fn server_menu(&self, name: &str) -> McpMenu {
1008 let menu = self.server(name, |server| {
1009 let entry = &server.entry;
1010 let saved = match entry.scope {
1011 Scope::Extension => "for this session".to_owned(),
1012 scope => format!("saved to the {} mcp.json", scope.as_str()),
1013 };
1014 let snapshot = server
1015 .connection
1016 .as_ref()
1017 .map(|connection| connection.snapshot());
1018 let state = snapshot.as_ref().map(|snapshot| snapshot.state);
1019 let mut items = Vec::new();
1020 if !entry.config.is_enabled() {
1021 items.push(item("enable", "Enable", Some(saved)));
1022 } else {
1023 if state == Some(State::NeedsAuth) {
1024 items.push(item("signin", "Sign in", Some("opens the browser".into())));
1025 }
1026 if let Some(snapshot) = snapshot
1027 .as_ref()
1028 .filter(|snapshot| snapshot.state == State::Connected)
1029 {
1030 let offered = format!("{} offered", snapshot.tools.len());
1031 items.push(item("tools", "Tools", Some(offered)));
1032 }
1033 if matches!(
1034 state,
1035 Some(State::Failed | State::Disconnected | State::Connected | State::NeedsAuth)
1036 ) {
1037 items.push(item("reconnect", "Reconnect", None));
1038 }
1039 let oauth = server
1040 .connection
1041 .as_ref()
1042 .is_some_and(|connection| connection.oauth_url().is_some());
1043 if state == Some(State::Connected) && oauth {
1044 let deletes = "deletes the stored credentials".to_owned();
1045 items.push(item("signout", "Sign out", Some(deletes)));
1046 }
1047 let exposure = entry.config.exposure().as_str().to_owned();
1048 items.push(item("exposure", "Exposure", Some(exposure)));
1049 items.push(item("disable", "Disable", Some(saved)));
1050 }
1051 let details = [
1052 entry.describe_transport(),
1053 format!("{}: {}", entry.scope.as_str(), entry.source.display()),
1054 format!("State: {}", describe_state(server, false)),
1055 ];
1056 let connection_error = snapshot
1057 .as_ref()
1058 .filter(|snapshot| snapshot.state != State::Connected)
1059 .and_then(|snapshot| snapshot.error.clone());
1060 let error: Vec<String> = server
1061 .message
1062 .clone()
1063 .into_iter()
1064 .chain(connection_error)
1065 .collect();
1066 McpMenu {
1067 title: format!("MCP server {name}"),
1068 details: Some(details.join("\n")),
1069 error: (!error.is_empty()).then(|| error.join("\n")),
1070 selected: items.first().map(|item| item.value.clone()),
1071 items,
1072 confirm_label: "select".into(),
1073 cancel_label: "back".into(),
1074 ..McpMenu::default()
1075 }
1076 });
1077 menu.unwrap_or_else(|| McpMenu {
1078 title: name.into(),
1079 empty: "This server is no longer configured.".into(),
1080 cancel_label: "back".into(),
1081 ..McpMenu::default()
1082 })
1083 }
1084
1085 async fn show_tools(&self, ui: &dyn ExtensionUi, name: &str) -> Option<()> {
1087 let build = || {
1088 self.server(name, |server| {
1089 let config = &server.entry.config;
1090 let exposure = config.exposure();
1091 let overridden = if config.tool_exposure.is_empty() {
1092 ""
1093 } else {
1094 "\nSome tools override it with toolExposure."
1095 };
1096 let tools = server
1097 .connection
1098 .as_ref()
1099 .map(|connection| connection.snapshot().tools)
1100 .unwrap_or_default();
1101 McpMenu {
1102 title: format!("Tools of {name}"),
1103 details: Some(format!(
1104 "Exposure {}: {}{overridden}",
1105 exposure.as_str(),
1106 describe_exposure(exposure)
1107 )),
1108 items: tools
1109 .iter()
1110 .map(|tool| {
1111 let description =
1112 first_line(tool.description.as_deref().unwrap_or_default());
1113 let description = match config.tool_exposure(&tool.name) {
1114 own if own == exposure => description.to_owned(),
1115 own => format!("[{}] {description}", own.as_str()),
1116 };
1117 item(&tool.name, &tool.name, Some(description))
1118 })
1119 .collect(),
1120 empty: "The server offers no tools.".into(),
1121 confirm_label: "back".into(),
1122 cancel_label: "back".into(),
1123 ..McpMenu::default()
1124 }
1125 })
1126 .unwrap_or_default()
1127 };
1128 Self::menu(ui, build).await.map(drop)
1129 }
1130
1131 async fn choose_exposure(&self, ctx: &Context, name: &str) -> Option<Option<String>> {
1134 let Some((current, details)) = self.server(name, |server| {
1135 let source = server.entry.source.display();
1136 let details = match server.entry.scope {
1137 Scope::Extension => {
1138 format!("Applies to this session; the server is registered by {source}.")
1139 }
1140 _ => format!("Saved to {source}."),
1141 };
1142 (server.entry.config.exposure(), details)
1143 }) else {
1144 return Some(None);
1145 };
1146 let build = || McpMenu {
1147 title: format!("Exposure of {name}"),
1148 details: Some(details.clone()),
1149 items: EXPOSURES
1150 .iter()
1151 .map(|(exposure, description)| {
1152 let mark = if *exposure == current { "✓ " } else { " " };
1153 let label = format!("{mark}{}", exposure.as_str());
1154 item(exposure.as_str(), &label, Some((*description).into()))
1155 })
1156 .collect(),
1157 selected: Some(current.as_str().into()),
1158 confirm_label: "save".into(),
1159 cancel_label: "back".into(),
1160 ..McpMenu::default()
1161 };
1162 let choice = Self::menu(ctx.ui.as_ref(), build).await?;
1163 let exposure = EXPOSURES
1164 .iter()
1165 .map(|(exposure, _)| *exposure)
1166 .find(|exposure| choice.as_deref() == Some(exposure.as_str()))
1167 .filter(|exposure| *exposure != current);
1168 Some(exposure.and_then(|exposure| self.set_exposure(ctx, name, exposure)))
1169 }
1170
1171 async fn run_action(&self, ctx: &Context, name: &str, action: &str) -> Option<()> {
1173 let ui = ctx.ui.as_ref();
1174 let status = |title: String, message: &str| {
1175 ui.mcp_manager(McpScreen::Status(title, message.to_owned()));
1176 };
1177 let message = match action {
1178 "signin" => {
1179 let title = format!("Sign in to {name}");
1180 status(title.clone(), "Contacting the authorization server…");
1181 self.sign_in(name, manager_prompt(&ctx.ui, title)).await
1182 }
1183 "reconnect" => {
1184 status(format!("MCP server {name}"), "Reconnecting…");
1186 if let Some(connection) = self.connection(name) {
1187 let _ = connection.reconnect().await;
1188 }
1189 None
1190 }
1191 "signout" => {
1192 let _ = self.sign_out(name).await;
1193 None
1194 }
1195 "tools" => {
1196 self.show_tools(ui, name).await?;
1197 None
1198 }
1199 "exposure" => self.choose_exposure(ctx, name).await?,
1200 "enable" | "disable" => {
1201 let enable = action == "enable";
1202 let doing = if enable {
1203 "Connecting…"
1204 } else {
1205 "Disconnecting…"
1206 };
1207 status(format!("MCP server {name}"), doing);
1208 self.set_enabled(ctx, name, enable).await
1209 }
1210 _ => None,
1211 };
1212 if let Some(server) = lock(&self.shared)
1213 .servers
1214 .iter_mut()
1215 .find(|server| server.entry.name == name)
1216 {
1217 server.message = message;
1218 }
1219 self.ensure_discovery_active(ctx);
1220 Some(())
1221 }
1222
1223 fn save_config(&self, name: &str, patch: ConfigPatch) -> Option<String> {
1227 let (source, scope) = self.server(name, |server| {
1228 (server.entry.source.clone(), server.entry.scope)
1229 })?;
1230 if scope != Scope::Extension
1232 && let Err(error) = config::update_server_config(&source, name, patch)
1233 {
1234 return Some(format!("Could not update {}: {error}", source.display()));
1235 }
1236 if let Some(server) = lock(&self.shared)
1237 .servers
1238 .iter_mut()
1239 .find(|server| server.entry.name == name)
1240 {
1241 patch.apply(&mut server.entry.config);
1242 }
1243 None
1244 }
1245
1246 async fn set_enabled(&self, ctx: &Context, name: &str, enabled: bool) -> Option<String> {
1249 if let Some(failed) = self.save_config(name, ConfigPatch::Enabled(enabled)) {
1250 return Some(failed);
1251 }
1252 if !enabled {
1253 let connection = lock(&self.shared)
1254 .servers
1255 .iter_mut()
1256 .find(|server| server.entry.name == name)
1257 .and_then(|server| server.connection.take());
1258 self.hide_tools(name);
1259 if let Some(connection) = connection {
1260 connection.close().await;
1261 }
1262 return None;
1263 }
1264 let generation = lock(&self.shared).generation;
1265 if let Some(handle) = self.start(name, ctx, generation) {
1266 let _ = handle.await;
1267 }
1268 None
1269 }
1270
1271 fn set_exposure(&self, ctx: &Context, name: &str, exposure: McpExposure) -> Option<String> {
1273 if let Some(failed) = self.save_config(name, ConfigPatch::Exposure(exposure)) {
1274 return Some(failed);
1275 }
1276 if let Some(connection) = self
1277 .connection(name)
1278 .filter(|connection| connection.state() == State::Connected)
1279 {
1280 self.register_tools(&connection);
1281 }
1282 self.sync_resource_tools();
1283 let indirect: HashSet<String> = ctx
1286 .tools
1287 .all()
1288 .into_iter()
1289 .filter(|tool| tool.exposure != Exposure::Direct)
1290 .map(|tool| tool.name)
1291 .collect();
1292 let own = lock(&self.shared)
1293 .server_tools
1294 .get(name)
1295 .cloned()
1296 .unwrap_or_default();
1297 let active = ctx
1298 .tools
1299 .active()
1300 .into_iter()
1301 .filter(|tool| !own.contains(tool) || !indirect.contains(tool))
1302 .collect();
1303 ctx.tools.set_active(active);
1304 None
1305 }
1306
1307 fn hide_tools(&self, name: &str) {
1310 let Some(tools) = self.tools() else {
1311 return;
1312 };
1313 let hidden: Vec<RegisteredTool> = {
1314 let mut shared = lock(&self.shared);
1315 let names = shared
1316 .server_tools
1317 .insert(name.to_owned(), Vec::new())
1318 .unwrap_or_default();
1319 names
1320 .iter()
1321 .filter_map(|tool| shared.definitions.get(tool))
1322 .map(|definition| RegisteredTool {
1323 exposure: Exposure::Hidden,
1324 ..definition.clone()
1325 })
1326 .collect()
1327 };
1328 for definition in hidden {
1329 tools.register(definition);
1330 }
1331 self.sync_resource_tools();
1332 }
1333}
1334
1335fn login_prompt(ctx: &Context, name: &str) -> SignInPrompt {
1337 let (ui, server, tui) = (Arc::clone(&ctx.ui), name.to_owned(), ctx.mode == Mode::Tui);
1338 let show_authorization_url = Box::new(move |url: &str| {
1339 let lines = match tui {
1341 true if cfg!(target_os = "macos") => format!("{url}\nCmd+click to open"),
1342 true => format!("{url}\nCtrl+click to open"),
1343 false => url.to_owned(),
1344 };
1345 ui.notify(
1346 &format!("Sign in to MCP server \"{server}\" in your browser:\n{lines}"),
1347 NotifyKind::Info,
1348 );
1349 yapi_ai::auth::open_browser(url);
1350 });
1351 let (ui, server) = (Arc::clone(&ctx.ui), name.to_owned());
1352 let redirect_url = Box::new(move |cancel| {
1353 ui.input(
1354 &format!("Waiting for sign-in to \"{server}\". If the browser cannot reach this machine, paste the URL it was redirected to."),
1355 Some("http://127.0.0.1:.../callback?code=..."),
1356 DialogOptions {
1357 cancel: Some(cancel),
1358 ..DialogOptions::default()
1359 },
1360 )
1361 });
1362 SignInPrompt {
1363 show_authorization_url,
1364 redirect_url,
1365 }
1366}
1367
1368fn manager_prompt(ui: &Arc<dyn ExtensionUi>, title: String) -> SignInPrompt {
1370 let url = Arc::new(Mutex::new(String::new()));
1371 let shown = Arc::clone(&url);
1372 let ui = Arc::clone(ui);
1373 SignInPrompt {
1374 show_authorization_url: Box::new(move |link: &str| {
1375 link.clone_into(&mut lock(&shown));
1376 yapi_ai::auth::open_browser(link);
1377 }),
1378 redirect_url: Box::new(move |cancel| {
1379 let (ui, title, url) = (Arc::clone(&ui), title.clone(), lock(&url).clone());
1380 Box::pin(async move {
1381 let (reply, answer) = oneshot::channel();
1382 ui.mcp_manager(McpScreen::RedirectUrl(title.clone(), url, reply));
1383 let value = tokio::select! {
1384 value = answer => value.ok().flatten(),
1385 () = cancel.cancelled() => None,
1386 };
1387 ui.mcp_manager(McpScreen::Status(title, "Connecting…".into()));
1388 value
1389 })
1390 }),
1391 }
1392}
1393
1394fn item(value: &str, label: &str, description: Option<String>) -> AutocompleteItem {
1396 AutocompleteItem {
1397 value: value.to_owned(),
1398 label: label.to_owned(),
1399 description,
1400 }
1401}
1402
1403fn attention_rank(server: &Server) -> u8 {
1405 if !server.entry.config.is_enabled() {
1406 return 5;
1407 }
1408 match server
1409 .connection
1410 .as_ref()
1411 .map(|connection| connection.state())
1412 {
1413 Some(State::NeedsAuth) => 0,
1414 Some(State::Failed) => 1,
1415 Some(State::Disconnected) => 2,
1416 Some(State::Connected) => 4,
1417 _ => 3,
1418 }
1419}
1420
1421fn describe_exposure(exposure: McpExposure) -> &'static str {
1423 EXPOSURES
1424 .iter()
1425 .find(|(known, _)| *known == exposure)
1426 .map_or("unreachable", |(_, description)| description)
1427}
1428
1429fn no_servers(agent_dir: &Path) -> String {
1431 format!(
1432 "No MCP servers configured. Add them to {} or .yapi/mcp.json.",
1433 agent_dir.join(ConfigFile::Mcp.file_name()).display()
1434 )
1435}
1436
1437impl McpExtension {
1438 async fn reconnect_signed_in(&self, ctx: &Context) {
1441 let connections: Vec<Arc<Connection>> = lock(&self.shared)
1442 .servers
1443 .iter()
1444 .filter_map(|server| server.connection.clone())
1445 .collect();
1446 let mut signed_in = Vec::new();
1447 for connection in connections {
1448 if connection.signed_in_elsewhere().await {
1449 signed_in.push(connection);
1450 }
1451 }
1452 if signed_in.is_empty() {
1453 return;
1454 }
1455 futures_util::future::join_all(signed_in.iter().map(|connection| connection.reconnect()))
1456 .await;
1457 self.ensure_discovery_active(ctx);
1458 }
1459
1460 fn enabled(&self, filter: impl Fn(&Server) -> bool) -> Vec<String> {
1462 lock(&self.shared)
1463 .servers
1464 .iter()
1465 .filter(|server| server.entry.config.is_enabled() && filter(server))
1466 .map(|server| server.entry.name.clone())
1467 .collect()
1468 }
1469
1470 async fn servers_changed(&self, ctx: &Context) {
1474 let configured: Vec<ServerEntry> = {
1475 let shared = lock(&self.shared);
1476 if !shared.active {
1477 return;
1478 }
1479 shared
1480 .servers
1481 .iter()
1482 .filter(|server| server.registered.is_none())
1483 .map(|server| server.entry.clone())
1484 .collect()
1485 };
1486 let (next, overridden) = registered_servers(ctx, &configured);
1487 let (removed, generation) = {
1488 let mut shared = lock(&self.shared);
1489 shared.overridden = overridden;
1490 let (removed, kept): (Vec<Server>, Vec<Server>) = std::mem::take(&mut shared.servers)
1491 .into_iter()
1492 .partition(|server| {
1493 server.registered.is_some()
1494 && next
1495 .iter()
1496 .find(|other| other.entry.name == server.entry.name)
1497 .map(|other| &other.registered)
1498 != Some(&server.registered)
1499 });
1500 shared.servers = kept;
1501 for server in next {
1502 let name = &server.entry.name;
1503 if !shared.servers.iter().any(|known| known.entry.name == *name) {
1504 shared.servers.push(server);
1505 }
1506 }
1507 (removed, shared.generation)
1508 };
1509 for server in &removed {
1510 self.hide_tools(&server.entry.name);
1511 }
1512 self.ensure_discovery_active(ctx);
1513 for connection in removed.into_iter().filter_map(|server| server.connection) {
1514 connection.close().await;
1515 }
1516 let connecting =
1517 self.enabled(|server| server.registered.is_some() && server.ready.is_none());
1518 let handles: Vec<_> = connecting
1519 .iter()
1520 .filter_map(|name| self.start(name, ctx, generation))
1521 .collect();
1522 if handles.is_empty() {
1523 return;
1524 }
1525 let this = self.clone();
1527 let ui = Arc::clone(&ctx.ui);
1528 tokio::spawn(async move {
1529 for handle in handles {
1530 let _ = handle.await;
1531 }
1532 if lock(&this.shared).generation == generation {
1533 this.report_problems(ui.as_ref(), Some(&connecting));
1534 }
1535 });
1536 }
1537
1538 fn connection(&self, name: &str) -> Option<Arc<Connection>> {
1539 lock(&self.shared)
1540 .servers
1541 .iter()
1542 .find(|server| server.entry.name == name)
1543 .and_then(|server| server.connection.clone())
1544 }
1545
1546 async fn command(&self, args: &str, ctx: &Context) {
1547 let startup = lock(&self.shared).startup.clone();
1548 Self::wait_for(startup.into_iter().collect(), &ctx.cancel).await;
1549 let words: Vec<&str> = args.split_whitespace().collect();
1550 let Some((action, rest)) = words.split_first() else {
1551 if ctx.mode == Mode::Tui {
1552 self.manage(ctx).await;
1553 ctx.ui.mcp_manager(McpScreen::Close);
1554 } else {
1555 ctx.ui
1556 .notify(&self.format_status(&ctx.agent_dir), NotifyKind::Info);
1557 }
1558 return;
1559 };
1560 if rest.len() > 1 {
1561 ctx.ui.notify(USAGE, NotifyKind::Warning);
1562 return;
1563 }
1564 let name = rest.first().copied();
1565 let none = "No enabled MCP server uses OAuth. Only HTTP servers without an Authorization header do.";
1566 let oauth = |server: &Server| {
1567 server.connection.is_some() && server.entry.config.transport.uses_oauth()
1568 };
1569 let needs_auth = |server: &Server| {
1570 server
1571 .connection
1572 .as_ref()
1573 .is_some_and(|connection| connection.snapshot().state == State::NeedsAuth)
1574 };
1575 match *action {
1576 "login" => {
1577 let Some(name) = self.pick(name, ctx, oauth, needs_auth, none).await else {
1578 return;
1579 };
1580 if !ctx.ui.has_ui() {
1581 ctx.ui.notify(
1582 &format!("Signing in to MCP server \"{name}\" requires interactive mode."),
1583 NotifyKind::Error,
1584 );
1585 return;
1586 }
1587 if let Some(failure) = self.sign_in(&name, login_prompt(ctx, &name)).await {
1588 let kind = if failure == "Sign-in cancelled." {
1589 NotifyKind::Info
1590 } else {
1591 NotifyKind::Error
1592 };
1593 ctx.ui.notify(&failure, kind);
1594 return;
1595 }
1596 self.ensure_discovery_active(ctx);
1597 let tools = self
1598 .connection(&name)
1599 .map_or(0, |connection| connection.snapshot().tools.len());
1600 ctx.ui.notify(
1601 &format!("Signed in to MCP server \"{name}\" ({tools} tools)."),
1602 NotifyKind::Info,
1603 );
1604 }
1605 "logout" => {
1606 let Some(name) = self.pick(name, ctx, oauth, needs_auth, none).await else {
1607 return;
1608 };
1609 let removed = match self.sign_out(&name).await {
1610 Ok(removed) => removed,
1611 Err(error) => {
1612 ctx.ui.notify(&error.to_string(), NotifyKind::Error);
1613 return;
1614 }
1615 };
1616 let message = if removed {
1617 format!("Signed out of MCP server \"{name}\".")
1618 } else {
1619 format!("No stored credentials for MCP server \"{name}\".")
1620 };
1621 ctx.ui.notify(&message, NotifyKind::Info);
1622 }
1623 "reconnect" => {
1624 let failed = |server: &Server| {
1625 server.connection.as_ref().is_some_and(|connection| {
1626 matches!(
1627 connection.snapshot().state,
1628 State::Failed | State::Disconnected
1629 )
1630 })
1631 };
1632 let Some(name) = self
1633 .pick(
1634 name,
1635 ctx,
1636 |server| server.connection.is_some(),
1637 failed,
1638 "No enabled MCP server to reconnect.",
1639 )
1640 .await
1641 else {
1642 return;
1643 };
1644 let Some(connection) = self.connection(&name) else {
1645 return;
1646 };
1647 match connection.reconnect().await {
1648 Err(error) => ctx.ui.notify(&error.to_string(), NotifyKind::Error),
1649 Ok(()) => {
1650 self.ensure_discovery_active(ctx);
1651 let state = {
1652 let shared = lock(&self.shared);
1653 shared
1654 .servers
1655 .iter()
1656 .find(|server| server.entry.name == name)
1657 .map(|server| describe_state(server, true))
1658 .unwrap_or_default()
1659 };
1660 ctx.ui.notify(
1661 &format!("Reconnected to MCP server \"{name}\" ({state})."),
1662 NotifyKind::Info,
1663 );
1664 }
1665 }
1666 }
1667 _ => ctx.ui.notify(USAGE, NotifyKind::Warning),
1668 }
1669 }
1670
1671 fn argument_completions(&self, prefix: &str) -> Option<Vec<AutocompleteItem>> {
1673 let words: Vec<&str> = prefix.split_whitespace().collect();
1674 let trailing = prefix.ends_with(char::is_whitespace);
1675 let (action, server) = match (words.as_slice(), trailing) {
1676 ([], _) => ("", None),
1677 ([action], false) => (*action, None),
1678 ([action], true) => (*action, Some("")),
1679 ([action, server], false) => (*action, Some(*server)),
1680 _ => return None,
1681 };
1682 let Some(server) = server else {
1683 return Some(
1684 ["login", "logout", "reconnect"]
1685 .into_iter()
1686 .filter(|item| item.starts_with(action))
1687 .map(|item| AutocompleteItem {
1688 value: format!("{item} "),
1689 label: item.into(),
1690 description: None,
1691 })
1692 .collect(),
1693 );
1694 };
1695 if !matches!(action, "login" | "logout" | "reconnect") {
1696 return None;
1697 }
1698 let shared = lock(&self.shared);
1699 let items: Vec<AutocompleteItem> = shared
1700 .servers
1701 .iter()
1702 .filter(|candidate| {
1703 if action == "reconnect" {
1704 candidate.connection.is_some()
1705 } else {
1706 candidate.connection.is_some() && candidate.entry.config.transport.uses_oauth()
1707 }
1708 })
1709 .filter(|candidate| candidate.entry.name.starts_with(server))
1710 .map(|candidate| AutocompleteItem {
1711 value: format!("{action} {}", candidate.entry.name),
1712 label: candidate.entry.name.clone(),
1713 description: Some(describe_state(candidate, true)),
1714 })
1715 .collect();
1716 (!items.is_empty()).then_some(items)
1717 }
1718}
1719
1720impl Extension for McpExtension {
1721 fn source(&self) -> SourceInfo {
1722 builtin_source("mcp")
1723 }
1724
1725 fn commands(&self) -> Vec<Command> {
1726 vec![Command {
1727 name: "mcp".into(),
1728 description:
1729 "Manage MCP servers: sign in, reconnect, enable or disable, and change exposure"
1730 .into(),
1731 }]
1732 }
1733
1734 fn complete(&self, _command: &str, prefix: &str) -> ArgumentCompletions {
1735 self.argument_completions(prefix).into()
1736 }
1737
1738 fn run_command<'a>(
1739 &'a self,
1740 _command: &'a str,
1741 args: &'a str,
1742 ctx: &'a Context,
1743 ) -> BoxFuture<'a, ()> {
1744 Box::pin(self.command(args, ctx))
1745 }
1746
1747 fn handles(&self, kind: &str) -> bool {
1750 kind == "turn_start" || kind == "mcp_servers_change"
1751 }
1752
1753 fn handle<'a>(&'a self, ctx: &'a Context, event: &'a Value) -> BoxFuture<'a, Option<Value>> {
1754 Box::pin(async move {
1755 match event["type"].as_str() {
1756 Some("turn_start") => self.reconnect_signed_in(ctx).await,
1757 Some("mcp_servers_change") => self.servers_changed(ctx).await,
1758 _ => {}
1759 }
1760 None
1761 })
1762 }
1763
1764 fn session_start<'a>(&'a self, ctx: &'a Context) -> BoxFuture<'a, ()> {
1765 Box::pin(async move {
1766 let loaded = config::load(&ctx.agent_dir, &ctx.project_dir, ctx.project_trusted);
1767 let generation = {
1768 let mut shared = lock(&self.shared);
1769 shared.config_errors = loaded.errors;
1770 shared.warned_unreachable = false;
1771 shared.auto_enable_codemode = loaded.auto_enable_codemode.unwrap_or(true);
1772 shared.waited_for_startup = false;
1773 shared.startup = None;
1774 shared.generation += 1;
1775 shared.tools = Some(ctx.tools.clone());
1776 shared.active = true;
1777 let (registered, overridden) = registered_servers(ctx, &loaded.servers);
1778 shared.overridden = overridden;
1779 shared.servers = loaded
1780 .servers
1781 .into_iter()
1782 .map(|entry| Server::new(entry, None))
1783 .chain(registered)
1784 .collect();
1785 shared.generation
1786 };
1787 self.ensure_discovery_active(ctx);
1788 let enabled = self.enabled(|_| true);
1789 if enabled.is_empty() {
1790 self.report_problems(ctx.ui.as_ref(), None);
1791 return;
1792 }
1793 let handles: Vec<_> = enabled
1794 .iter()
1795 .filter_map(|name| self.start(name, ctx, generation))
1796 .collect();
1797 let (done, startup) = watch::channel(false);
1798 lock(&self.shared).startup = Some(startup);
1799 let this = self.clone();
1800 let ui = Arc::clone(&ctx.ui);
1801 tokio::spawn(async move {
1802 for handle in handles {
1803 let _ = handle.await;
1804 }
1805 if lock(&this.shared).generation == generation {
1806 this.report_problems(ui.as_ref(), None);
1807 }
1808 let _ = done.send(true);
1809 });
1810 })
1811 }
1812
1813 fn before_agent_start<'a>(
1814 &'a self,
1815 ctx: &'a Context,
1816 sections: &'a mut IndexMap<String, String>,
1817 ) -> BoxFuture<'a, ()> {
1818 Box::pin(async move {
1819 let first = !std::mem::replace(&mut lock(&self.shared).waited_for_startup, true);
1820 if first {
1821 let waiting = self.pending(has_direct_tools);
1822 if !waiting.is_empty() {
1823 let finished = tokio::time::timeout(
1824 STARTUP_WAIT,
1825 Self::wait_for(waiting, &tokio_util::sync::CancellationToken::new()),
1826 )
1827 .await
1828 .is_ok();
1829 if !finished {
1830 ctx.ui.notify(
1831 "MCP servers are still connecting; their tools become available once connected.",
1832 NotifyKind::Info,
1833 );
1834 }
1835 }
1836 }
1837 let listing: Vec<(ServerEntry, Option<String>)> = lock(&self.shared)
1838 .servers
1839 .iter()
1840 .map(|server| {
1841 let instructions = server
1842 .connection
1843 .as_ref()
1844 .and_then(|connection| connection.snapshot().instructions);
1845 (server.entry.clone(), instructions)
1846 })
1847 .collect();
1848 let borrowed: Vec<(&ServerEntry, Option<String>)> = listing
1849 .iter()
1850 .map(|(entry, instructions)| (entry, instructions.clone()))
1851 .collect();
1852 match render_servers_section(&borrowed) {
1853 Some(section) => {
1854 sections.insert(SERVERS_SECTION.into(), section);
1855 }
1856 None => {
1857 sections.shift_remove(SERVERS_SECTION);
1858 }
1859 }
1860 })
1861 }
1862
1863 fn tool_call<'a>(
1867 &'a self,
1868 ctx: &'a Context,
1869 tool: &'a str,
1870 input: &'a Value,
1871 ) -> BoxFuture<'a, Option<String>> {
1872 Box::pin(async move {
1873 if self.pending(|_| true).is_empty() {
1874 return None;
1875 }
1876 let info = ctx.tools.all().into_iter().find(|info| info.name == tool)?;
1877 let pending = if codemode::is_codemode_tool(&info) {
1878 let code = input["code"].as_str().unwrap_or_default();
1879 self.pending(|entry| script_needs_server(code, &entry.name))
1880 } else if is_tool_search(&info)
1881 || [
1882 LIST_MCP_RESOURCES_TOOL,
1883 LIST_MCP_RESOURCE_TEMPLATES_TOOL,
1884 READ_MCP_RESOURCE_TOOL,
1885 ]
1886 .contains(&tool)
1887 {
1888 self.pending(|_| true)
1889 } else {
1890 return None;
1891 };
1892 Self::wait_for(pending, &ctx.cancel).await;
1893 None
1894 })
1895 }
1896
1897 fn session_shutdown<'a>(&'a self, _ctx: &'a Context) -> BoxFuture<'a, ()> {
1898 Box::pin(async move {
1899 let closing: Vec<Arc<Connection>> = {
1900 let mut shared = lock(&self.shared);
1901 shared.generation += 1;
1902 shared.active = false;
1903 std::mem::take(&mut shared.servers)
1904 .into_iter()
1905 .filter_map(|server| server.connection)
1906 .collect()
1907 };
1908 for connection in closing {
1909 connection.close().await;
1910 }
1911 })
1912 }
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917 use std::path::PathBuf;
1918 use std::time::Duration;
1919
1920 use futures_util::future::BoxFuture;
1921 use serde_json::json;
1922 use tokio_util::sync::CancellationToken;
1923 use yapi_agent::{Tool, UpdateSink};
1924 use yapi_types::event::ToolResult;
1925 use yapi_types::message::ToolDeclaration;
1926
1927 use super::*;
1928 use crate::extensions::NoUi;
1929 use crate::tools::registry::ToolRegistry;
1930
1931 #[test]
1932 fn scripts_need_named_servers() {
1933 let code = "await tools.mcp__my_docs__search({q: 1})";
1934 assert!(script_needs_server(code, "my-docs"));
1935 assert!(!script_needs_server(code, "other"));
1936 assert!(script_needs_server(
1937 "const found = await searchTools('x')",
1938 "other"
1939 ));
1940 assert!(script_needs_server("ALL_TOOLS.length", "other"));
1941 assert!(!script_needs_server("describeTools()", "other"));
1942 assert!(!script_needs_server("my_searchTools()", "other"));
1943 }
1944
1945 struct Declared(ToolDeclaration);
1946
1947 impl Tool for Declared {
1948 fn declaration(&self) -> &ToolDeclaration {
1949 &self.0
1950 }
1951
1952 fn execute(
1953 &self,
1954 _call_id: String,
1955 _args: Value,
1956 _cancel: CancellationToken,
1957 _updates: UpdateSink,
1958 ) -> BoxFuture<'_, Result<ToolResult, String>> {
1959 Box::pin(async { Err("unused".into()) })
1960 }
1961 }
1962
1963 fn tool(name: &str, parameters: Value, exposure: Exposure) -> RegisteredTool {
1964 RegisteredTool {
1965 exposure,
1966 ..RegisteredTool::direct(
1967 Arc::new(Declared(ToolDeclaration {
1968 name: name.into(),
1969 description: String::new(),
1970 parameters,
1971 constrained_sampling: None,
1972 })),
1973 None,
1974 Vec::new(),
1975 )
1976 }
1977 }
1978
1979 fn connecting() -> (McpExtension, Context, Vec<watch::Sender<bool>>) {
1983 let extension = McpExtension::new();
1984 let mut senders = Vec::new();
1985 for name in ["docs", "other"] {
1986 let (sender, ready) = watch::channel(false);
1987 let entry = ServerEntry {
1988 name: name.into(),
1989 config: config::validate_server(name, &json!({"command": "true"})).unwrap(),
1990 source: PathBuf::new(),
1991 scope: Scope::Global,
1992 };
1993 let mut server = Server::new(entry, None);
1994 server.ready = Some(ready);
1995 lock(&extension.shared).servers.push(server);
1996 senders.push(sender);
1997 }
1998 let tools = Tools::new(ToolRegistry::default());
1999 tools.register(tool(
2000 codemode::NAME,
2001 codemode::parameters(),
2002 Exposure::ModelOnly,
2003 ));
2004 tools.register(tool(
2005 TOOL_SEARCH_TOOL_NAME,
2006 json!({"type": "object"}),
2007 Exposure::ModelOnly,
2008 ));
2009 tools.register(tool(
2010 "named_like_search",
2011 json!({"type": "object"}),
2012 Exposure::Direct,
2013 ));
2014 let ctx = Context {
2015 cwd: PathBuf::new(),
2016 agent_dir: PathBuf::new(),
2017 project_dir: PathBuf::new(),
2018 project_trusted: false,
2019 mode: Mode::Print,
2020 ui: Arc::new(NoUi),
2021 tools,
2022 cancel: CancellationToken::new(),
2023 session: Default::default(),
2024 };
2025 (extension, ctx, senders)
2026 }
2027
2028 async fn waits(extension: &McpExtension, ctx: &Context, tool: &str, input: Value) -> bool {
2030 tokio::time::timeout(
2031 Duration::from_millis(50),
2032 extension.tool_call(ctx, tool, &input),
2033 )
2034 .await
2035 .is_err()
2036 }
2037
2038 #[tokio::test]
2039 async fn calls_wait_for_servers() {
2040 let (extension, ctx, senders) = connecting();
2041 let script = |code: &str| json!({"code": code});
2042 assert!(waits(&extension, &ctx, "codemode", script("tools.mcp__docs__a()")).await);
2043 assert!(
2044 waits(
2045 &extension,
2046 &ctx,
2047 "codemode",
2048 script("await searchTools('a')")
2049 )
2050 .await
2051 );
2052 assert!(waits(&extension, &ctx, "codemode", script("ALL_TOOLS")).await);
2053 assert!(!waits(&extension, &ctx, "codemode", script("return 1")).await);
2054 assert!(waits(&extension, &ctx, TOOL_SEARCH_TOOL_NAME, json!({})).await);
2055 assert!(!waits(&extension, &ctx, "named_like_search", json!({})).await);
2056 assert!(!waits(&extension, &ctx, "unknown", json!({})).await);
2057 senders[0].send_replace(true);
2059 assert!(!waits(&extension, &ctx, "codemode", script("tools.mcp__docs__a()")).await);
2060 assert!(waits(&extension, &ctx, "codemode", script("ALL_TOOLS")).await);
2061 }
2062
2063 #[tokio::test]
2064 async fn abort_ends_wait() {
2065 let (extension, ctx, _senders) = connecting();
2066 let input = json!({"code": "ALL_TOOLS"});
2067 let call = extension.tool_call(&ctx, "codemode", &input);
2068 let cancel = ctx.cancel.clone();
2069 let (outcome, ()) = tokio::join!(
2070 tokio::time::timeout(Duration::from_secs(5), call),
2071 async move {
2072 tokio::time::sleep(Duration::from_millis(20)).await;
2073 cancel.cancel();
2074 }
2075 );
2076 assert_eq!(outcome, Ok(None));
2077 }
2078}