Skip to main content

vtcode_mcp/client/
status.rs

1use super::*;
2
3impl McpClient {
4    /// Current status snapshot for UI/debugging purposes.
5    pub fn get_status(&self) -> McpClientStatus {
6        let state = self.state.read();
7        let providers = &state.providers;
8        // Use iterator to collect keys directly without intermediate push
9        let mut configured_providers: Vec<String> = providers.keys().cloned().collect();
10        configured_providers.sort_unstable();
11        McpClientStatus {
12            enabled: self.config.enabled,
13            provider_count: providers.len(),
14            active_connections: providers.len(),
15            configured_providers,
16        }
17    }
18
19    /// Return configured MCP servers and their current connection state.
20    ///
21    /// Connected servers additionally report the protocol version settled
22    /// during the handshake (`negotiated_protocol_version`), which may differ
23    /// from the configured version when the server negotiates down.
24    pub async fn list_servers(&self) -> Vec<Value> {
25        let live: Vec<(McpProviderConfig, Option<Arc<McpProvider>>)> = {
26            let state = self.state.read();
27            self.config
28                .providers
29                .iter()
30                .map(|provider_config| (provider_config.clone(), state.providers.get(&provider_config.name).cloned()))
31                .collect()
32        };
33        let mut servers = Vec::with_capacity(live.len());
34        for (provider_config, provider) in live {
35            let negotiated = match &provider {
36                Some(provider) => provider.negotiated_protocol_version().await,
37                None => None,
38            };
39            let connected = provider.is_some();
40            let (transport, target) = match &provider_config.transport {
41                McpTransportConfig::Stdio(stdio) => ("stdio", Value::String(stdio.command.clone())),
42                McpTransportConfig::Http(http) => ("http", Value::String(http.endpoint.clone())),
43            };
44
45            servers.push(json!({
46                "name": provider_config.name,
47                "enabled": provider_config.enabled,
48                "connected": connected,
49                "connection_state": if connected { "connected" } else { "disconnected" },
50                "transport": transport,
51                "target": target,
52                "negotiated_protocol_version": negotiated.map(Value::String).unwrap_or(Value::Null),
53            }));
54        }
55        servers
56    }
57
58    /// Return whether model-callable lifecycle tools are enabled by config.
59    pub fn allow_model_lifecycle_control(&self) -> bool {
60        self.config.lifecycle.allow_model_control
61    }
62
63    /// Connect one configured MCP server by name.
64    pub async fn connect_server(&self, server_name: &str) -> Result<()> {
65        if !self.config.enabled {
66            bail!("MCP support is disabled in the current configuration");
67        }
68        reject_unsupported_ui_apps_tier(&self.config.ui)?;
69
70        if self.state.read().providers.contains_key(server_name) {
71            return Ok(());
72        }
73
74        let provider_config = self
75            .config
76            .providers
77            .iter()
78            .find(|provider| provider.name == server_name)
79            .cloned()
80            .ok_or_else(|| anyhow!("MCP server '{server_name}' is not configured"))?;
81
82        if !provider_config.enabled {
83            bail!("MCP server '{server_name}' is configured but disabled");
84        }
85
86        if let Some(reason) = self.requirement_mismatch_reason(&provider_config) {
87            bail!("Cannot connect MCP server '{}': {}", provider_config.name, reason);
88        }
89
90        if matches!(provider_config.transport, McpTransportConfig::Http(_)) && !self.config.experimental_use_rmcp_client
91        {
92            bail!(
93                "Cannot connect MCP HTTP server '{}' while experimental_use_rmcp_client is disabled",
94                provider_config.name
95            );
96        }
97
98        let allowlist_snapshot = self.state.read().allowlist.clone();
99        let tool_timeout = self.tool_timeout();
100        let provider = self
101            .connect_and_initialize_provider(&provider_config, &allowlist_snapshot, tool_timeout)
102            .await?;
103
104        if let Err(err) = provider.cached_tools_or_refresh_shared(&allowlist_snapshot, tool_timeout).await {
105            warn!("Connected MCP server '{}' but failed to refresh tools: {err}", server_name);
106        } else if let Some(cache) = provider.cached_tools_shared().await {
107            self.record_tool_provider(&provider.name, &cache);
108        }
109
110        drop(self.state.write().providers.insert(provider.name.clone(), Arc::new(provider)));
111        Ok(())
112    }
113
114    /// Disconnect one active MCP server by name.
115    pub async fn disconnect_server(&self, server_name: &str) -> Result<()> {
116        let provider = {
117            let mut state = self.state.write();
118            let provider = state
119                .providers
120                .remove(server_name)
121                .ok_or_else(|| anyhow!("MCP server '{server_name}' is not connected"))?;
122            state
123                .tool_provider_index
124                .retain(|_, provider_name| provider_name != server_name);
125            state
126                .resource_provider_index
127                .retain(|_, provider_name| provider_name != server_name);
128            state
129                .prompt_provider_index
130                .retain(|_, provider_name| provider_name != server_name);
131            provider
132        };
133
134        provider.shutdown().await?;
135        Ok(())
136    }
137
138    /// Sync MCP tool descriptions to files for dynamic context discovery
139    ///
140    /// This implements Cursor-style dynamic context discovery:
141    /// - Tool descriptions are written to `.vtcode/mcp/tools/{provider}/{tool}.md`
142    /// - Status is written to `.vtcode/mcp/status.json`
143    /// - Agents can discover tools via grep/read_file without loading all schemas
144    ///
145    /// Returns the paths to written files (index path, tool count)
146    pub async fn sync_tools_to_files(&self, workspace_root: &Path) -> Result<(PathBuf, usize)> {
147        let tools = self.list_tools().await?;
148        let mcp_dir = workspace_root.join(".vtcode").join("mcp");
149        let tools_dir = mcp_dir.join("tools");
150
151        // Create directories
152        ensure_dir_exists(&tools_dir)
153            .await
154            .with_context(|| format!("Failed to create MCP tools directory: {}", tools_dir.display()))?;
155
156        // Group tools by provider
157        let mut by_provider: BTreeMap<String, Vec<&McpToolInfo>> = BTreeMap::new();
158        for tool in &tools {
159            by_provider.entry(tool.provider.clone()).or_default().push(tool);
160        }
161
162        // Write tool files per provider
163        for (provider, provider_tools) in &by_provider {
164            let provider_dir = tools_dir.join(sanitize_filename(provider));
165            ensure_dir_exists(&provider_dir)
166                .await
167                .with_context(|| format!("Failed to create provider directory: {}", provider_dir.display()))?;
168
169            for tool in provider_tools {
170                let tool_content = format_tool_markdown(tool);
171                let tool_path = provider_dir.join(format!("{}.md", sanitize_filename(&tool.name)));
172                write_file_with_context(&tool_path, &tool_content, "MCP tool file")
173                    .await
174                    .with_context(|| format!("Failed to write tool file: {}", tool_path.display()))?;
175            }
176        }
177
178        // Write index file
179        let index_content = self.generate_tools_index(&tools, &by_provider);
180        let index_path = tools_dir.join("INDEX.md");
181        write_file_with_context(&index_path, &index_content, "MCP tools index")
182            .await
183            .with_context(|| format!("Failed to write MCP tools index: {}", index_path.display()))?;
184
185        // Write status file
186        let status = self.generate_status_json();
187        let status_path = mcp_dir.join("status.json");
188        let status_json = serde_json::to_string_pretty(&status)?;
189        write_file_with_context(&status_path, &status_json, "MCP status")
190            .await
191            .with_context(|| format!("Failed to write MCP status: {}", status_path.display()))?;
192
193        info!(
194            tools = tools.len(),
195            providers = by_provider.len(),
196            index = %index_path.display(),
197            "Synced MCP tool descriptions to files"
198        );
199
200        Ok((index_path, tools.len()))
201    }
202
203    /// Generate INDEX.md content for MCP tools
204    fn generate_tools_index(&self, tools: &[McpToolInfo], by_provider: &BTreeMap<String, Vec<&McpToolInfo>>) -> String {
205        let mut content = String::new();
206        content.push_str("# MCP Tools Index\n\n");
207        content.push_str("This file lists all available MCP tools for dynamic discovery.\n");
208        content.push_str("Use `read_file` on individual tool files for full schema details.\n\n");
209
210        if tools.is_empty() {
211            content.push_str("*No MCP tools available.*\n\n");
212            content.push_str("Configure MCP servers in `vtcode.toml` or `.mcp.json`.\n");
213        } else {
214            content.push_str(&format!("**Total Tools**: {}\n\n", tools.len()));
215
216            // Summary table
217            content.push_str("## Quick Reference\n\n");
218            content.push_str("| Provider | Tool | Description |\n");
219            content.push_str("|----------|------|-------------|\n");
220
221            for tool in tools {
222                let desc = tool.description.lines().next().unwrap_or(&tool.description);
223                let desc_truncated = vtcode_commons::formatting::truncate_byte_budget(desc, 57, "...");
224                content.push_str(&format!(
225                    "| {} | `{}` | {} |\n",
226                    tool.provider,
227                    tool.name,
228                    desc_truncated.replace('|', "\\|")
229                ));
230            }
231
232            // Per-provider sections
233            content.push_str("\n## Tools by Provider\n\n");
234            for (provider, provider_tools) in by_provider {
235                content.push_str(&format!("### {provider}\n\n"));
236                for tool in provider_tools {
237                    content.push_str(&format!(
238                        "- **{}**: {}\n  - Path: `.vtcode/mcp/tools/{}/{}.md`\n",
239                        tool.name,
240                        tool.description.lines().next().unwrap_or(&tool.description),
241                        sanitize_filename(provider),
242                        sanitize_filename(&tool.name)
243                    ));
244                }
245                content.push('\n');
246            }
247        }
248
249        content.push_str("\n---\n");
250        content.push_str("*Generated automatically. Do not edit manually.*\n");
251
252        content
253    }
254
255    /// Generate status.json content
256    fn generate_status_json(&self) -> Value {
257        let status = self.get_status();
258        json!({
259            "enabled": status.enabled,
260            "provider_count": status.provider_count,
261            "active_connections": status.active_connections,
262            "configured_providers": status.configured_providers,
263            "last_updated": Utc::now().to_rfc3339(),
264        })
265    }
266}