Skip to content

Commit 03086d2

Browse files
committed
fix(mcp): handle gemini MCP config changes
1 parent e422961 commit 03086d2

File tree

2 files changed

+315
-5
lines changed

2 files changed

+315
-5
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
- Improved MCP list parsing to detect Gemini CLI entries with checkmarks and ANSI colors.
12+
- Added Gemini MCP settings migration to remove invalid `type` fields and map HTTP URLs.
13+
814
## [0.1.0] - 2025-12-23
915

1016
### Added

src/features/mcp_manager/executor.rs

Lines changed: 309 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
use super::tools::{CliType, McpTool};
22
use crate::core::{OperationError, Result};
3+
use serde_json::Value;
4+
use std::env;
5+
use std::fs;
6+
use std::path::{Path, PathBuf};
37
use std::process::Command;
48

59
/// MCP CLI 執行器
@@ -14,6 +18,7 @@ impl McpExecutor {
1418

1519
/// 取得已安裝的 MCP 清單
1620
pub fn list_installed(&self) -> Result<Vec<String>> {
21+
self.maybe_migrate_gemini_settings()?;
1722
let output = Command::new(self.cli.command())
1823
.args(["mcp", "list"])
1924
.output()
@@ -32,6 +37,7 @@ impl McpExecutor {
3237

3338
/// 安裝 MCP
3439
pub fn install(&self, tool: &McpTool) -> Result<()> {
40+
self.maybe_migrate_gemini_settings()?;
3541
let mut args: Vec<&str> = vec!["mcp", "add"];
3642
let string_refs: Vec<&str> = tool.install_args.iter().map(|s| s.as_str()).collect();
3743
args.extend(string_refs);
@@ -45,6 +51,7 @@ impl McpExecutor {
4551
})?;
4652

4753
if output.status.success() {
54+
self.maybe_migrate_gemini_settings()?;
4855
Ok(())
4956
} else {
5057
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
@@ -57,6 +64,7 @@ impl McpExecutor {
5764

5865
/// 移除 MCP
5966
pub fn remove(&self, name: &str) -> Result<()> {
67+
self.maybe_migrate_gemini_settings()?;
6068
let output = Command::new(self.cli.command())
6169
.args(["mcp", "remove", name])
6270
.output()
@@ -75,29 +83,269 @@ impl McpExecutor {
7583
})
7684
}
7785
}
86+
87+
fn maybe_migrate_gemini_settings(&self) -> Result<()> {
88+
if self.cli != CliType::Gemini {
89+
return Ok(());
90+
}
91+
92+
for path in gemini_settings_paths() {
93+
if !path.exists() {
94+
continue;
95+
}
96+
migrate_gemini_settings_file(&path)?;
97+
}
98+
99+
Ok(())
100+
}
78101
}
79102

80103
/// 解析 mcp list 的輸出
81104
fn parse_mcp_list(output: &str) -> Vec<String> {
82105
let mut names = Vec::new();
83106

84107
for line in output.lines() {
85-
let trimmed = line.trim();
86-
if trimmed.is_empty() || trimmed.starts_with("MCP") || trimmed.starts_with("---") {
108+
let stripped = strip_ansi_codes(line);
109+
let trimmed = stripped.trim();
110+
if trimmed.is_empty() {
111+
continue;
112+
}
113+
114+
let lower = trimmed.to_ascii_lowercase();
115+
if lower.starts_with("mcp ")
116+
|| lower.starts_with("mcp servers")
117+
|| lower.starts_with("configured mcp")
118+
|| lower.starts_with("---")
119+
{
120+
continue;
121+
}
122+
if lower.starts_with("name") && (lower.contains("status") || lower.contains("command")) {
87123
continue;
88124
}
89-
if let Some(name) = trimmed.split_whitespace().next() {
125+
126+
for token in trimmed.split_whitespace() {
90127
let clean_name =
91-
name.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
92-
if !clean_name.is_empty() {
128+
token.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
129+
if clean_name.is_empty() {
130+
continue;
131+
}
132+
133+
let clean_lower = clean_name.to_ascii_lowercase();
134+
if is_ignored_token(&clean_lower) {
135+
continue;
136+
}
137+
138+
if !names.iter().any(|name| name == clean_name) {
93139
names.push(clean_name.to_string());
94140
}
141+
break;
95142
}
96143
}
97144

98145
names
99146
}
100147

148+
fn gemini_settings_paths() -> Vec<PathBuf> {
149+
let mut paths = Vec::new();
150+
151+
if let Ok(cwd) = env::current_dir() {
152+
paths.push(cwd.join(".gemini").join("settings.json"));
153+
}
154+
155+
if let Ok(home) = env::var("HOME") {
156+
let home_path = PathBuf::from(home);
157+
paths.push(home_path.join(".gemini").join("settings.json"));
158+
paths.push(home_path.join(".config").join("gemini").join("settings.json"));
159+
paths.push(home_path.join(".config").join("gemini-cli").join("settings.json"));
160+
}
161+
162+
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
163+
let xdg_path = PathBuf::from(xdg);
164+
paths.push(xdg_path.join("gemini").join("settings.json"));
165+
paths.push(xdg_path.join("gemini-cli").join("settings.json"));
166+
}
167+
168+
let mut unique = Vec::new();
169+
for path in paths {
170+
if !unique.contains(&path) {
171+
unique.push(path);
172+
}
173+
}
174+
175+
unique
176+
}
177+
178+
fn migrate_gemini_settings_file(path: &Path) -> Result<bool> {
179+
let raw = fs::read_to_string(path).map_err(|err| OperationError::Io {
180+
path: path.display().to_string(),
181+
source: err,
182+
})?;
183+
184+
if !raw.contains("\"type\"") {
185+
return Ok(false);
186+
}
187+
188+
let sanitized = strip_json_comments(&raw);
189+
let mut root: Value = serde_json::from_str(&sanitized).map_err(|err| {
190+
OperationError::Config {
191+
key: path.display().to_string(),
192+
message: format!("設定檔解析失敗: {}", err),
193+
}
194+
})?;
195+
196+
let changed = migrate_gemini_mcp_servers(&mut root);
197+
if changed {
198+
let formatted = serde_json::to_string_pretty(&root).map_err(|err| {
199+
OperationError::Config {
200+
key: path.display().to_string(),
201+
message: format!("設定檔序列化失敗: {}", err),
202+
}
203+
})?;
204+
fs::write(path, format!("{}\n", formatted)).map_err(|err| OperationError::Io {
205+
path: path.display().to_string(),
206+
source: err,
207+
})?;
208+
}
209+
210+
Ok(changed)
211+
}
212+
213+
fn migrate_gemini_mcp_servers(root: &mut Value) -> bool {
214+
let Some(servers) = root
215+
.get_mut("mcpServers")
216+
.and_then(|value| value.as_object_mut())
217+
else {
218+
return false;
219+
};
220+
221+
let mut changed = false;
222+
223+
for server in servers.values_mut() {
224+
let Some(server_obj) = server.as_object_mut() else {
225+
continue;
226+
};
227+
228+
let transport = match server_obj.remove("type") {
229+
Some(value) => {
230+
changed = true;
231+
value.as_str().unwrap_or("").to_ascii_lowercase()
232+
}
233+
None => continue,
234+
};
235+
236+
if transport == "http" {
237+
if server_obj.get("httpUrl").is_none() {
238+
if let Some(url_value) = server_obj.remove("url") {
239+
server_obj.insert("httpUrl".to_string(), url_value);
240+
changed = true;
241+
}
242+
}
243+
if server_obj.get("httpUrl").is_some() {
244+
server_obj.remove("url");
245+
}
246+
}
247+
}
248+
249+
changed
250+
}
251+
252+
fn is_ignored_token(token: &str) -> bool {
253+
matches!(
254+
token,
255+
"mcp"
256+
| "server"
257+
| "servers"
258+
| "name"
259+
| "status"
260+
| "command"
261+
| "configured"
262+
| "enabled"
263+
| "disabled"
264+
| "running"
265+
| "stopped"
266+
| "connected"
267+
)
268+
}
269+
270+
fn strip_ansi_codes(input: &str) -> String {
271+
let mut output = String::with_capacity(input.len());
272+
let mut chars = input.chars().peekable();
273+
274+
while let Some(ch) = chars.next() {
275+
if ch == '\u{1b}' {
276+
if chars.peek().copied() == Some('[') {
277+
chars.next();
278+
while let Some(code_ch) = chars.next() {
279+
if code_ch.is_ascii_alphabetic() {
280+
break;
281+
}
282+
}
283+
continue;
284+
}
285+
}
286+
output.push(ch);
287+
}
288+
289+
output
290+
}
291+
292+
fn strip_json_comments(input: &str) -> String {
293+
let mut output = String::with_capacity(input.len());
294+
let mut chars = input.chars().peekable();
295+
let mut in_string = false;
296+
let mut escaped = false;
297+
298+
while let Some(ch) = chars.next() {
299+
if in_string {
300+
if escaped {
301+
escaped = false;
302+
} else if ch == '\\' {
303+
escaped = true;
304+
} else if ch == '"' {
305+
in_string = false;
306+
}
307+
output.push(ch);
308+
continue;
309+
}
310+
311+
if ch == '"' {
312+
in_string = true;
313+
output.push(ch);
314+
continue;
315+
}
316+
317+
if ch == '/' {
318+
match chars.peek() {
319+
Some('/') => {
320+
chars.next();
321+
while let Some(next) = chars.next() {
322+
if next == '\n' {
323+
output.push('\n');
324+
break;
325+
}
326+
}
327+
continue;
328+
}
329+
Some('*') => {
330+
chars.next();
331+
while let Some(next) = chars.next() {
332+
if next == '*' && matches!(chars.peek(), Some('/')) {
333+
chars.next();
334+
break;
335+
}
336+
}
337+
continue;
338+
}
339+
_ => {}
340+
}
341+
}
342+
343+
output.push(ch);
344+
}
345+
346+
output
347+
}
348+
101349
#[cfg(test)]
102350
mod tests {
103351
use super::*;
@@ -116,4 +364,60 @@ mod tests {
116364
assert!(result.contains(&"sequential-thinking".to_string()));
117365
assert!(result.contains(&"context7".to_string()));
118366
}
367+
368+
#[test]
369+
fn test_parse_mcp_list_with_checkmark_prefix() {
370+
let output = concat!(
371+
"Configured MCP servers:\n",
372+
"\u{2713} sequential-thinking: npx -y tool (stdio) - Connected"
373+
);
374+
let result = parse_mcp_list(output);
375+
assert_eq!(result, vec!["sequential-thinking".to_string()]);
376+
}
377+
378+
#[test]
379+
fn test_parse_mcp_list_with_ansi_colors() {
380+
let output = concat!(
381+
"Configured MCP servers:\n",
382+
"\u{1b}[32m\u{2713}\u{1b}[0m sequential-thinking: npx -y tool (stdio) - Connected"
383+
);
384+
let result = parse_mcp_list(output);
385+
assert_eq!(result, vec!["sequential-thinking".to_string()]);
386+
}
387+
388+
#[test]
389+
fn test_migrate_gemini_settings_http_type() {
390+
let dir = tempfile::tempdir().unwrap();
391+
let path = dir.path().join("settings.json");
392+
let content = r#"{"mcpServers":{"context7":{"url":"https://example.com","type":"http"}}}"#;
393+
394+
fs::write(&path, content).unwrap();
395+
396+
let changed = migrate_gemini_settings_file(&path).unwrap();
397+
assert!(changed);
398+
399+
let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
400+
let server = &value["mcpServers"]["context7"];
401+
assert_eq!(server["httpUrl"], "https://example.com");
402+
assert!(server.get("url").is_none());
403+
assert!(server.get("type").is_none());
404+
}
405+
406+
#[test]
407+
fn test_migrate_gemini_settings_sse_type() {
408+
let dir = tempfile::tempdir().unwrap();
409+
let path = dir.path().join("settings.json");
410+
let content = r#"{"mcpServers":{"context7":{"url":"https://example.com","type":"sse"}}}"#;
411+
412+
fs::write(&path, content).unwrap();
413+
414+
let changed = migrate_gemini_settings_file(&path).unwrap();
415+
assert!(changed);
416+
417+
let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
418+
let server = &value["mcpServers"]["context7"];
419+
assert_eq!(server["url"], "https://example.com");
420+
assert!(server.get("httpUrl").is_none());
421+
assert!(server.get("type").is_none());
422+
}
119423
}

0 commit comments

Comments
 (0)