Skip to main content

vtcode_core/mcp/
plugin_providers.rs

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