11"""Copilot MCP client implementation."""
22
3+ import json
4+ from pathlib import Path
5+ from typing import Dict
36from .base_client import MCPClient
7+ from .base import print_squared_frame
48
59
610class CopilotMCPClient (MCPClient ):
@@ -9,15 +13,319 @@ class CopilotMCPClient(MCPClient):
913 def __init__ (self ):
1014 super ().__init__ ("copilot" )
1115
16+ def is_server_installed (self , tool_name : str , server_name : str ) -> bool :
17+ """Check if a server is installed by reading Copilot mcp-config.json files."""
18+ config_locations = self ._get_config_locations (tool_name )
19+ for config_path in config_locations :
20+ if config_path .exists ():
21+ try :
22+ with open (config_path , 'r' ) as f :
23+ config = json .load (f )
24+
25+ # Check for mcpServers in Copilot config
26+ if "mcpServers" in config and isinstance (config ["mcpServers" ], dict ):
27+ if server_name in config ["mcpServers" ]:
28+ return True
29+ except Exception :
30+ continue
31+ return False
32+
33+ def add_server (self , server_name : str , scope : str = "user" ) -> bool :
34+ """Add a server by directly editing Copilot mcp-config.json files based on scope."""
35+ return self ._fallback_add_server_scoped (server_name , scope )
36+
37+ def _fallback_add_server_scoped (self , server_name : str , scope : str ) -> bool :
38+ """Add a server to Copilot config files based on scope."""
39+ # Get server configuration - try registry first, then legacy
40+ server_info = self .get_server_config (server_name )
41+ if not server_info :
42+ print (f" No server configuration found for { server_name } " )
43+ return False
44+
45+ # Convert to Copilot format
46+ copilot_server_info = self ._convert_to_copilot_format (server_info )
47+
48+ # Determine target locations based on scope
49+ config_locations = self ._get_config_locations (self .tool_name )
50+ if scope == "user" :
51+ target_locations = [config_locations [0 ]] # User-level only
52+ elif scope == "project" :
53+ # Project shared and personal (skip user)
54+ target_locations = config_locations [1 :]
55+ else :
56+ target_locations = config_locations
57+
58+ for config_path in target_locations :
59+ if self ._add_server_to_config (config_path , server_name , copilot_server_info ):
60+ level = "user-level" if config_path == config_locations [0 ] else "project-level"
61+ print (f" Added { server_name } to { level } configuration" )
62+ return True
63+
64+ return False
65+
66+ def _convert_server_config_to_client_format (self , server_config ) -> Dict :
67+ """
68+ Override to provide Copilot-specific server config conversion.
69+
70+ Args:
71+ server_config: STDIOServerConfig or RemoteServerConfig object
72+
73+ Returns:
74+ Dict in Copilot configuration format
75+ """
76+ if hasattr (server_config , 'url' ) and server_config .url :
77+ # Remote server (shouldn't happen for memory server, but handle it)
78+ config = {
79+ "type" : "http" ,
80+ "url" : server_config .url ,
81+ }
82+ if hasattr (server_config , 'headers' ) and server_config .headers :
83+ config ["headers" ] = server_config .headers
84+ return config
85+ else :
86+ # STDIO server - convert to Copilot format
87+ # Get the basic server info first
88+ server_info = {
89+ "command" : getattr (server_config , 'command' , 'echo' ),
90+ "args" : getattr (server_config , 'args' , []),
91+ "env" : getattr (server_config , 'env' , {}),
92+ }
93+
94+ # Use the Copilot-specific conversion
95+ result = self ._convert_to_copilot_format (server_info )
96+ return result
97+
98+ def remove_server (self , server_name : str , scope : str = "user" ) -> bool :
99+ """Remove a server by directly editing Copilot mcp-config.json files based on scope."""
100+ config_locations = self ._get_config_locations (self .tool_name )
101+
102+ if scope == "user" :
103+ target_locations = [config_locations [0 ]] # User-level only
104+ elif scope == "project" :
105+ # Project shared and personal (skip user)
106+ target_locations = config_locations [1 :]
107+ else :
108+ target_locations = config_locations
109+
110+ success = False
111+ for config_path in target_locations :
112+ if self ._remove_server_from_config (config_path , server_name ):
113+ level = "user-level" if config_path == config_locations [0 ] else "project-level"
114+ print (f" Removed { server_name } from { level } configuration" )
115+ success = True
116+ break # Remove from first found location
117+
118+ if not success :
119+ level = "user-level" if scope == "user" else "project-level" if scope == "project" else "configuration"
120+ print (f" { server_name } not found in { level } configuration" )
121+
122+ return success
123+
124+ def add_all_servers (self , scope : str = "user" ) -> bool :
125+ """Add all MCP servers for this tool based on scope."""
126+ tool_configs = self .get_tool_config (self .tool_name , scope )
127+ if not tool_configs :
128+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , f"No MCP server configurations found for { self .tool_name } " )
129+ return False
130+
131+ # Print initial frame for adding operation
132+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , f"Adding MCP servers for { self .tool_name } ..." )
133+
134+ if scope == "user" :
135+ target_locations = [self ._get_config_locations (self .tool_name )[0 ]] # User-level only
136+ elif scope == "project" :
137+ target_locations = self ._get_config_locations (self .tool_name )[1 :] # Project only
138+ else :
139+ target_locations = self ._get_config_locations (self .tool_name )
140+
141+ success_count = 0
142+ for server_name in tool_configs .keys ():
143+ server_info = self .get_server_config (server_name )
144+ if not server_info :
145+ print (f" No server configuration found for { server_name } " )
146+ continue
147+
148+ # Convert to Copilot format
149+ copilot_server_info = self ._convert_to_copilot_format (server_info )
150+
151+ added = False
152+ for config_path in target_locations :
153+ if self ._add_server_to_config (config_path , server_name , copilot_server_info ):
154+ level = "user-level" if config_path == target_locations [0 ] else "project-level"
155+ print (f" Added { server_name } to { level } configuration" )
156+ added = True
157+ success_count += 1
158+ break # Add to first available location
159+ if not added :
160+ print (f" ✗ Failed to add { server_name } " )
161+
162+ # Print success frame
163+ if success_count > 0 :
164+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , f"✓ Successfully added { success_count } MCP servers for { self .tool_name } " )
165+ else :
166+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , f"✗ Failed to add any MCP servers for { self .tool_name } " )
167+
168+ return success_count > 0
169+
170+ def _convert_to_copilot_format (self , server_info : dict ) -> dict :
171+ """Convert global server config to Copilot mcpServers format."""
172+ # Start with the stdio format conversion
173+ stdio_format = self ._convert_server_to_stdio_format (server_info )
174+
175+ # Convert Copilot-specific format
176+ copilot_format = {
177+ "type" : "local" , # Copilot uses "local" instead of "stdio"
178+ "command" : stdio_format .get ("command" , "echo" ),
179+ "args" : stdio_format .get ("args" , []),
180+ "tools" : ["*" ] # Copilot requires tools specification
181+ }
182+
183+ # Include env if present
184+ if "env" in stdio_format and stdio_format ["env" ]:
185+ copilot_format ["env" ] = stdio_format ["env" ]
186+
187+ return copilot_format
188+
189+ def list_servers (self , scope : str = "all" ) -> bool :
190+ """List servers by reading Copilot mcp-config.json files."""
191+ tool_configs = self .get_tool_config (self .tool_name )
192+ if not tool_configs :
193+ print (f"No MCP server configurations found for { self .tool_name } " )
194+ return False
195+
196+ config_locations = self ._get_config_locations (self .tool_name )
197+ user_servers = {}
198+ project_servers = {}
199+
200+ for i , config_path in enumerate (config_locations ):
201+ if config_path .exists ():
202+ try :
203+ with open (config_path , 'r' ) as f :
204+ config = json .load (f )
205+
206+ if "mcpServers" in config and isinstance (config ["mcpServers" ], dict ):
207+ if i == 0 : # user-level
208+ user_servers .update (config ["mcpServers" ])
209+ else : # project-level
210+ project_servers .update (config ["mcpServers" ])
211+
212+ except Exception as e :
213+ print (f"Warning: Failed to read { config_path } : { e } " )
214+ continue
215+
216+ content_lines = []
217+
218+ show_user = scope in ["all" , "user" ]
219+ show_project = scope in ["all" , "project" ]
220+
221+ if show_user and user_servers :
222+ content_lines .append ("User-level servers:" )
223+ for name , config in user_servers .items ():
224+ content_lines .append (f" { name } : { config } " )
225+ if show_project and project_servers :
226+ content_lines .append ("" )
227+
228+ if show_project and project_servers :
229+ content_lines .append ("Project-level servers:" )
230+ for name , config in project_servers .items ():
231+ content_lines .append (f" { name } : { config } " )
232+
233+ servers_to_show = (show_user and user_servers ) or (show_project and project_servers )
234+
235+ if servers_to_show :
236+ content = "\n " .join (content_lines )
237+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , content )
238+ return True
239+ else :
240+ level_desc = "" if scope == "all" else "user-level" if scope == "user" else "project-level" if scope == "project" else "configuration"
241+ if level_desc :
242+ content = f"No MCP servers configured in { level_desc } configuration"
243+ else :
244+ content = "No MCP servers configured"
245+ print_squared_frame (f"{ self .tool_name .upper ()} MCP SERVERS" , content )
246+ return True
247+
12248 def _get_config_locations (self , tool_name : str ):
13249 """Override to provide Copilot-specific config locations."""
14- locations = super ()._get_config_locations (tool_name )
15- # Add Copilot-specific locations at the beginning
16- from pathlib import Path
17250 home = Path .home ()
18- copilot_locations = [
19- home / ".copilot" / "mcp.json" ,
20- home / ".config" / "GitHub" / "Copilot" / "mcp.json" ,
251+ # Copilot uses mcp-config.json at user level only
252+ locations = [
253+ home / ".copilot" / "mcp-config.json" , # User-level (primary)
254+ Path .cwd () / ".mcp.json" , # Project-level
21255 ]
22- locations [:0 ] = copilot_locations # Add to beginning
23256 return locations
257+
258+ def _get_config_paths (self , scope : str ):
259+ """Override to provide Copilot-specific config paths for scope-based operations."""
260+ home = Path .home ()
261+ if scope == "user" :
262+ return [home / ".copilot" / "mcp-config.json" ]
263+ elif scope == "project" :
264+ return [Path .cwd () / ".mcp.json" ]
265+ else : # all
266+ return [
267+ home / ".copilot" / "mcp-config.json" ,
268+ Path .cwd () / ".mcp.json"
269+ ]
270+
271+ def _add_server_config_to_file (self , config_path , server_name : str , client_config : dict ) -> bool :
272+ """Add server config to a Copilot JSON file."""
273+ config_path = Path (config_path )
274+
275+ try :
276+ # Load existing config
277+ config = {}
278+ if config_path .exists ():
279+ with open (config_path , 'r' ) as f :
280+ config = json .load (f )
281+
282+ # Ensure mcpServers exists
283+ if "mcpServers" not in config :
284+ config ["mcpServers" ] = {}
285+
286+ # Add the server config
287+ config ["mcpServers" ][server_name ] = client_config
288+
289+ # Write back
290+ config_path .parent .mkdir (parents = True , exist_ok = True )
291+ with open (config_path , 'w' ) as f :
292+ json .dump (config , f , indent = 2 )
293+
294+ return True
295+
296+ except Exception as e :
297+ print (f"Error adding server to Copilot config { config_path } : { e } " )
298+ return False
299+
300+ def _add_server_to_config (self , config_path , server_name : str , server_info : dict ) -> bool :
301+ """Add a server to a Copilot config file."""
302+ # Convert to Copilot format first
303+ copilot_server_info = self ._convert_to_copilot_format (server_info )
304+ return self ._add_server_config_to_file (config_path , server_name , copilot_server_info )
305+
306+ def _remove_server_from_config (self , config_path , server_name : str ) -> bool :
307+ """Remove a server from a Copilot config file."""
308+ config_path = Path (config_path )
309+
310+ try :
311+ if not config_path .exists ():
312+ return False
313+
314+ with open (config_path , 'r' ) as f :
315+ config = json .load (f )
316+
317+ # Check if the server exists in mcpServers
318+ if "mcpServers" in config and isinstance (config ["mcpServers" ], dict ):
319+ if server_name in config ["mcpServers" ]:
320+ del config ["mcpServers" ][server_name ]
321+
322+ # Write back
323+ with open (config_path , 'w' ) as f :
324+ json .dump (config , f , indent = 2 )
325+ return True
326+
327+ return False
328+
329+ except Exception as e :
330+ print (f"Error removing server from Copilot config { config_path } : { e } " )
331+ return False
0 commit comments