Skip to main content

vtcode_core/mcp/
plugin_providers.rs

1use hashbrown::HashMap;
2use std::path::{Path, PathBuf};
3use tracing::warn;
4
5use vtcode_agent_plugins::{LoadedPlugin, plugin_roots_for};
6use vtcode_config::mcp::McpProviderConfig;
7
8/// Discover plugin MCP servers from the workspace and user plugin roots.
9pub fn discover_plugin_mcp_providers(workspace_root: &Path) -> Vec<McpProviderConfig> {
10    discover_plugin_mcp_providers_in_roots(&plugin_roots_for(workspace_root))
11}
12
13/// Discover plugin MCP servers from explicit plugin roots.
14///
15/// Roots are visited in order with each root scanned at most once. Callers that
16/// already resolved roots pass them here instead of scanning the host home
17/// directory again through [`discover_plugin_mcp_providers`].
18pub(crate) fn discover_plugin_mcp_providers_in_roots(roots: &[PathBuf]) -> Vec<McpProviderConfig> {
19    let mut providers = Vec::new();
20
21    // Deduplicate roots so a workspace under the home directory does not cause
22    // the same plugin root to be scanned twice.
23    let mut seen = std::collections::HashSet::new();
24    let mut unique_roots = Vec::new();
25    for root in roots {
26        if seen.insert(root.clone()) {
27            unique_roots.push(root.clone());
28        }
29    }
30
31    for root in unique_roots {
32        if !root.is_dir() {
33            continue;
34        }
35        let entries = match std::fs::read_dir(&root) {
36            Ok(entries) => entries,
37            Err(e) => {
38                tracing::debug!(root = %root.display(), error = %e, "failed to read plugin root directory");
39                continue;
40            }
41        };
42        for entry in entries {
43            let Ok(entry) = entry else { continue };
44            let path = entry.path();
45            if !path.is_dir() {
46                continue;
47            }
48            match LoadedPlugin::load_from_dir(&path) {
49                Ok(loaded) => {
50                    if let Some(mcp_config) = loaded.mcp {
51                        for (server_name, server) in mcp_config.servers {
52                            match map_server_to_provider(&loaded.manifest.name, &server_name, server, &loaded.root) {
53                                Ok(provider) => providers.push(provider),
54                                Err(e) => {
55                                    warn!(plugin = %loaded.manifest.name, server = %server_name, error = %e, "skipping plugin MCP server")
56                                }
57                            }
58                        }
59                    }
60                }
61                Err(e) => {
62                    warn!(root = %path.display(), error = %e, "failed to load agent plugin for MCP discovery");
63                }
64            }
65        }
66    }
67
68    providers
69}
70
71fn map_server_to_provider(
72    plugin_name: &str,
73    server_name: &str,
74    server: vtcode_agent_plugins::ServerConfig,
75    plugin_root: &Path,
76) -> Result<McpProviderConfig, vtcode_agent_plugins::PluginError> {
77    let provider_name = format!("{}.{}", plugin_name, server_name);
78
79    match server {
80        vtcode_agent_plugins::ServerConfig::Stdio(stdio) => {
81            // §9.1: dedicated writable PLUGIN_DATA outside the package so it
82            // survives plugin updates. Fail closed when it cannot be created:
83            // continuing with a missing base would let `${PLUGIN_DATA}` cwds
84            // resolve against a nonexistent dir (see expansion.rs).
85            let plugin_data = vtcode_agent_plugins::default_plugin_data_dir(plugin_name);
86            if let Err(e) = std::fs::create_dir_all(&plugin_data) {
87                return Err(vtcode_agent_plugins::PluginError::InvalidMcp(format!(
88                    "server '{server_name}' cannot use plugin data dir {}: {e}",
89                    plugin_data.display()
90                )));
91            }
92
93            // Defense in depth: parse already rejects reserved keys, but a
94            // manually constructed config must still be invalid.
95            for key in stdio.env.keys() {
96                if vtcode_agent_plugins::is_reserved_env_key(key) {
97                    return Err(vtcode_agent_plugins::PluginError::InvalidMcp(format!(
98                        "server '{server_name}' env must not set reserved '{key}'"
99                    )));
100                }
101            }
102            if let Err(reason) = vtcode_agent_plugins::validate_command_token(&stdio.command) {
103                return Err(vtcode_agent_plugins::PluginError::InvalidMcp(format!(
104                    "server '{server_name}' has invalid command: {reason}"
105                )));
106            }
107            if let Some(ref raw_cwd) = stdio.cwd {
108                if let Err(reason) = vtcode_agent_plugins::validate_cwd_form(raw_cwd) {
109                    return Err(vtcode_agent_plugins::PluginError::InvalidMcp(format!(
110                        "server '{server_name}' has invalid cwd: {reason}"
111                    )));
112                }
113            }
114
115            // Resolve plugin-relative commands eagerly so the spawn uses the
116            // canonical absolute path. Passing the raw "./bin/server" would let
117            // the OS re-resolve it at spawn time, defeating the containment
118            // check (e.g. a symlink that points outside the plugin root).
119            let command = if stdio.command.starts_with("./") {
120                vtcode_agent_plugins::validate_plugin_relative(&stdio.command, plugin_root)
121                    .map_err(|e| vtcode_agent_plugins::PluginError::PathEscape(e.to_string()))?
122                    .to_string_lossy()
123                    .to_string()
124            } else {
125                stdio.command
126            };
127
128            // Filesystem-resolved roots for expansion and subprocess env.
129            let canonical_root =
130                vtcode_commons::canonicalize(plugin_root).unwrap_or_else(|_| plugin_root.to_path_buf());
131            let canonical_data = vtcode_commons::canonicalize(&plugin_data).unwrap_or_else(|_| plugin_data.clone());
132
133            let mut env = stdio
134                .env
135                .into_iter()
136                .map(|(k, v)| (k, vtcode_agent_plugins::expand_placeholders(&v, &canonical_root, &canonical_data)))
137                .collect::<HashMap<String, String>>();
138            // §9.1: overlay configured env on the client base, then set
139            // reserved vars last, replacing equivalents per platform semantics.
140            #[cfg(windows)]
141            {
142                env.retain(|k, _| k.to_ascii_lowercase() != "plugin_root" && k.to_ascii_lowercase() != "plugin_data");
143            }
144            env.insert("PLUGIN_ROOT".into(), canonical_root.to_string_lossy().to_string());
145            env.insert("PLUGIN_DATA".into(), canonical_data.to_string_lossy().to_string());
146
147            let resolved_cwd =
148                vtcode_agent_plugins::resolve_cwd(stdio.cwd.as_deref(), &canonical_root, &canonical_data)
149                    .map_err(|e| vtcode_agent_plugins::PluginError::PathEscape(e.to_string()))?;
150            let cwd = resolved_cwd.to_string_lossy().to_string();
151
152            let args = stdio
153                .args
154                .iter()
155                .map(|a| vtcode_agent_plugins::expand_placeholders(a, &canonical_root, &canonical_data))
156                .collect();
157
158            Ok(McpProviderConfig {
159                name: provider_name,
160                transport: vtcode_config::mcp::McpTransportConfig::Stdio(vtcode_config::mcp::McpStdioServerConfig {
161                    command,
162                    args,
163                    working_directory: Some(cwd),
164                }),
165                env,
166                ..McpProviderConfig::default()
167            })
168        }
169        vtcode_agent_plugins::ServerConfig::StreamableHttp(http) => Ok(McpProviderConfig {
170            name: provider_name,
171            transport: vtcode_config::mcp::McpTransportConfig::Http(vtcode_config::mcp::McpHttpServerConfig {
172                endpoint: http.url,
173                api_key_env: None,
174                oauth: None,
175                protocol_version: vtcode_config::mcp::MCP_STABLE_PROTOCOL_VERSION.into(),
176                handshake: vtcode_config::mcp::McpHttpHandshakeMode::Legacy,
177                http_headers: http.headers.into_iter().collect(),
178                env_http_headers: HashMap::new(),
179            }),
180            ..McpProviderConfig::default()
181        }),
182        vtcode_agent_plugins::ServerConfig::Sse(_) => {
183            Err(vtcode_agent_plugins::PluginError::InvalidMcp("SSE transport is not supported by VT Code".into()))
184        }
185    }
186}