Skip to main content

yapi_core/mcp/
extension.rs

1//! The built-in MCP extension: connects the servers in `mcp.json` and those
2//! extensions register with `pi.registerMcpServer` when a session starts, and
3//! servers registered later right away. A server in `mcp.json` takes
4//! precedence over a registered one of the same name. The extension registers
5//! tools as `mcp__<server>__<tool>`. Port of `extensions/mcp/index.ts` in pi
6//! `v1.0.0`.
7//!
8//! Connections run in the background. The first prompt waits only for servers
9//! with `direct` tools. `tool_search` and the resource tools wait for the
10//! servers they need when they run. Servers whose tools are not declared are
11//! listed in the `mcp_servers` system prompt section. In the TUI `/mcp` opens
12//! a manager to sign in, reconnect, enable or disable servers and change
13//! their exposure, saved to the `mcp.json` that defines the server or, for
14//! servers extensions registered, kept for the session. Elsewhere it reports
15//! the status, and `/mcp login` signs in to OAuth servers.
16
17use 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
49/// How long the first prompt waits for servers with `direct` tools.
50const STARTUP_WAIT: Duration = Duration::from_secs(10);
51/// The system prompt section that lists servers whose tools are not declared.
52pub 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]";
57/// How often an open `/mcp` menu looks for changes.
58const MENU_REFRESH: Duration = Duration::from_millis(100);
59/// pi's `EXPOSURE_DESCRIPTIONS`: the exposures `/mcp` offers.
60const 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/// pi's `McpMenu`: a menu of the `/mcp` manager.
76#[derive(Clone, Debug, Default, PartialEq)]
77pub struct McpMenu {
78    /// The title.
79    pub title: String,
80    /// Shown below the title.
81    pub details: Option<String>,
82    /// Shown below the details in the error color.
83    pub error: Option<String>,
84    /// The items.
85    pub items: Vec<AutocompleteItem>,
86    /// Shown when there are no items.
87    pub empty: String,
88    /// Value of the item selected when the menu opens.
89    pub selected: Option<String>,
90    /// What the confirm key does, for the key hint.
91    pub confirm_label: String,
92    /// What the cancel key does, for the key hint.
93    pub cancel_label: String,
94}
95
96/// pi's `McpUi`: a screen of the `/mcp` manager.
97#[derive(Debug)]
98pub enum McpScreen {
99    /// A menu, rebuilt through the receiver while it is open, keeping the
100    /// selected item. The reply is the chosen item's value, or `None` when
101    /// cancelled.
102    Menu(watch::Receiver<McpMenu>, oneshot::Sender<Option<String>>),
103    /// A title and a message, shown while an operation runs.
104    Status(String, String),
105    /// A title, the authorization URL, and a field for the URL the browser
106    /// was redirected to. The reply is that URL, or `None` when cancelled.
107    RedirectUrl(String, String, oneshot::Sender<Option<String>>),
108    /// Closes the manager.
109    Close,
110}
111
112/// A configured server. A disabled server has no connection.
113struct Server {
114    entry: ServerEntry,
115    connection: Option<Arc<Connection>>,
116    /// True once the connection started for the server connected or failed.
117    ready: Option<watch::Receiver<bool>>,
118    /// Why the last `/mcp` action failed, shown in the manager.
119    message: Option<String>,
120    /// For servers extensions registered: the config as registered, to
121    /// detect registrations with another config.
122    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
137/// pi's `registeredServers`: the servers extensions registered, except names
138/// `configured` in `mcp.json`, which take precedence, and a notice for each
139/// registration they override.
140fn 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            // Validated when it was registered.
167            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    /// Registrations that `mcp.json` overrides, as `/mcp` lists them.
185    overridden: Vec<String>,
186    /// True once startup connections settled and their problems were reported.
187    startup: Option<watch::Receiver<bool>>,
188    warned_unreachable: bool,
189    /// `autoEnableCodemode`: activate codemode for `codemode` servers.
190    auto_enable_codemode: bool,
191    waited_for_startup: bool,
192    generation: u64,
193    /// Tool name to the `<server>\0<tool>` it belongs to.
194    tool_owners: HashMap<String, String>,
195    /// Tool names each server offers now.
196    server_tools: HashMap<String, Vec<String>>,
197    /// The last tool registered under each name.
198    definitions: HashMap<String, RegisteredTool>,
199    /// The exposure the resource tools were last registered with.
200    resource_tools_exposure: Option<McpExposure>,
201    tools: Option<Tools>,
202    /// Between `session_start` and `session_shutdown`. Registrations before
203    /// that are read on `session_start`.
204    active: bool,
205}
206
207/// The MCP extension. Each session has its own.
208#[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
236/// pi's `renderServersSection`: every enabled server with codemode or
237/// deferred tools, how its tools are reached, and a one-line summary.
238fn 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    // Each description also takes a ": " separator.
289    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
323/// Whether a codemode script needs the server: it names the server's
324/// namespace, or searches, lists or describes tools, which may name the server
325/// in other forms.
326fn 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
333/// Short state for lists. `with_error` appends the first line of a failure.
334fn 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    /// A new extension.
369    pub fn new() -> McpExtension {
370        McpExtension::default()
371    }
372
373    fn tools(&self) -> Option<Tools> {
374        lock(&self.shared).tools.clone()
375    }
376
377    /// pi's `registerTools`: names, exposure and namespace for each of the
378    /// server's tools. Tools it dropped are re-registered as hidden.
379    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    /// Enabled, non-hidden servers with resources, with their exposure: what
463    /// the resource tools reach.
464    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    /// Registers the resource tools with the widest exposure of the servers
485    /// they reach, or as hidden when none has resources.
486    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    /// Activates the tool that reaches undeclared MCP tools: codemode for
543    /// `codemode` exposure unless `autoEnableCodemode` is false, `tool_search`
544    /// for `deferred`. Warns once when neither is active.
545    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        // Other extensions' tools of these names cannot reach MCP tools.
572        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    /// One message for everything that needs the user after startup, or
608    /// only for the servers named in `only`.
609    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    /// Creates the server's connection and connects it in the background.
660    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        // pi's `getApiKeyForProvider`, for servers with `auth.provider`.
684        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    /// Waits for `waiting` to settle, or for `cancel`.
715    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    /// The plain status `/mcp` shows without the TUI.
747    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    /// The server a subcommand names, or the only or preferred candidate.
815    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    /// pi's `signIn`: a failure message, or `None` once signed in and
881    /// reconnected.
882    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    /// pi's `signOut`: removes the stored credentials and disconnects.
901    /// Returns whether any were stored.
902    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    /// pi's `/mcp` manager, `manage`: the servers menu and each server's
912    /// menu, until closed.
913    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    /// pi's `McpUi.menu`: shows the menu `build` makes, kept current while it
931    /// is open. The chosen value, `Some(None)` when cancelled, or `None` when
932    /// the manager closed.
933    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        // shortcut: polls instead of pi's change events, add a listener on
938        // `Connection` state changes if polling ever shows in a profile.
939        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    /// `read` applied to server `name`.
956    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    /// pi's `serversMenu`: servers that need the user first.
965    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    /// pi's `serverMenu`: the actions the server's state allows.
1007    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    /// pi's `showTools`. `None` when the manager closed.
1086    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    /// pi's `chooseExposure`: why the choice could not be saved, or `None`
1132    /// when the manager closed.
1133    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    /// pi's `runAction`. `None` when the manager closed.
1172    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                // A failure shows as the connection's state and error.
1185                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    /// pi's `saveConfig`: saves `patch` to the `mcp.json` that defines the
1224    /// server, unless an extension registered it, and applies it. Returns
1225    /// why it could not be saved.
1226    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        // Changes to servers extensions registered apply to the session only.
1231        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    /// pi's `setEnabled`: why the config could not be saved. Connection
1247    /// errors show in the state.
1248    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    /// pi's `setExposure`: why the config could not be saved.
1272    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        // Tools no longer exposed directly leave the declared set. Direct
1284        // tools are activated on registration.
1285        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    /// pi's `hideTools`: makes the tools of a disabled or unregistered
1308    /// server unreachable.
1309    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
1335/// The sign-in prompt of `/mcp login`: notifications and an input dialog.
1336fn 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        // pi links both lines, while yapi shows the text and the terminal detects the URL.
1340        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
1368/// The sign-in prompt of the `/mcp` manager: its screen for the redirect URL.
1369fn 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
1394/// A menu item.
1395fn 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
1403/// pi's `attentionRank`: servers that need the user first.
1404fn 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
1421/// What an exposure means, for the tools menu.
1422fn describe_exposure(exposure: McpExposure) -> &'static str {
1423    EXPOSURES
1424        .iter()
1425        .find(|(known, _)| *known == exposure)
1426        .map_or("unreachable", |(_, description)| description)
1427}
1428
1429/// What `/mcp` shows without servers.
1430fn 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    /// Reconnects servers that need a sign-in when their credentials were
1439    /// stored since, as by `yapi mcp login` in another process.
1440    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    /// Names of the enabled servers that `filter` selects.
1461    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    /// pi's `mcp_servers_change` handler: connects servers registered during
1471    /// the session, and drops unregistered ones and those registered again
1472    /// with another config, which connect again with it.
1473    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        // Later events do not wait for the connections.
1526        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    /// pi-mcp's completions of `/mcp` arguments.
1672    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    // `turn_start` picks up sign-ins done outside the session, such as
1748    // `yapi mcp login` run by the agent.
1749    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(
1767                &ctx.agent_dir,
1768                &ctx.project_dir,
1769                ctx.project_trusted,
1770                ctx.project_only_mcp.as_deref(),
1771            );
1772            let generation = {
1773                let mut shared = lock(&self.shared);
1774                shared.config_errors = loaded.errors;
1775                shared.warned_unreachable = false;
1776                shared.auto_enable_codemode = loaded.auto_enable_codemode.unwrap_or(true);
1777                shared.waited_for_startup = false;
1778                shared.startup = None;
1779                shared.generation += 1;
1780                shared.tools = Some(ctx.tools.clone());
1781                shared.active = true;
1782                let (registered, overridden) = registered_servers(ctx, &loaded.servers);
1783                shared.overridden = overridden;
1784                shared.servers = loaded
1785                    .servers
1786                    .into_iter()
1787                    .map(|entry| Server::new(entry, None))
1788                    .chain(registered)
1789                    .collect();
1790                shared.generation
1791            };
1792            self.ensure_discovery_active(ctx);
1793            let enabled = self.enabled(|_| true);
1794            if enabled.is_empty() {
1795                self.report_problems(ctx.ui.as_ref(), None);
1796                return;
1797            }
1798            let handles: Vec<_> = enabled
1799                .iter()
1800                .filter_map(|name| self.start(name, ctx, generation))
1801                .collect();
1802            let (done, startup) = watch::channel(false);
1803            lock(&self.shared).startup = Some(startup);
1804            let this = self.clone();
1805            let ui = Arc::clone(&ctx.ui);
1806            tokio::spawn(async move {
1807                for handle in handles {
1808                    let _ = handle.await;
1809                }
1810                if lock(&this.shared).generation == generation {
1811                    this.report_problems(ui.as_ref(), None);
1812                }
1813                let _ = done.send(true);
1814            });
1815        })
1816    }
1817
1818    fn before_agent_start<'a>(
1819        &'a self,
1820        ctx: &'a Context,
1821        sections: &'a mut IndexMap<String, String>,
1822    ) -> BoxFuture<'a, ()> {
1823        Box::pin(async move {
1824            let first = !std::mem::replace(&mut lock(&self.shared).waited_for_startup, true);
1825            if first {
1826                let waiting = self.pending(has_direct_tools);
1827                if !waiting.is_empty() {
1828                    let finished = tokio::time::timeout(
1829                        STARTUP_WAIT,
1830                        Self::wait_for(waiting, &tokio_util::sync::CancellationToken::new()),
1831                    )
1832                    .await
1833                    .is_ok();
1834                    if !finished {
1835                        ctx.ui.notify(
1836                            "MCP servers are still connecting; their tools become available once connected.",
1837                            NotifyKind::Info,
1838                        );
1839                    }
1840                }
1841            }
1842            let listing: Vec<(ServerEntry, Option<String>)> = lock(&self.shared)
1843                .servers
1844                .iter()
1845                .map(|server| {
1846                    let instructions = server
1847                        .connection
1848                        .as_ref()
1849                        .and_then(|connection| connection.snapshot().instructions);
1850                    (server.entry.clone(), instructions)
1851                })
1852                .collect();
1853            let borrowed: Vec<(&ServerEntry, Option<String>)> = listing
1854                .iter()
1855                .map(|(entry, instructions)| (entry, instructions.clone()))
1856                .collect();
1857            match render_servers_section(&borrowed) {
1858                Some(section) => {
1859                    sections.insert(SERVERS_SECTION.into(), section);
1860                }
1861                None => {
1862                    sections.shift_remove(SERVERS_SECTION);
1863                }
1864            }
1865        })
1866    }
1867
1868    /// A codemode script waits for the servers it names, or for every server
1869    /// when it searches or lists tools, so their tools are registered before it
1870    /// runs. tool_search and the resource tools reach every server.
1871    fn tool_call<'a>(
1872        &'a self,
1873        ctx: &'a Context,
1874        tool: &'a str,
1875        input: &'a Value,
1876    ) -> BoxFuture<'a, Option<String>> {
1877        Box::pin(async move {
1878            if self.pending(|_| true).is_empty() {
1879                return None;
1880            }
1881            let info = ctx.tools.all().into_iter().find(|info| info.name == tool)?;
1882            let pending = if codemode::is_codemode_tool(&info) {
1883                let code = input["code"].as_str().unwrap_or_default();
1884                self.pending(|entry| script_needs_server(code, &entry.name))
1885            } else if is_tool_search(&info)
1886                || [
1887                    LIST_MCP_RESOURCES_TOOL,
1888                    LIST_MCP_RESOURCE_TEMPLATES_TOOL,
1889                    READ_MCP_RESOURCE_TOOL,
1890                ]
1891                .contains(&tool)
1892            {
1893                self.pending(|_| true)
1894            } else {
1895                return None;
1896            };
1897            Self::wait_for(pending, &ctx.cancel).await;
1898            None
1899        })
1900    }
1901
1902    fn session_shutdown<'a>(&'a self, _ctx: &'a Context) -> BoxFuture<'a, ()> {
1903        Box::pin(async move {
1904            let closing: Vec<Arc<Connection>> = {
1905                let mut shared = lock(&self.shared);
1906                shared.generation += 1;
1907                shared.active = false;
1908                std::mem::take(&mut shared.servers)
1909                    .into_iter()
1910                    .filter_map(|server| server.connection)
1911                    .collect()
1912            };
1913            for connection in closing {
1914                connection.close().await;
1915            }
1916        })
1917    }
1918}
1919
1920#[cfg(test)]
1921mod tests {
1922    use std::path::PathBuf;
1923    use std::time::Duration;
1924
1925    use futures_util::future::BoxFuture;
1926    use serde_json::json;
1927    use tokio_util::sync::CancellationToken;
1928    use yapi_agent::{Tool, UpdateSink};
1929    use yapi_types::event::ToolResult;
1930    use yapi_types::message::ToolDeclaration;
1931
1932    use super::*;
1933    use crate::extensions::NoUi;
1934    use crate::tools::registry::ToolRegistry;
1935
1936    #[test]
1937    fn scripts_need_named_servers() {
1938        let code = "await tools.mcp__my_docs__search({q: 1})";
1939        assert!(script_needs_server(code, "my-docs"));
1940        assert!(!script_needs_server(code, "other"));
1941        assert!(script_needs_server(
1942            "const found = await searchTools('x')",
1943            "other"
1944        ));
1945        assert!(script_needs_server("ALL_TOOLS.length", "other"));
1946        assert!(!script_needs_server("describeTools()", "other"));
1947        assert!(!script_needs_server("my_searchTools()", "other"));
1948    }
1949
1950    struct Declared(ToolDeclaration);
1951
1952    impl Tool for Declared {
1953        fn declaration(&self) -> &ToolDeclaration {
1954            &self.0
1955        }
1956
1957        fn execute(
1958            &self,
1959            _call_id: String,
1960            _args: Value,
1961            _cancel: CancellationToken,
1962            _updates: UpdateSink,
1963        ) -> BoxFuture<'_, Result<ToolResult, String>> {
1964            Box::pin(async { Err("unused".into()) })
1965        }
1966    }
1967
1968    fn tool(name: &str, parameters: Value, exposure: Exposure) -> RegisteredTool {
1969        RegisteredTool {
1970            exposure,
1971            ..RegisteredTool::direct(
1972                Arc::new(Declared(ToolDeclaration {
1973                    name: name.into(),
1974                    description: String::new(),
1975                    parameters,
1976                    constrained_sampling: None,
1977                })),
1978                None,
1979                Vec::new(),
1980            )
1981        }
1982    }
1983
1984    /// An extension with servers `docs` and `other` still connecting, a
1985    /// context whose tools hold codemode, tool_search and `named_like_search`,
1986    /// another extension's tool, and the senders that settle the servers.
1987    fn connecting() -> (McpExtension, Context, Vec<watch::Sender<bool>>) {
1988        let extension = McpExtension::new();
1989        let mut senders = Vec::new();
1990        for name in ["docs", "other"] {
1991            let (sender, ready) = watch::channel(false);
1992            let entry = ServerEntry {
1993                name: name.into(),
1994                config: config::validate_server(name, &json!({"command": "true"})).unwrap(),
1995                source: PathBuf::new(),
1996                scope: Scope::Global,
1997            };
1998            let mut server = Server::new(entry, None);
1999            server.ready = Some(ready);
2000            lock(&extension.shared).servers.push(server);
2001            senders.push(sender);
2002        }
2003        let tools = Tools::new(ToolRegistry::default());
2004        tools.register(tool(
2005            codemode::NAME,
2006            codemode::parameters(),
2007            Exposure::ModelOnly,
2008        ));
2009        tools.register(tool(
2010            TOOL_SEARCH_TOOL_NAME,
2011            json!({"type": "object"}),
2012            Exposure::ModelOnly,
2013        ));
2014        tools.register(tool(
2015            "named_like_search",
2016            json!({"type": "object"}),
2017            Exposure::Direct,
2018        ));
2019        let ctx = Context {
2020            cwd: PathBuf::new(),
2021            agent_dir: PathBuf::new(),
2022            project_dir: PathBuf::new(),
2023            project_trusted: false,
2024            project_only_mcp: None,
2025            mode: Mode::Print,
2026            ui: Arc::new(NoUi),
2027            tools,
2028            cancel: CancellationToken::new(),
2029            session: Default::default(),
2030        };
2031        (extension, ctx, senders)
2032    }
2033
2034    /// Whether the call still waits after a moment.
2035    async fn waits(extension: &McpExtension, ctx: &Context, tool: &str, input: Value) -> bool {
2036        tokio::time::timeout(
2037            Duration::from_millis(50),
2038            extension.tool_call(ctx, tool, &input),
2039        )
2040        .await
2041        .is_err()
2042    }
2043
2044    #[tokio::test]
2045    async fn calls_wait_for_servers() {
2046        let (extension, ctx, senders) = connecting();
2047        let script = |code: &str| json!({"code": code});
2048        assert!(waits(&extension, &ctx, "codemode", script("tools.mcp__docs__a()")).await);
2049        assert!(
2050            waits(
2051                &extension,
2052                &ctx,
2053                "codemode",
2054                script("await searchTools('a')")
2055            )
2056            .await
2057        );
2058        assert!(waits(&extension, &ctx, "codemode", script("ALL_TOOLS")).await);
2059        assert!(!waits(&extension, &ctx, "codemode", script("return 1")).await);
2060        assert!(waits(&extension, &ctx, TOOL_SEARCH_TOOL_NAME, json!({})).await);
2061        assert!(!waits(&extension, &ctx, "named_like_search", json!({})).await);
2062        assert!(!waits(&extension, &ctx, "unknown", json!({})).await);
2063        // A server that connected or failed no longer holds a script back.
2064        senders[0].send_replace(true);
2065        assert!(!waits(&extension, &ctx, "codemode", script("tools.mcp__docs__a()")).await);
2066        assert!(waits(&extension, &ctx, "codemode", script("ALL_TOOLS")).await);
2067    }
2068
2069    #[tokio::test]
2070    async fn abort_ends_wait() {
2071        let (extension, ctx, _senders) = connecting();
2072        let input = json!({"code": "ALL_TOOLS"});
2073        let call = extension.tool_call(&ctx, "codemode", &input);
2074        let cancel = ctx.cancel.clone();
2075        let (outcome, ()) = tokio::join!(
2076            tokio::time::timeout(Duration::from_secs(5), call),
2077            async move {
2078                tokio::time::sleep(Duration::from_millis(20)).await;
2079                cancel.cancel();
2080            }
2081        );
2082        assert_eq!(outcome, Ok(None));
2083    }
2084}