11use super :: tools:: { CliType , McpTool } ;
22use crate :: core:: { OperationError , Result } ;
3+ use serde_json:: Value ;
4+ use std:: env;
5+ use std:: fs;
6+ use std:: path:: { Path , PathBuf } ;
37use 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 的輸出
81104fn 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) ]
102350mod 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