From 3194a35230156bbdc64724dfe6daaf568830684e Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Sun, 25 May 2025 16:00:07 -0700 Subject: [PATCH 01/51] feat: Add Gemini 2.5 Flash model and leaderboard data --- aider/resources/model-settings.yml | 4 +++ aider/website/_data/polyglot_leaderboard.yml | 30 +++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/aider/resources/model-settings.yml b/aider/resources/model-settings.yml index 2d004557be6..d8318962f5e 100644 --- a/aider/resources/model-settings.yml +++ b/aider/resources/model-settings.yml @@ -1747,3 +1747,7 @@ editor_edit_format: editor-diff accepts_settings: ["thinking_tokens"] +- name: vertex_ai/gemini-2.5-flash-preview-05-20 + edit_format: diff + use_repo_map: true + accepts_settings: ["reasoning_effort", "thinking_tokens"] \ No newline at end of file diff --git a/aider/website/_data/polyglot_leaderboard.yml b/aider/website/_data/polyglot_leaderboard.yml index 13ef32e368a..a9a30de3ff0 100644 --- a/aider/website/_data/polyglot_leaderboard.yml +++ b/aider/website/_data/polyglot_leaderboard.yml @@ -1419,4 +1419,32 @@ date: 2025-05-25 versions: 0.83.3.dev seconds_per_case: 44.1 - total_cost: 65.7484 \ No newline at end of file + total_cost: 65.7484 + +- dirname: 2025-05-25-22-42-53--flash25-05-20 + test_cases: 225 + model: gemini-2.5-flash-preview-05-20 + edit_format: diff + commit_hash: a8568c3 + pass_rate_1: 23.6 + pass_rate_2: 55.1 + pass_num_1: 53 + pass_num_2: 124 + percent_cases_well_formed: 96.4 + error_outputs: 8 + num_malformed_responses: 8 + num_with_malformed_responses: 8 + user_asks: 96 + lazy_comments: 0 + syntax_errors: 0 + indentation_errors: 0 + exhausted_context_windows: 0 + prompt_tokens: 4512205 + completion_tokens: 2991967 + test_timeouts: 2 + total_tests: 225 + command: aider --model gemini/gemini-2.5-flash-preview-05-20 + date: 2025-05-25 + versions: 0.83.3.dev + seconds_per_case: 50.4 + total_cost: 7.6091 \ No newline at end of file From 214b811ef9226bd6a93f496fedf02d82755df163 Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Mon, 26 May 2025 08:56:01 -0700 Subject: [PATCH 02/51] chore: Add new polyglot benchmark results --- aider/website/_data/polyglot_leaderboard.yml | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/aider/website/_data/polyglot_leaderboard.yml b/aider/website/_data/polyglot_leaderboard.yml index a9a30de3ff0..2d6915d61ae 100644 --- a/aider/website/_data/polyglot_leaderboard.yml +++ b/aider/website/_data/polyglot_leaderboard.yml @@ -1447,4 +1447,33 @@ date: 2025-05-25 versions: 0.83.3.dev seconds_per_case: 50.4 - total_cost: 7.6091 \ No newline at end of file + total_cost: 7.6091 + +- dirname: 2025-05-25-22-58-44--flash25-05-20-24k-think + test_cases: 225 + model: gemini/gemini-2.5-flash-preview-05-20 + edit_format: diff + commit_hash: a8568c3-dirty + thinking_tokens: 24576 + pass_rate_1: 26.2 + pass_rate_2: 55.1 + pass_num_1: 59 + pass_num_2: 124 + percent_cases_well_formed: 95.6 + error_outputs: 15 + num_malformed_responses: 15 + num_with_malformed_responses: 10 + user_asks: 101 + lazy_comments: 0 + syntax_errors: 0 + indentation_errors: 0 + exhausted_context_windows: 0 + prompt_tokens: 3666792 + completion_tokens: 2703162 + test_timeouts: 4 + total_tests: 225 + command: aider --model gemini/gemini-2.5-flash-preview-05-20 + date: 2025-05-25 + versions: 0.83.3.dev + seconds_per_case: 53.9 + total_cost: 8.5625 \ No newline at end of file From acebc112376f8f7042aeb87d6043fb0dec5f6b20 Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Mon, 26 May 2025 08:56:35 -0700 Subject: [PATCH 03/51] chore: Update model names in polyglot leaderboard --- aider/website/_data/polyglot_leaderboard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aider/website/_data/polyglot_leaderboard.yml b/aider/website/_data/polyglot_leaderboard.yml index 2d6915d61ae..6bd5896af50 100644 --- a/aider/website/_data/polyglot_leaderboard.yml +++ b/aider/website/_data/polyglot_leaderboard.yml @@ -1423,7 +1423,7 @@ - dirname: 2025-05-25-22-42-53--flash25-05-20 test_cases: 225 - model: gemini-2.5-flash-preview-05-20 + model: gemini-2.5-flash-preview-05-20 (no think) edit_format: diff commit_hash: a8568c3 pass_rate_1: 23.6 @@ -1451,7 +1451,7 @@ - dirname: 2025-05-25-22-58-44--flash25-05-20-24k-think test_cases: 225 - model: gemini/gemini-2.5-flash-preview-05-20 + model: gemini-2.5-flash-preview-05-20 (24k think) edit_format: diff commit_hash: a8568c3-dirty thinking_tokens: 24576 From 9c9eedd9c53a85daa7ca2228ee384c5066fd6244 Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Mon, 26 May 2025 12:18:22 -0700 Subject: [PATCH 04/51] chore: Update polyglot leaderboard data for gemini-2.5-flash --- aider/website/_data/polyglot_leaderboard.yml | 37 ++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/aider/website/_data/polyglot_leaderboard.yml b/aider/website/_data/polyglot_leaderboard.yml index 6bd5896af50..51676fc729b 100644 --- a/aider/website/_data/polyglot_leaderboard.yml +++ b/aider/website/_data/polyglot_leaderboard.yml @@ -1421,34 +1421,35 @@ seconds_per_case: 44.1 total_cost: 65.7484 -- dirname: 2025-05-25-22-42-53--flash25-05-20 +- dirname: 2025-05-26-15-56-31--flash25-05-20-24k-think # dirname is misleading test_cases: 225 model: gemini-2.5-flash-preview-05-20 (no think) edit_format: diff - commit_hash: a8568c3 - pass_rate_1: 23.6 - pass_rate_2: 55.1 - pass_num_1: 53 - pass_num_2: 124 - percent_cases_well_formed: 96.4 - error_outputs: 8 - num_malformed_responses: 8 - num_with_malformed_responses: 8 - user_asks: 96 + commit_hash: 214b811-dirty + thinking_tokens: 0 # <-- no thinking + pass_rate_1: 20.9 + pass_rate_2: 44.0 + pass_num_1: 47 + pass_num_2: 99 + percent_cases_well_formed: 93.8 + error_outputs: 16 + num_malformed_responses: 16 + num_with_malformed_responses: 14 + user_asks: 79 lazy_comments: 0 syntax_errors: 0 indentation_errors: 0 exhausted_context_windows: 0 - prompt_tokens: 4512205 - completion_tokens: 2991967 - test_timeouts: 2 + prompt_tokens: 5512458 + completion_tokens: 514145 + test_timeouts: 4 total_tests: 225 command: aider --model gemini/gemini-2.5-flash-preview-05-20 - date: 2025-05-25 + date: 2025-05-26 versions: 0.83.3.dev - seconds_per_case: 50.4 - total_cost: 7.6091 - + seconds_per_case: 12.2 + total_cost: 1.1354 + - dirname: 2025-05-25-22-58-44--flash25-05-20-24k-think test_cases: 225 model: gemini-2.5-flash-preview-05-20 (24k think) From b79a77793643623adcbc6f885a7ce7c3b66031d9 Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Mon, 26 May 2025 16:29:10 -0700 Subject: [PATCH 05/51] copy --- README.md | 2 +- aider/website/assets/sample-analytics.jsonl | 536 +++++++++--------- .../website/docs/config/adv-model-settings.md | 317 +++++++++++ aider/website/docs/config/model-aliases.md | 4 +- aider/website/docs/faq.md | 11 +- aider/website/docs/leaderboards/index.md | 2 +- aider/website/index.html | 4 +- 7 files changed, 600 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index 4a5ad7e8b87..7b3395da41d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ cog.out(text) GitHub Stars PyPI Downloads +src="https://img.shields.io/badge/📦%20Installs-2.4M-2ecc71?style=flat-square&labelColor=555555"/> Tokens per week OpenRouter Ranking ## Priority diff --git a/aider/website/docs/faq.md b/aider/website/docs/faq.md index 07132ce6ff7..6e8baecbb14 100644 --- a/aider/website/docs/faq.md +++ b/aider/website/docs/faq.md @@ -264,10 +264,17 @@ tr:hover { background-color: #f5f5f5; } - - + + + + +
Model NameTotal TokensPercent
gemini/gemini-2.5-pro-exp-03-251,216,05167.6%
o3542,66930.2%
gemini/gemini-2.5-pro-exp-03-251,109,76861.9%
o3542,66930.3%
anthropic/claude-sonnet-4-2025051492,5085.2%
gemini/gemini-2.5-pro-preview-05-0640,2562.2%
gemini/gemini-2.5-flash-preview-05-207,6380.4%
gemini/REDACTED6430.0%
+ +{: .note :} +Some models show as REDACTED, because they are new or unpopular models. +Aider's analytics only records the names of "well known" LLMs. ## How are the "aider wrote xx% of code" stats computed? diff --git a/aider/website/docs/leaderboards/index.md b/aider/website/docs/leaderboards/index.md index 1485ce5327b..18aac4a2499 100644 --- a/aider/website/docs/leaderboards/index.md +++ b/aider/website/docs/leaderboards/index.md @@ -285,6 +285,6 @@ mod_dates = [get_last_modified_date(file) for file in files] latest_mod_date = max(mod_dates) cog.out(f"{latest_mod_date.strftime('%B %d, %Y.')}") ]]]--> -May 09, 2025. +May 26, 2025.

diff --git a/aider/website/index.html b/aider/website/index.html index 87e19b86272..9bcaa13a767 100644 --- a/aider/website/index.html +++ b/aider/website/index.html @@ -69,11 +69,11 @@

AI pair programming in your terminal

]]]-->
⭐ GitHub Stars - 33K + 34K 📦 Installs - 2.3M + 2.4M
📈 Tokens/week From 8304029b92e5c20850467ed22c503921d4738796 Mon Sep 17 00:00:00 2001 From: Paul Gauthier Date: Mon, 26 May 2025 16:29:28 -0700 Subject: [PATCH 06/51] lint --- aider/models.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/aider/models.py b/aider/models.py index 6289babca70..8c4a0c3eaae 100644 --- a/aider/models.py +++ b/aider/models.py @@ -878,20 +878,24 @@ def is_ollama(self): def github_copilot_token_to_open_ai_key(self): # check to see if there's an openai api key # If so, check to see if it's expire - openai_api_key = 'OPENAI_API_KEY' + openai_api_key = "OPENAI_API_KEY" if openai_api_key not in os.environ or ( - int(dict(x.split("=") for x in os.environ[openai_api_key].split(";"))['exp']) < int(datetime.now().timestamp()) + int(dict(x.split("=") for x in os.environ[openai_api_key].split(";"))["exp"]) + < int(datetime.now().timestamp()) ): import requests + headers = { - 'Authorization': f"Bearer {os.environ['GITHUB_COPILOT_TOKEN']}", - 'Editor-Version': self.extra_params['extra_headers']['Editor-Version'], - 'Copilot-Integration-Id': self.extra_params['extra_headers']['Copilot-Integration-Id'], - 'Content-Type': 'application/json', + "Authorization": f"Bearer {os.environ['GITHUB_COPILOT_TOKEN']}", + "Editor-Version": self.extra_params["extra_headers"]["Editor-Version"], + "Copilot-Integration-Id": self.extra_params["extra_headers"][ + "Copilot-Integration-Id" + ], + "Content-Type": "application/json", } res = requests.get("https://api.github.com/copilot_internal/v2/token", headers=headers) - os.environ[openai_api_key] = res.json()['token'] + os.environ[openai_api_key] = res.json()["token"] def send_completion(self, messages, functions, stream, temperature=None): if os.environ.get("AIDER_SANITY_CHECK_TURNS"): @@ -935,7 +939,7 @@ def send_completion(self, messages, functions, stream, temperature=None): kwargs["messages"] = messages # Are we using github copilot? - if 'GITHUB_COPILOT_TOKEN' in os.environ: + if "GITHUB_COPILOT_TOKEN" in os.environ: self.github_copilot_token_to_open_ai_key() res = litellm.completion(**kwargs) From a0282dc143219231eed4cc518e99828a28f57bb7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 27 May 2025 21:57:59 +0200 Subject: [PATCH 07/51] Fix merge conflit from mcp PR --- aider/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/models.py b/aider/models.py index 215312bfebf..cf95adad1a3 100644 --- a/aider/models.py +++ b/aider/models.py @@ -898,7 +898,7 @@ def github_copilot_token_to_open_ai_key(self): res = requests.get("https://api.github.com/copilot_internal/v2/token", headers=headers) os.environ[openai_api_key] = res.json()["token"] - def send_completion(self, messages, functions, stream, temperature=None): + def send_completion(self, messages, functions, stream, temperature=None, tools=None): if os.environ.get("AIDER_SANITY_CHECK_TURNS"): sanity_check_messages(messages) From ece40beee584d74ee5a1ea525696051f907f44bc Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Tue, 27 May 2025 22:40:14 +0200 Subject: [PATCH 08/51] feat: Add MCP profile data structure and manager --- aider/mcp_profile_manager.py | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 aider/mcp_profile_manager.py diff --git a/aider/mcp_profile_manager.py b/aider/mcp_profile_manager.py new file mode 100644 index 00000000000..73170dfeaa3 --- /dev/null +++ b/aider/mcp_profile_manager.py @@ -0,0 +1,88 @@ +from pathlib import Path +from typing import List, Dict, Optional, Tuple, Any +from dataclasses import dataclass, asdict +import yaml + +MCP_PROFILES_YAML_PATH = Path.home() / ".aider.mcp.profiles.yml" + +@dataclass +class MCPProfile: + name: str + server_names: List[str] + +def load_mcp_profiles() -> Dict[str, MCPProfile]: + try: + with open(MCP_PROFILES_YAML_PATH, 'r') as f: + profiles_data = yaml.safe_load(f) + if isinstance(profiles_data, list): + profiles = {} + for profile_dict in profiles_data: + try: + profile = MCPProfile(**profile_dict) + profiles[profile.name] = profile + except TypeError as e: + # Handle cases where a dict in YAML doesn't match MCPProfile fields + # Potentially log this, for now, we'll skip malformed entries + print(f"Warning: Skipping malformed profile entry: {profile_dict} due to {e}") + return profiles + elif profiles_data is None: # File is empty + return {} + else: # File content is not a list as expected + # Potentially log this + print(f"Warning: MCP profiles YAML content is not a list: {MCP_PROFILES_YAML_PATH}") + return {} + except FileNotFoundError: + return {} + except yaml.YAMLError as e: + # Potentially log this, e.g., using io if available + print(f"Warning: Error parsing MCP profiles YAML: {e}") + return {} + +def save_mcp_profiles(profiles: Dict[str, MCPProfile]): + try: + profiles_list = [asdict(profile) for profile in profiles.values()] + with open(MCP_PROFILES_YAML_PATH, 'w') as f: + yaml.dump(profiles_list, f, sort_keys=False) + except (IOError, yaml.YAMLError) as e: + # Potentially log this, e.g., using io if available + print(f"Warning: Error saving MCP profiles YAML: {e}") + +def get_known_mcp_server_names(settings_mcpservers: Optional[List[Dict]]) -> List[str]: + if not settings_mcpservers: + return [] + + server_names = [] + for server_config in settings_mcpservers: + if isinstance(server_config, dict) and 'name' in server_config: + server_names.append(server_config['name']) + return server_names + +class MCPProfileManager: + def __init__(self, io, settings): + self.io = io + self.settings = settings + self.profiles: Dict[str, MCPProfile] = {} + self.active_profile_name: Optional[str] = None + self.active_mcp_client_pool: Optional[Any] = None # Replace Any with MCPClientPool later + + def load_or_initialize_profiles(self): + self.profiles = load_mcp_profiles() + + if "all" not in self.profiles: + known_server_names = get_known_mcp_server_names( + self.settings.mcpservers if hasattr(self.settings, 'mcpservers') else None + ) + all_profile = MCPProfile(name="all", server_names=known_server_names) + self.profiles["all"] = all_profile + save_mcp_profiles(self.profiles) + if self.io: + self.io.tool_output("Initialized default MCP profile 'all'.") + + def get_profile(self, name: str) -> Optional[MCPProfile]: + return self.profiles.get(name) + + def list_profile_names(self) -> List[str]: + return list(self.profiles.keys()) + + def list_profiles_details(self) -> List[Tuple[str, List[str]]]: + return [(profile.name, profile.server_names) for profile in self.profiles.values()] From 6981e0cf4efde2e36dac9a498de908cae40258b5 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Tue, 27 May 2025 22:42:52 +0200 Subject: [PATCH 09/51] chore: Instantiate and pass MCP profile manager to Coder/Commands --- aider/coders/base_coder.py | 3 +++ aider/commands.py | 4 ++++ aider/main.py | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 0669b2597fb..3525f86a9e9 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -126,6 +126,7 @@ class Coder: file_watcher = None mcp_servers = None mcp_tools = None + mcp_profile_manager = None @classmethod def create( @@ -343,9 +344,11 @@ def __init__( auto_copy_context=False, auto_accept_architect=True, mcp_servers=None, + mcp_profile_manager=None, ): # Fill in a dummy Analytics if needed, but it is never .enable()'d self.analytics = analytics if analytics is not None else Analytics() + self.mcp_profile_manager = mcp_profile_manager self.event = self.analytics.event self.chat_language = chat_language diff --git a/aider/commands.py b/aider/commands.py index aaf6d7ddd9a..22cb1261e83 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -36,6 +36,7 @@ def __init__(self, placeholder=None, **kwargs): class Commands: voice = None scraper = None + mcp_profile_manager = None def clone(self): return Commands( @@ -48,6 +49,7 @@ def clone(self): verbose=self.verbose, editor=self.editor, original_read_only_fnames=self.original_read_only_fnames, + mcp_profile_manager=self.mcp_profile_manager, ) def __init__( @@ -63,10 +65,12 @@ def __init__( verbose=False, editor=None, original_read_only_fnames=None, + mcp_profile_manager=None, ): self.io = io self.coder = coder self.parser = parser + self.mcp_profile_manager = mcp_profile_manager self.args = args self.verbose = verbose diff --git a/aider/main.py b/aider/main.py index 9358f6b6bd8..b0b8bfc0afd 100644 --- a/aider/main.py +++ b/aider/main.py @@ -31,6 +31,7 @@ from aider.io import InputOutput from aider.llm import litellm # noqa: F401; properly init litellm on launch from aider.mcp import load_mcp_servers +from aider.mcp_profile_manager import MCPProfileManager from aider.models import ModelSettings from aider.onboarding import offer_openrouter_oauth, select_default_model from aider.repo import ANY_GIT_ERROR, GitRepo @@ -738,6 +739,10 @@ def get_io(pretty): if args.gitignore: check_gitignore(git_root, io) + # Instantiate MCPProfileManager + mcp_profile_manager = MCPProfileManager(io, args) + mcp_profile_manager.load_or_initialize_profiles() + if args.verbose: show = format_settings(parser, args) io.tool_output(show) @@ -939,6 +944,7 @@ def get_io(pretty): verbose=args.verbose, editor=args.editor, original_read_only_fnames=read_only_fnames, + mcp_profile_manager=mcp_profile_manager, ) summarizer = ChatSummary( @@ -1004,6 +1010,7 @@ def get_io(pretty): auto_copy_context=args.copy_paste, auto_accept_architect=args.auto_accept_architect, mcp_servers=mcp_servers, + mcp_profile_manager=mcp_profile_manager, ) except UnknownEditFormat as err: io.tool_error(str(err)) From acb4580a73cdbd64cd281e80490755fcfdc3fdd3 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Tue, 27 May 2025 22:45:58 +0200 Subject: [PATCH 10/51] feat: Add /mcp command to list profiles and update completion fix: Include mcpservers-file in known server names fix: Ensure 'all' MCP profile reflects current server configs feat: Support dict format for MCP server config fix: Fix 'all' profile update logic on startup Fix keys for mcp config refactor: Extract MCP server names from 'mcpServers' key --- aider/commands.py | 51 ++++++++++ aider/io.py | 64 +++++++++++-- aider/mcp_profile_manager.py | 175 ++++++++++++++++++++++++++++++----- 3 files changed, 260 insertions(+), 30 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 22cb1261e83..35efde01f8b 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1592,6 +1592,57 @@ def cmd_reasoning_effort(self, args): announcements = "\n".join(self.coder.get_announcements()) self.io.tool_output(announcements) + def cmd_mcp(self, args_str: str): + "Manage MCP (Model-Controller-Proxy) profiles" + args = args_str.strip().split() + + if not self.mcp_profile_manager: + self.io.tool_error("MCP profile manager is not initialized.") + return + + if not args: + profiles_details = self.mcp_profile_manager.list_profiles_details() + self.io.tool_output("Available MCP profiles:") + if profiles_details: + for name, servers in profiles_details: + self.io.tool_output( + f" - {name}: {', '.join(servers) if servers else ' (no servers)'}" + ) + else: + self.io.tool_output(" (No profiles defined)") + + active_name = self.mcp_profile_manager.active_profile_name + if active_name: + self.io.tool_output(f"\nCurrently active MCP profile: {active_name}") + else: + self.io.tool_output("\nNo MCP profile is currently active.") + + self.io.tool_output("\nCommands:") + self.io.tool_output(" /mcp enable - Enable an MCP profile") + self.io.tool_output(" /mcp disable - Disable the active MCP profile") + # Add more subcommands as they are implemented + # self.io.tool_output(" /mcp create [server_name ...]") + # self.io.tool_output(" /mcp delete ") + # self.io.tool_output(" /mcp add ") + # self.io.tool_output(" /mcp remove ") + return + + sub_command = args[0] + if sub_command == "enable": + if len(args) > 1: + profile_name = args[1] + # Placeholder for enable logic + self.io.tool_error(f"Subcommand 'enable {profile_name}' not yet implemented.") + else: + self.io.tool_error("Usage: /mcp enable ") + elif sub_command == "disable": + # Placeholder for disable logic + self.io.tool_error("Subcommand 'disable' not yet implemented.") + else: + self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") + self.io.tool_output("Valid subcommands are: enable, disable") + + def cmd_copy_context(self, args=None): """Copy the current chat context as markdown, suitable to paste into a web UI""" diff --git a/aider/io.py b/aider/io.py index f28a1c86dfb..f02ed2b167f 100644 --- a/aider/io.py +++ b/aider/io.py @@ -146,18 +146,66 @@ def tokenize(self): ) def get_command_completions(self, document, complete_event, text, words): - if len(words) == 1 and not text[-1].isspace(): - partial = words[0].lower() - candidates = [cmd for cmd in self.command_names if cmd.startswith(partial)] + if len(words) == 1 and not text[-1].isspace(): # Completing the command itself + partial_cmd = words[0].lower() + # Ensure self.command_names is populated + if not hasattr(self, 'command_names') or not self.command_names: + if self.commands: + self.command_names = self.commands.get_commands() + else: + self.command_names = [] + + candidates = [cmd for cmd in self.command_names if cmd.startswith(partial_cmd)] for candidate in sorted(candidates): - yield Completion(candidate, start_position=-len(words[-1])) - return - - if len(words) <= 1 or text[-1].isspace(): + yield Completion(candidate, start_position=-len(words[0])) return + # If we are here, words[0] is a complete command. + # We are completing arguments for words[0]. cmd = words[0] - partial = words[-1].lower() + + # If text ends with a space, or no partial word to complete + if text[-1].isspace() or len(words) == 1 : + partial_arg = "" + else: # We are completing the current (last) word as an argument + partial_arg = words[-1].lower() + + # Special handling for /mcp + if cmd == "/mcp": + if len(words) == 2 and not text[-1].isspace(): # Completing the subcommand (enable/disable) + subcommands = ["enable", "disable"] + for sub_cmd_candidate in subcommands: + if sub_cmd_candidate.startswith(partial_arg): + yield Completion(sub_cmd_candidate, start_position=-len(words[-1])) + return + elif (len(words) == 2 and text[-1].isspace()) or \ + (len(words) == 1 and text[-1].isspace()): # Suggesting subcommand after "/mcp " + subcommands = ["enable", "disable"] + for sub_cmd_candidate in subcommands: + yield Completion(sub_cmd_candidate, start_position=0) + return + elif len(words) >= 2 and words[1] == "enable": + # Completing profile name for "/mcp enable " + # Or suggesting profile names after "/mcp enable " + if self.commands and self.commands.mcp_profile_manager: + profile_names = self.commands.mcp_profile_manager.list_profile_names() + for profile_name in profile_names: + if profile_name.startswith(partial_arg): + start_pos = -len(words[-1]) if (len(words) > 2 and not text[-1].isspace()) else 0 + yield Completion(profile_name, start_position=start_pos) + return + return # No mcp_profile_manager or no profiles + + # Fallback to existing general command argument completion + if (len(words) <= 1 or text[-1].isspace()) and cmd != "/mcp": # Avoid this path if we handled /mcp above + return + + # If we fell through /mcp logic, and we are not at a space, use original partial. + # Otherwise, partial_arg would have been set above. + if cmd != "/mcp" and not text[-1].isspace(): # Original logic for other commands + partial = words[-1].lower() + else: # Use the partial_arg determined for argument completion + partial = partial_arg matches, _, _ = self.commands.matching_commands(cmd) if len(matches) == 1: diff --git a/aider/mcp_profile_manager.py b/aider/mcp_profile_manager.py index 73170dfeaa3..1228d5e017b 100644 --- a/aider/mcp_profile_manager.py +++ b/aider/mcp_profile_manager.py @@ -17,13 +17,26 @@ def load_mcp_profiles() -> Dict[str, MCPProfile]: if isinstance(profiles_data, list): profiles = {} for profile_dict in profiles_data: + if not isinstance(profile_dict, dict): + print(f"Warning: Skipping non-dictionary profile entry: {profile_dict}") + continue try: + # Ensure server_names is a list. + loaded_server_names = profile_dict.get('server_names') + if not isinstance(loaded_server_names, list): + profile_name = profile_dict.get('name', 'UnknownProfile') + if loaded_server_names is not None: # Log if it was present but wrong type + print( + f"Warning: 'server_names' for profile '{profile_name}' is not a list" + f" (type: {type(loaded_server_names)}). Initializing as empty list." + ) + profile_dict['server_names'] = [] # Default to empty list + profile = MCPProfile(**profile_dict) profiles[profile.name] = profile - except TypeError as e: - # Handle cases where a dict in YAML doesn't match MCPProfile fields - # Potentially log this, for now, we'll skip malformed entries - print(f"Warning: Skipping malformed profile entry: {profile_dict} due to {e}") + except TypeError as e: # Catches issues like missing 'name' or other fields + profile_name_for_error = profile_dict.get('name', str(profile_dict)) + print(f"Warning: Skipping malformed profile entry for '{profile_name_for_error}': {e}") return profiles elif profiles_data is None: # File is empty return {} @@ -47,15 +60,107 @@ def save_mcp_profiles(profiles: Dict[str, MCPProfile]): # Potentially log this, e.g., using io if available print(f"Warning: Error saving MCP profiles YAML: {e}") -def get_known_mcp_server_names(settings_mcpservers: Optional[List[Dict]]) -> List[str]: - if not settings_mcpservers: - return [] - - server_names = [] - for server_config in settings_mcpservers: - if isinstance(server_config, dict) and 'name' in server_config: - server_names.append(server_config['name']) - return server_names +def get_known_mcp_server_names( + mcpservers_arg: Optional[Any], # This is args.mcpservers + mcpservers_file_arg: Optional[str], # This is args.mcpservers_file + io: Optional[Any] +) -> List[str]: + server_names_set = set() + file_encoding = 'utf-8' # Default encoding + + if io and hasattr(io, 'encoding') and io.encoding: + file_encoding = io.encoding + + # Helper to process data loaded from YAML/JSON + def _extract_server_names_from_data(data: Any, source_description: str): + + if not isinstance(data, dict): + if data is not None and io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"MCP servers config from {source_description} is not a dictionary (type:" + f" {type(data)}), skipping. Expected a top-level dictionary." + ) + return + + mcp_servers_object = data.get("mcpServers") + + if not isinstance(mcp_servers_object, dict): + if mcp_servers_object is not None and io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"MCP servers config from {source_description}: 'mcpServers' key does not point to a dictionary (type:" + f" {type(mcp_servers_object)}), skipping." + ) + elif "mcpServers" not in data and io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"MCP servers config from {source_description}: Missing 'mcpServers' top-level key, skipping." + ) + return + + extracted_names = list(mcp_servers_object.keys()) + for server_name in extracted_names: + server_names_set.add(server_name) + + # Helper to process a file path + def _process_file_path(file_path_str: str, source_description: str): + try: + path_obj = Path(file_path_str) + # Check if path_obj is None or empty string before calling expanduser + if not path_obj or not str(path_obj).strip(): + if io and hasattr(io, 'tool_warning'): + io.tool_warning(f"Empty file path provided for {source_description}, skipping.") + return + + file_path = path_obj.expanduser() + if file_path.exists(): + with open(file_path, 'r', encoding=file_encoding) as f: + data = yaml.safe_load(f) + _extract_server_names_from_data(data, f"file {file_path_str}") + # Silently ignore if file not found + except yaml.YAMLError as e: + if io and hasattr(io, 'tool_warning'): + io.tool_warning(f"Error parsing MCP servers {source_description} file {file_path_str}: {e}") + except IOError as e: + if io and hasattr(io, 'tool_warning'): + io.tool_warning(f"Error reading MCP servers {source_description} file {file_path_str}: {e}") + except Exception as e: + if io and hasattr(io, 'tool_warning'): + io.tool_warning(f"Unexpected error processing MCP {source_description} file {file_path_str}: {e}") + + # 1. Process mcpservers_arg (args.mcpservers) + if mcpservers_arg: + if isinstance(mcpservers_arg, dict): # Already parsed as a dict (e.g. from aider.conf.yml) + _extract_server_names_from_data(mcpservers_arg, "direct config (dict)") + elif isinstance(mcpservers_arg, str): + parsed_as_yaml_dict = False + try: + data = yaml.safe_load(mcpservers_arg) + if isinstance(data, dict): # Parsed as a dict + _extract_server_names_from_data(data, "direct config (YAML string)") + parsed_as_yaml_dict = True + elif data is not None and io and hasattr(io, 'tool_warning'): # Parsed as something else + io.tool_warning( + f"MCP servers config from direct YAML string was not a dictionary (type: {type(data)}), skipping." + ) + except yaml.YAMLError: + # Not valid YAML, so it's likely a path. Fall through. + pass + + if not parsed_as_yaml_dict: + # If it wasn't parsed as a YAML dict treat the original string as a path. + _process_file_path(mcpservers_arg, "direct config (path)") + elif isinstance(mcpservers_arg, list) and io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"MCP servers config from direct config was a list, skipping. Expected a dictionary." + ) + # Else: other types are ignored or handled by _extract_server_names_from_data if it's a dict. + + # 2. Process mcpservers_file_arg (args.mcpservers_file) + if mcpservers_file_arg: + _process_file_path(mcpservers_file_arg, "file arg") + + final_names = list(server_names_set) + + return final_names class MCPProfileManager: def __init__(self, io, settings): @@ -67,16 +172,42 @@ def __init__(self, io, settings): def load_or_initialize_profiles(self): self.profiles = load_mcp_profiles() - - if "all" not in self.profiles: - known_server_names = get_known_mcp_server_names( - self.settings.mcpservers if hasattr(self.settings, 'mcpservers') else None - ) - all_profile = MCPProfile(name="all", server_names=known_server_names) - self.profiles["all"] = all_profile - save_mcp_profiles(self.profiles) - if self.io: + + mcpservers_arg_val = getattr(self.settings, 'mcp_servers', None) + mcpservers_file_arg_val = getattr(self.settings, 'mcp_servers_file', None) + + current_known_server_names = sorted(list(set(get_known_mcp_server_names( + mcpservers_arg_val, + mcpservers_file_arg_val, + self.io + )))) + + needs_save = False + + old_all_profile_server_names_sorted = None + if "all" in self.profiles: + # self.profiles["all"].server_names is guaranteed to be a list by robust load_mcp_profiles + old_all_profile_server_names_sorted = sorted(self.profiles["all"].server_names) + + # Create/Update the 'all' profile in the in-memory dictionary to match current reality. + # This ensures self.profiles["all"] is always up-to-date after this step. + self.profiles["all"] = MCPProfile(name="all", server_names=current_known_server_names) + + if old_all_profile_server_names_sorted is None: + # The 'all' profile was not in the loaded file, so we created it. + needs_save = True + if self.io and hasattr(self.io, 'tool_output'): self.io.tool_output("Initialized default MCP profile 'all'.") + elif old_all_profile_server_names_sorted != current_known_server_names: + # The 'all' profile was in the loaded file, but its servers differed from current reality. + needs_save = True + if self.io and hasattr(self.io, 'tool_output'): + self.io.tool_output("Updated MCP profile 'all' with current server configurations.") + # If old_all_profile_server_names_sorted == current_known_server_names, then the loaded 'all' profile + # was already correct. No message, needs_save remains False. + + if needs_save: + save_mcp_profiles(self.profiles) def get_profile(self, name: str) -> Optional[MCPProfile]: return self.profiles.get(name) From 314a74f9aa36e1cad7239459bb3a3d28bb643d51 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 May 2025 00:12:47 +0200 Subject: [PATCH 11/51] Moved McpProfileManager to mcp module --- aider/main.py | 2 +- aider/{ => mcp}/mcp_profile_manager.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename aider/{ => mcp}/mcp_profile_manager.py (100%) diff --git a/aider/main.py b/aider/main.py index b0b8bfc0afd..d7d4296eb10 100644 --- a/aider/main.py +++ b/aider/main.py @@ -31,7 +31,7 @@ from aider.io import InputOutput from aider.llm import litellm # noqa: F401; properly init litellm on launch from aider.mcp import load_mcp_servers -from aider.mcp_profile_manager import MCPProfileManager +from aider.mcp.mcp_profile_manager import MCPProfileManager from aider.models import ModelSettings from aider.onboarding import offer_openrouter_oauth, select_default_model from aider.repo import ANY_GIT_ERROR, GitRepo diff --git a/aider/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py similarity index 100% rename from aider/mcp_profile_manager.py rename to aider/mcp/mcp_profile_manager.py From 6fd6502c074a4f28a1c15bed9d1eacb656596761 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:21:46 +0200 Subject: [PATCH 12/51] refactor: Use load_mcp_servers in profile manager --- aider/mcp/mcp_profile_manager.py | 141 +++++++++++-------------------- 1 file changed, 48 insertions(+), 93 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 1228d5e017b..09da23bdc63 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -2,6 +2,8 @@ from typing import List, Dict, Optional, Tuple, Any from dataclasses import dataclass, asdict import yaml +import json # Added +from aider.mcp import load_mcp_servers # Added MCP_PROFILES_YAML_PATH = Path.home() / ".aider.mcp.profiles.yml" @@ -65,102 +67,55 @@ def get_known_mcp_server_names( mcpservers_file_arg: Optional[str], # This is args.mcpservers_file io: Optional[Any] ) -> List[str]: - server_names_set = set() - file_encoding = 'utf-8' # Default encoding - - if io and hasattr(io, 'encoding') and io.encoding: - file_encoding = io.encoding - - # Helper to process data loaded from YAML/JSON - def _extract_server_names_from_data(data: Any, source_description: str): - - if not isinstance(data, dict): - if data is not None and io and hasattr(io, 'tool_warning'): - io.tool_warning( - f"MCP servers config from {source_description} is not a dictionary (type:" - f" {type(data)}), skipping. Expected a top-level dictionary." - ) - return + loaded_mcp_objects = [] - mcp_servers_object = data.get("mcpServers") - - if not isinstance(mcp_servers_object, dict): - if mcp_servers_object is not None and io and hasattr(io, 'tool_warning'): - io.tool_warning( - f"MCP servers config from {source_description}: 'mcpServers' key does not point to a dictionary (type:" - f" {type(mcp_servers_object)}), skipping." - ) - elif "mcpServers" not in data and io and hasattr(io, 'tool_warning'): - io.tool_warning( - f"MCP servers config from {source_description}: Missing 'mcpServers' top-level key, skipping." - ) - return - - extracted_names = list(mcp_servers_object.keys()) - for server_name in extracted_names: - server_names_set.add(server_name) - - # Helper to process a file path - def _process_file_path(file_path_str: str, source_description: str): - try: - path_obj = Path(file_path_str) - # Check if path_obj is None or empty string before calling expanduser - if not path_obj or not str(path_obj).strip(): - if io and hasattr(io, 'tool_warning'): - io.tool_warning(f"Empty file path provided for {source_description}, skipping.") - return - - file_path = path_obj.expanduser() - if file_path.exists(): - with open(file_path, 'r', encoding=file_encoding) as f: - data = yaml.safe_load(f) - _extract_server_names_from_data(data, f"file {file_path_str}") - # Silently ignore if file not found - except yaml.YAMLError as e: - if io and hasattr(io, 'tool_warning'): - io.tool_warning(f"Error parsing MCP servers {source_description} file {file_path_str}: {e}") - except IOError as e: - if io and hasattr(io, 'tool_warning'): - io.tool_warning(f"Error reading MCP servers {source_description} file {file_path_str}: {e}") - except Exception as e: - if io and hasattr(io, 'tool_warning'): - io.tool_warning(f"Unexpected error processing MCP {source_description} file {file_path_str}: {e}") - - # 1. Process mcpservers_arg (args.mcpservers) + # 1. Process mcpservers_arg if mcpservers_arg: - if isinstance(mcpservers_arg, dict): # Already parsed as a dict (e.g. from aider.conf.yml) - _extract_server_names_from_data(mcpservers_arg, "direct config (dict)") - elif isinstance(mcpservers_arg, str): - parsed_as_yaml_dict = False + temp_servers_from_arg = [] + if isinstance(mcpservers_arg, dict): try: - data = yaml.safe_load(mcpservers_arg) - if isinstance(data, dict): # Parsed as a dict - _extract_server_names_from_data(data, "direct config (YAML string)") - parsed_as_yaml_dict = True - elif data is not None and io and hasattr(io, 'tool_warning'): # Parsed as something else - io.tool_warning( - f"MCP servers config from direct YAML string was not a dictionary (type: {type(data)}), skipping." - ) - except yaml.YAMLError: - # Not valid YAML, so it's likely a path. Fall through. - pass - - if not parsed_as_yaml_dict: - # If it wasn't parsed as a YAML dict treat the original string as a path. - _process_file_path(mcpservers_arg, "direct config (path)") - elif isinstance(mcpservers_arg, list) and io and hasattr(io, 'tool_warning'): - io.tool_warning( - f"MCP servers config from direct config was a list, skipping. Expected a dictionary." - ) - # Else: other types are ignored or handled by _extract_server_names_from_data if it's a dict. - - # 2. Process mcpservers_file_arg (args.mcpservers_file) - if mcpservers_file_arg: - _process_file_path(mcpservers_file_arg, "file arg") + # Convert dict to JSON string to pass to load_mcp_servers + mcp_json_string = json.dumps(mcpservers_arg) + # Call load_mcp_servers with the JSON string + temp_servers_from_arg = load_mcp_servers(mcp_servers=mcp_json_string, mcp_servers_file=None, io=io, verbose=False) + except TypeError as e: # json.dumps might fail + if io and hasattr(io, 'tool_warning'): + io.tool_warning(f"Could not serialize MCP servers dict to JSON: {e}") + elif isinstance(mcpservers_arg, str): + # load_mcp_servers will first try mcp_servers as a JSON string. + # If that doesn't yield results, we then try mcpservers_arg as a file path. + servers_from_json_str = load_mcp_servers(mcp_servers=mcpservers_arg, mcp_servers_file=None, io=io, verbose=False) + if servers_from_json_str: + temp_servers_from_arg = servers_from_json_str + else: + # If not a valid JSON string or it was empty, try as a file path + temp_servers_from_arg = load_mcp_servers(mcp_servers=None, mcp_servers_file=mcpservers_arg, io=io, verbose=False) + elif isinstance(mcpservers_arg, list): + if io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"MCP servers config from direct config was a list, skipping. Expected a dictionary or JSON string." + ) + # Other types for mcpservers_arg will be implicitly ignored or handled by load_mcp_servers - final_names = list(server_names_set) + if temp_servers_from_arg: + loaded_mcp_objects.extend(temp_servers_from_arg) - return final_names + # 2. Process mcpservers_file_arg + if mcpservers_file_arg: + # If mcpservers_arg was a path and is the same as mcpservers_file_arg, + # load_mcp_servers might process it again. + # The set operation for names at the end handles duplicates. + servers_from_file_arg = load_mcp_servers(mcp_servers=None, mcp_servers_file=mcpservers_file_arg, io=io, verbose=False) + if servers_from_file_arg: + loaded_mcp_objects.extend(servers_from_file_arg) + + # Extract unique names from the McpServer objects + server_names_set = set() + for server_obj in loaded_mcp_objects: + if hasattr(server_obj, 'name') and isinstance(server_obj.name, str) and server_obj.name: + server_names_set.add(server_obj.name) + + return sorted(list(server_names_set)) class MCPProfileManager: def __init__(self, io, settings): @@ -176,11 +131,11 @@ def load_or_initialize_profiles(self): mcpservers_arg_val = getattr(self.settings, 'mcp_servers', None) mcpservers_file_arg_val = getattr(self.settings, 'mcp_servers_file', None) - current_known_server_names = sorted(list(set(get_known_mcp_server_names( + current_known_server_names = get_known_mcp_server_names( mcpservers_arg_val, mcpservers_file_arg_val, self.io - )))) + ) needs_save = False From f4e21b15767e31b516346c2bc3a3ddd33461a881 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:25:38 +0200 Subject: [PATCH 13/51] feat: Add logic to enable/disable MCP profiles and pool --- aider/commands.py | 9 +++--- aider/mcp/mcp_profile_manager.py | 51 +++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 35efde01f8b..13711ee5599 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1631,13 +1631,14 @@ def cmd_mcp(self, args_str: str): if sub_command == "enable": if len(args) > 1: profile_name = args[1] - # Placeholder for enable logic - self.io.tool_error(f"Subcommand 'enable {profile_name}' not yet implemented.") + self.mcp_profile_manager.enable_profile(profile_name, self.coder.main_model, self.coder.main_model.edit_format) else: self.io.tool_error("Usage: /mcp enable ") elif sub_command == "disable": - # Placeholder for disable logic - self.io.tool_error("Subcommand 'disable' not yet implemented.") + if len(args) == 1: + self.mcp_profile_manager.disable_profile() + else: + self.io.tool_error("Usage: /mcp disable") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") self.io.tool_output("Valid subcommands are: enable, disable") diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 09da23bdc63..057baa5f810 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -4,6 +4,7 @@ import yaml import json # Added from aider.mcp import load_mcp_servers # Added +from aider.mcp.mcp_client_pool import MCPClientPool MCP_PROFILES_YAML_PATH = Path.home() / ".aider.mcp.profiles.yml" @@ -123,7 +124,7 @@ def __init__(self, io, settings): self.settings = settings self.profiles: Dict[str, MCPProfile] = {} self.active_profile_name: Optional[str] = None - self.active_mcp_client_pool: Optional[Any] = None # Replace Any with MCPClientPool later + self.active_mcp_client_pool: Optional[MCPClientPool] = None def load_or_initialize_profiles(self): self.profiles = load_mcp_profiles() @@ -172,3 +173,51 @@ def list_profile_names(self) -> List[str]: def list_profiles_details(self) -> List[Tuple[str, List[str]]]: return [(profile.name, profile.server_names) for profile in self.profiles.values()] + + def enable_profile(self, profile_name: str, main_model, main_edit_format): + if self.active_profile_name == profile_name and self.active_mcp_client_pool: + self.io.tool_output(f"MCP profile '{profile_name}' is already active.") + return + + if self.active_profile_name is not None: + self.disable_profile() + + profile_to_enable = self.get_profile(profile_name) + + if not profile_to_enable: + self.io.tool_error(f"MCP profile '{profile_name}' not found.") + return + + if not profile_to_enable.server_names: + self.io.tool_warning(f"MCP profile '{profile_name}' has no servers configured. Profile enabled but no connections made.") + self.active_profile_name = profile_name + self.active_mcp_client_pool = None # Ensure it's cleared + return + + all_server_configs = getattr(self.settings, 'mcpservers', []) + if not isinstance(all_server_configs, list): # Ensure it's a list if it was some other type + all_server_configs = [] + + matched_server_configs = [s_conf for s_conf in all_server_configs if isinstance(s_conf, dict) and s_conf.get('name') in profile_to_enable.server_names] + + if not matched_server_configs: + self.io.tool_error(f"No configured MCP servers found for profile '{profile_name}'. Check server names and .aider.conf.yml.") + return + + self.active_mcp_client_pool = MCPClientPool(self.io, main_model, main_edit_format, matched_server_configs) + self.active_profile_name = profile_name + self.io.tool_output(f"MCP profile '{profile_name}' enabled with {len(matched_server_configs)} server(s).") + + def disable_profile(self): + if self.active_mcp_client_pool: + # Assuming MCPClientPool has a shutdown method. + # If not, this will need to be added to MCPClientPool. + if hasattr(self.active_mcp_client_pool, 'shutdown') and callable(getattr(self.active_mcp_client_pool, 'shutdown')): + self.active_mcp_client_pool.shutdown() + self.active_mcp_client_pool = None + + if self.active_profile_name: + self.io.tool_output(f"MCP profile '{self.active_profile_name}' disabled.") + self.active_profile_name = None + else: + self.io.tool_output("No MCP profile was active.") From a8ba33ee81b330b2c68f53612edfc91c54ca000a Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:26:46 +0200 Subject: [PATCH 14/51] feat: Create MCPClientPool for managing servers --- aider/mcp/mcp_client_pool.py | 148 +++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 aider/mcp/mcp_client_pool.py diff --git a/aider/mcp/mcp_client_pool.py b/aider/mcp/mcp_client_pool.py new file mode 100644 index 00000000000..655e53404ae --- /dev/null +++ b/aider/mcp/mcp_client_pool.py @@ -0,0 +1,148 @@ +import asyncio +import logging +from typing import List, Dict, Any, Optional + +from aider.mcp.server import McpServer # Assuming McpServer is in this path + + +class MCPClientPool: + """ + Manages a pool of MCPClient instances, one for each configured MCP server. + Provides a way to get available tools from all connected servers and + to shut down all connections gracefully. + """ + + def __init__(self, io: Any, model: Any, edit_format: str, server_configs: List[Dict[str, Any]]): + """ + Initializes the MCPClientPool. + + Args: + io: The input/output object for logging and user interaction. + model: The main language model being used. + edit_format: The edit format being used. + server_configs: A list of configurations for each MCP server. + """ + self.io = io + self.model = model + self.edit_format = edit_format + self.server_configs = server_configs + self.clients: Dict[str, McpServer] = {} + self._init_clients() + + def _init_clients(self): + """ + Initializes McpServer instances based on the server configurations. + """ + for config in self.server_configs: + server_name = config.get("name") + if not server_name: + logging.warning("MCP server config missing 'name', skipping.") + if self.io and hasattr(self.io, 'tool_warning'): + self.io.tool_warning("An MCP server configuration was missing a 'name' and has been skipped.") + continue + + if server_name in self.clients: + logging.warning(f"Duplicate MCP server name '{server_name}' found in configuration, skipping.") + if self.io and hasattr(self.io, 'tool_warning'): + self.io.tool_warning(f"Duplicate MCP server name '{server_name}' found. Only the first configuration will be used.") + continue + + try: + self.clients[server_name] = McpServer(server_config=config) + # TODO: Potentially connect here or connect on-demand when tools are requested. + # For now, just initializing. Connection will happen when tools are fetched. + logging.info(f"Initialized McpServer for '{server_name}'.") + except Exception as e: + logging.error(f"Failed to initialize McpServer for '{server_name}': {e}") + if self.io and hasattr(self.io, 'tool_error'): + self.io.tool_error(f"Failed to initialize MCP server '{server_name}': {e}") + + async def get_all_tools(self, context: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Connects to all MCP servers and retrieves their available tools. + + Args: + context: Optional context to pass to the MCP servers. + + Returns: + A list of tool definitions from all connected MCP servers. + """ + all_tools = [] + # TODO: Implement tool fetching logic. This will involve: + # 1. Iterating through self.clients. + # 2. For each client, ensuring it's connected (await client.connect()). + # 3. Calling a method on the client/session to get tools (e.g., await client.session.experimental_get_tools(context=context, format="openai_function")) + # 4. Aggregating the tools. + # 5. Handling potential errors during connection or tool retrieval for individual servers. + + # Placeholder implementation: + if self.io and hasattr(self.io, 'tool_output'): + self.io.tool_output("MCPClientPool.get_all_tools() is not fully implemented yet.") + + # Example of how one might connect and get tools (needs actual mcp.py library methods) + # for server_name, client in self.clients.items(): + # try: + # session = await client.connect() + # # Assuming a method like `experimental_get_tools` exists on the session + # # The actual method name and parameters might differ based on mcp.py + # server_tools = await session.experimental_get_tools(format="openai_function", context=context) + # if server_tools: + # all_tools.extend(server_tools) + # logging.info(f"Retrieved {len(server_tools)} tools from {server_name}") + # except Exception as e: + # logging.error(f"Error getting tools from MCP server {server_name}: {e}") + # if self.io and hasattr(self.io, 'tool_error'): + # self.io.tool_error(f"Could not retrieve tools from MCP server '{server_name}': {e}") + return all_tools + + def shutdown(self): + """ + Shuts down all MCP client connections. + This method is synchronous but calls an asynchronous helper. + """ + logging.info("Shutting down MCPClientPool...") + try: + asyncio.run(self._shutdown_async()) + except RuntimeError as e: + if "cannot be called from a running event loop" in str(e): + # If we're already in an event loop, schedule _shutdown_async + # This might happen if shutdown is called from an async context + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(self._shutdown_async()) + logging.info("Scheduled MCPClientPool shutdown in existing event loop.") + else: # Should not happen if RuntimeError is for running loop + logging.error(f"MCPClientPool shutdown error: {e}") + else: + logging.error(f"MCPClientPool shutdown error: {e}") + except Exception as e: # Catch any other unexpected errors + logging.error(f"Unexpected error during MCPClientPool shutdown: {e}") + + + async def _shutdown_async(self): + """ + Asynchronously disconnects all McpServer instances. + """ + logging.info("Starting asynchronous shutdown of MCP clients.") + shutdown_tasks = [] + for server_name, client in self.clients.items(): + logging.info(f"Initiating shutdown for MCP server: {server_name}") + shutdown_tasks.append(client.disconnect()) + + results = await asyncio.gather(*shutdown_tasks, return_exceptions=True) + + for server_name, result in zip(self.clients.keys(), results): + if isinstance(result, Exception): + logging.error(f"Error disconnecting from MCP server {server_name}: {result}") + if self.io and hasattr(self.io, 'tool_warning'): + self.io.tool_warning(f"Error during disconnect from MCP server '{server_name}': {result}") + else: + logging.info(f"Successfully disconnected from MCP server: {server_name}") + + self.clients.clear() + logging.info("All MCP clients have been processed for shutdown.") + + # TODO: Add methods for coder to interact with tools, e.g.: + # async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: + # pass + # This would require routing the call to the correct McpServer instance. From 4ee5085411240330f6dd2e46747127712d65618a Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:47:22 +0200 Subject: [PATCH 15/51] refactor: Move MCP tool init from coder to profile manager --- aider/coders/base_coder.py | 186 ++++++++++++++----------------- aider/mcp/mcp_client_pool.py | 96 +++++++++++----- aider/mcp/mcp_profile_manager.py | 21 +++- 3 files changed, 173 insertions(+), 130 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 3525f86a9e9..9e7bdd57b7d 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -343,7 +343,7 @@ def __init__( file_watcher=None, auto_copy_context=False, auto_accept_architect=True, - mcp_servers=None, + # mcp_servers is no longer used directly by BaseCoder for initialization mcp_profile_manager=None, ): # Fill in a dummy Analytics if needed, but it is never .enable()'d @@ -372,7 +372,7 @@ def __init__( self.detect_urls = detect_urls self.num_cache_warming_pings = num_cache_warming_pings - self.mcp_servers = mcp_servers + # self.mcp_servers = mcp_servers # No longer stored directly for initialization if not fnames: fnames = [] @@ -537,9 +537,9 @@ def __init__( self.auto_test = auto_test self.test_cmd = test_cmd - # Instantiate MCP tools - if self.mcp_servers: - self.initialize_mcp_tools() + # MCP tools are now initialized via MCPProfileManager when a profile is enabled. + # BaseCoder will get tools from the active pool. + # validate the functions jsonschema if self.functions: from jsonschema import Draft7Validator @@ -1652,12 +1652,16 @@ def process_tool_calls(self, tool_call_response): if tool_call_response is None: return False - tool_calls = tool_call_response.choices[0].message.tool_calls + llm_tool_calls = tool_call_response.choices[0].message.tool_calls + if not llm_tool_calls: + return False + # Collect all tool calls grouped by server - server_tool_calls = self._gather_server_tool_calls(tool_calls) + # The keys of server_tool_calls are McpServer instances + server_tool_calls_map = self._gather_server_tool_calls(llm_tool_calls) - if server_tool_calls and self.num_tool_calls < self.max_tool_calls: - self._print_tool_call_info(server_tool_calls) + if server_tool_calls_map and self.num_tool_calls < self.max_tool_calls: + self._print_tool_call_info(server_tool_calls_map) if self.io.confirm_ask("Run tools?"): tool_responses = self._execute_tool_calls(server_tool_calls) @@ -1691,124 +1695,102 @@ def _print_tool_call_info(self, server_tool_calls): self.io.tool_output("\n") - def _gather_server_tool_calls(self, tool_calls): - """Collect all tool calls grouped by server. + def _gather_server_tool_calls(self, llm_tool_calls: List[Any]) -> Dict[McpServer, List[Any]]: + """Collect all tool calls grouped by McpServer instance. Args: - tool_calls: List of tool calls from the LLM response + llm_tool_calls: List of tool_call objects from the LLM response. Returns: - dict: Dictionary mapping servers to their respective tool calls + dict: Dictionary mapping McpServer instances to a list of their respective tool_call objects. + """ + gathered_calls: Dict[McpServer, List[Any]] = {} + + if not self.mcp_profile_manager or not self.mcp_profile_manager.active_mcp_client_pool: + return gathered_calls + + active_pool = self.mcp_profile_manager.active_mcp_client_pool + # tools_by_server is Dict[server_name, List[tool_definition_dict]] + tools_by_server = active_pool.get_cached_tools_by_server() + # mcp_instances is Dict[server_name, McpServer_instance] + mcp_instances = active_pool.clients + + if not tools_by_server or not mcp_instances: + return gathered_calls + + for llm_tc in llm_tool_calls: + found_tool_for_llm_tc = False + for server_name, mcp_tool_definitions in tools_by_server.items(): + for mcp_tool_def in mcp_tool_definitions: + if mcp_tool_def.get("function", {}).get("name") == llm_tc.function.name: + target_server_instance = mcp_instances.get(server_name) + if target_server_instance: + if target_server_instance not in gathered_calls: + gathered_calls[target_server_instance] = [] + gathered_calls[target_server_instance].append(llm_tc) + found_tool_for_llm_tc = True + break # Found the definition for this llm_tc, move to next llm_tc + if found_tool_for_llm_tc: + break # Moved to next llm_tc + + return gathered_calls + + def _execute_tool_calls(self, server_tool_calls_map: Dict[McpServer, List[Any]]): + """Process tool calls from the response and execute them. + Args: + server_tool_calls_map: Dict mapping McpServer instances to their list of tool_calls. + Returns: + A list of tool response messages. """ - if not self.mcp_tools or len(self.mcp_tools) == 0: - return None - - server_tool_calls = {} - for tool_call in tool_calls: - # Check if this tool_call matches any MCP tool - for server_name, server_tools in self.mcp_tools: - for tool in server_tools: - if tool.get("function", {}).get("name") == tool_call.function.name: - # Find the McpServer instance that will be used for communication - for server in self.mcp_servers: - if server.name == server_name: - if server not in server_tool_calls: - server_tool_calls[server] = [] - server_tool_calls[server].append(tool_call) - break - - return server_tool_calls - - def _execute_tool_calls(self, tool_calls): - """Process tool calls from the response and execute them if they match MCP tools. - Returns a list of tool response messages.""" tool_responses = [] # Define the coroutine to execute all tool calls for a single server - async def _exec_server_tools(server, tool_calls_list): - tool_responses = [] + async def _exec_server_tools(mcp_server_instance: McpServer, tool_calls_for_server: List[Any]): + # Renamed 'server' to 'mcp_server_instance' for clarity + responses_for_server = [] try: # Connect to the server once - session = await server.connect() + session = await mcp_server_instance.connect() # Execute all tool calls for this server - for tool_call in tool_calls_list: + for tool_call_obj in tool_calls_for_server: call_result = await experimental_mcp_client.call_openai_tool( session=session, - openai_tool=tool_call, + openai_tool=tool_call_obj, # This is the tool_call object from LLM ) result_text = str(call_result.content[0].text) - tool_responses.append( - {"role": "tool", "tool_call_id": tool_call.id, "content": result_text} + responses_for_server.append( + {"role": "tool", "tool_call_id": tool_call_obj.id, "content": result_text} ) finally: - await server.disconnect() - return tool_responses + await mcp_server_instance.disconnect() + return responses_for_server # Execute all tool calls concurrently - async def _execute_all_tool_calls(): + async def _execute_all_tool_calls_async(): tasks = [] - for server, tool_calls_list in tool_calls.items(): - tasks.append(_exec_server_tools(server, tool_calls_list)) + for mcp_server_instance, tool_calls_for_server in server_tool_calls_map.items(): + tasks.append(_exec_server_tools(mcp_server_instance, tool_calls_for_server)) # Wait for all tasks to complete - results = await asyncio.gather(*tasks) - return results + results_list_of_lists = await asyncio.gather(*tasks, return_exceptions=True) + return results_list_of_lists # Run the async execution and collect results - if tool_calls: - all_results = asyncio.run(_execute_all_tool_calls()) + if server_tool_calls_map: + all_results_nested = asyncio.run(_execute_all_tool_calls_async()) # Flatten the results from all servers - for server_results in all_results: - tool_responses.extend(server_results) - + for server_batch_result in all_results_nested: + if isinstance(server_batch_result, Exception): + self.io.tool_error(f"Error executing batch of tool calls: {server_batch_result}") + # Potentially add a generic error tool_response for all tools in this batch + elif isinstance(server_batch_result, list): + tool_responses.extend(server_batch_result) + return tool_responses - def initialize_mcp_tools(self): - """ - Initialize tools from all configured MCP servers. MCP Servers that fail to be - initialized will not be available to the Coder instance. - """ - tools = [] - - async def get_server_tools(server): - try: - session = await server.connect() - server_tools = await experimental_mcp_client.load_mcp_tools( - session=session, format="openai" - ) - return (server.name, server_tools) - except Exception as e: - self.io.tool_warning(f"Error initializing MCP server {server.name}:\n{e}") - return None - finally: - await server.disconnect() - - async def get_all_server_tools(): - tasks = [get_server_tools(server) for server in self.mcp_servers] - results = await asyncio.gather(*tasks) - return [result for result in results if result is not None] - - if self.mcp_servers: - tools = asyncio.run(get_all_server_tools()) - - if len(tools) > 0: - self.io.tool_output("MCP servers configured:") - for server_name, server_tools in tools: - self.io.tool_output(f" - {server_name}") - - if self.verbose: - for tool in server_tools: - tool_name = tool.get("function", {}).get("name", "unknown") - tool_desc = tool.get("function", {}).get("description", "").split("\n")[0] - self.io.tool_output(f" - {tool_name}: {tool_desc}") - - self.mcp_tools = tools - - def get_tool_list(self): - """Get a flattened list of all MCP tools.""" - tool_list = [] - if self.mcp_tools: - for _, server_tools in self.mcp_tools: - tool_list.extend(server_tools) - return tool_list + def get_tool_list(self) -> List[Dict[str, Any]]: + """Get a flattened list of all MCP tools from the active profile.""" + if self.mcp_profile_manager and self.mcp_profile_manager.active_mcp_client_pool: + return self.mcp_profile_manager.active_mcp_client_pool.get_cached_tools_flat_list() + return [] def reply_completed(self): pass diff --git a/aider/mcp/mcp_client_pool.py b/aider/mcp/mcp_client_pool.py index 655e53404ae..0a696491a37 100644 --- a/aider/mcp/mcp_client_pool.py +++ b/aider/mcp/mcp_client_pool.py @@ -27,6 +27,7 @@ def __init__(self, io: Any, model: Any, edit_format: str, server_configs: List[D self.edit_format = edit_format self.server_configs = server_configs self.clients: Dict[str, McpServer] = {} + self.cached_tools_by_server: Optional[Dict[str, List[Dict[str, Any]]]] = None self._init_clients() def _init_clients(self): @@ -57,44 +58,85 @@ def _init_clients(self): if self.io and hasattr(self.io, 'tool_error'): self.io.tool_error(f"Failed to initialize MCP server '{server_name}': {e}") - async def get_all_tools(self, context: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + async def _fetch_tools_for_server(self, server_name: str, client: McpServer, context: Optional[Dict[str, Any]] = None) -> Optional[List[Dict[str, Any]]]: + """Helper to fetch tools for a single server.""" + from litellm import experimental_mcp_client # Local import to avoid circular dependency if mcp.py imports aider stuff + try: + session = await client.connect() + # The context parameter for load_mcp_tools is not standard, removing for now. + # If needed, it would be passed to experimental_get_tools if that was the target. + server_tools = await experimental_mcp_client.load_mcp_tools(session=session, format="openai") + logging.info(f"Retrieved {len(server_tools)} tools from {server_name}") + return server_tools + except Exception as e: + logging.error(f"Error getting tools from MCP server {server_name}: {e}") + if self.io and hasattr(self.io, 'tool_error'): + self.io.tool_error(f"Could not retrieve tools from MCP server '{server_name}': {e}") + return None + finally: + # Ensure client is disconnected even if tool fetching fails + await client.disconnect() + + + async def _fetch_tools_for_all_servers(self, context: Optional[Dict[str, Any]] = None) -> Dict[str, List[Dict[str, Any]]]: """ Connects to all MCP servers and retrieves their available tools. Args: - context: Optional context to pass to the MCP servers. + context: Optional context to pass to the MCP servers (currently unused by load_mcp_tools). Returns: - A list of tool definitions from all connected MCP servers. + A dictionary mapping server names to their list of tool definitions. """ - all_tools = [] - # TODO: Implement tool fetching logic. This will involve: - # 1. Iterating through self.clients. - # 2. For each client, ensuring it's connected (await client.connect()). - # 3. Calling a method on the client/session to get tools (e.g., await client.session.experimental_get_tools(context=context, format="openai_function")) - # 4. Aggregating the tools. - # 5. Handling potential errors during connection or tool retrieval for individual servers. - - # Placeholder implementation: + tools_by_server: Dict[str, List[Dict[str, Any]]] = {} + + tasks = [] + for server_name, client in self.clients.items(): + tasks.append(self._fetch_tools_for_server(server_name, client, context)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + for i, server_name in enumerate(self.clients.keys()): + result = results[i] + if isinstance(result, Exception): + logging.error(f"Exception fetching tools from {server_name}: {result}") + # Optionally store empty list or skip this server + elif result is not None: + tools_by_server[server_name] = result + + return tools_by_server + + async def fetch_and_cache_tools(self, context: Optional[Dict[str, Any]] = None): + """ + Fetches tools from all configured servers and caches them. + """ + if self.io and hasattr(self.io, 'tool_output'): + self.io.tool_output("Fetching tools from enabled MCP profile servers...") + self.cached_tools_by_server = await self._fetch_tools_for_all_servers(context) if self.io and hasattr(self.io, 'tool_output'): - self.io.tool_output("MCPClientPool.get_all_tools() is not fully implemented yet.") + num_tools = sum(len(tools) for tools in self.cached_tools_by_server.values()) + num_servers = len(self.cached_tools_by_server) + self.io.tool_output(f"Fetched {num_tools} tool(s) from {num_servers} server(s).") + + + def get_cached_tools_flat_list(self) -> List[Dict[str, Any]]: + """ + Returns a flat list of all cached tool definitions. + """ + if not self.cached_tools_by_server: + return [] - # Example of how one might connect and get tools (needs actual mcp.py library methods) - # for server_name, client in self.clients.items(): - # try: - # session = await client.connect() - # # Assuming a method like `experimental_get_tools` exists on the session - # # The actual method name and parameters might differ based on mcp.py - # server_tools = await session.experimental_get_tools(format="openai_function", context=context) - # if server_tools: - # all_tools.extend(server_tools) - # logging.info(f"Retrieved {len(server_tools)} tools from {server_name}") - # except Exception as e: - # logging.error(f"Error getting tools from MCP server {server_name}: {e}") - # if self.io and hasattr(self.io, 'tool_error'): - # self.io.tool_error(f"Could not retrieve tools from MCP server '{server_name}': {e}") + all_tools = [] + for server_tools in self.cached_tools_by_server.values(): + all_tools.extend(server_tools) return all_tools + def get_cached_tools_by_server(self) -> Optional[Dict[str, List[Dict[str, Any]]]]: + """ + Returns the cached tools, structured as a dictionary mapping server name to its tools. + """ + return self.cached_tools_by_server + def shutdown(self): """ Shuts down all MCP client connections. diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 057baa5f810..09f45acf347 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -204,7 +204,26 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): self.io.tool_error(f"No configured MCP servers found for profile '{profile_name}'. Check server names and .aider.conf.yml.") return - self.active_mcp_client_pool = MCPClientPool(self.io, main_model, main_edit_format, matched_server_configs) + pool = MCPClientPool(self.io, main_model, main_edit_format, matched_server_configs) + try: + # Run the async method to fetch tools in a blocking way + asyncio.run(pool.fetch_and_cache_tools()) + except RuntimeError as e: + if "cannot be called from a running event loop" in str(e): + loop = asyncio.get_event_loop() + if loop.is_running(): + # This case should ideally be avoided by design. + # If enable_profile is called from an async context, it should be awaited. + # For now, log a warning and proceed without tools if this rare case occurs. + self.io.tool_warning("Warning: Could not fetch MCP tools due to running asyncio event loop during profile enabling.") + else: + self.io.tool_error(f"Error fetching MCP tools: {e}") + return # Stop if asyncio.run fails not due to existing loop + else: + self.io.tool_error(f"Error fetching MCP tools: {e}") + return # Stop on other runtime errors + + self.active_mcp_client_pool = pool self.active_profile_name = profile_name self.io.tool_output(f"MCP profile '{profile_name}' enabled with {len(matched_server_configs)} server(s).") From 3141845bfe196c9e3b7cbea20389af55c0bf240a Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:47:43 +0200 Subject: [PATCH 16/51] fix: Import missing modules and fix tool call typo --- aider/coders/base_coder.py | 7 +++++-- aider/mcp/mcp_profile_manager.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 9e7bdd57b7d..96e9f5f6218 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1,5 +1,7 @@ #!/usr/bin/env python +import asyncio +import base64 import asyncio import base64 import hashlib @@ -25,7 +27,7 @@ Locale = None from json.decoder import JSONDecodeError from pathlib import Path -from typing import List +from typing import List, Dict, Any from litellm import experimental_mcp_client from rich.console import Console @@ -39,6 +41,7 @@ from aider.linter import Linter from aider.llm import litellm from aider.models import RETRY_TIMEOUT +from aider.mcp.server import McpServer from aider.reasoning_tags import ( REASONING_TAG, format_reasoning_content, @@ -1664,7 +1667,7 @@ def process_tool_calls(self, tool_call_response): self._print_tool_call_info(server_tool_calls_map) if self.io.confirm_ask("Run tools?"): - tool_responses = self._execute_tool_calls(server_tool_calls) + tool_responses = self._execute_tool_calls(server_tool_calls_map) # Add the assistant message with tool calls # Converting to a dict so it can be safely dumped to json diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 09f45acf347..6e57493d87a 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from typing import List, Dict, Optional, Tuple, Any from dataclasses import dataclass, asdict From d6b3c88e90f1a973e6497617b36a059e228f1a88 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 00:48:49 +0200 Subject: [PATCH 17/51] fix: Remove mcp_servers from kwargs in Coder.create --- aider/coders/base_coder.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 96e9f5f6218..1883774d0e3 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -197,6 +197,11 @@ def create( kwargs = use_kwargs from_coder.ok_to_warm_cache = False + # Ensure 'mcp_servers' is not passed as it's no longer a valid argument for Coder.__init__. + # This applies whether kwargs come from from_coder or directly to Coder.create. + if 'mcp_servers' in kwargs: + del kwargs['mcp_servers'] + for coder in coders.__all__: if hasattr(coder, "edit_format") and coder.edit_format == edit_format: res = coder(main_model, io, **kwargs) From 5b0bcb4f52eb89edc0da74e0c9b44b299fee70cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 May 2025 01:01:59 +0200 Subject: [PATCH 18/51] fix: Use load_mcp_servers to get configs for profile activation --- aider/mcp/mcp_profile_manager.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 6e57493d87a..aa996e3e1f9 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -110,13 +110,13 @@ def get_known_mcp_server_names( servers_from_file_arg = load_mcp_servers(mcp_servers=None, mcp_servers_file=mcpservers_file_arg, io=io, verbose=False) if servers_from_file_arg: loaded_mcp_objects.extend(servers_from_file_arg) - + # Extract unique names from the McpServer objects server_names_set = set() for server_obj in loaded_mcp_objects: if hasattr(server_obj, 'name') and isinstance(server_obj.name, str) and server_obj.name: server_names_set.add(server_obj.name) - + return sorted(list(server_names_set)) class MCPProfileManager: @@ -195,11 +195,23 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): self.active_mcp_client_pool = None # Ensure it's cleared return - all_server_configs = getattr(self.settings, 'mcpservers', []) - if not isinstance(all_server_configs, list): # Ensure it's a list if it was some other type - all_server_configs = [] - - matched_server_configs = [s_conf for s_conf in all_server_configs if isinstance(s_conf, dict) and s_conf.get('name') in profile_to_enable.server_names] + # Retrieve server configurations using the centralized load_mcp_servers function + mcpservers_arg_val = getattr(self.settings, 'mcp_servers', None) + mcpservers_file_arg_val = getattr(self.settings, 'mcp_servers_file', None) + verbose_setting = getattr(self.settings, 'verbose', False) + + all_mcp_server_objects = load_mcp_servers( + mcp_servers=mcpservers_arg_val, + mcp_servers_file=mcpservers_file_arg_val, + io=self.io, + verbose=verbose_setting + ) + + # Filter these McpServer objects by name and get their config dictionaries + matched_server_configs = [ + s_obj.config for s_obj in all_mcp_server_objects + if s_obj.name in profile_to_enable.server_names + ] if not matched_server_configs: self.io.tool_error(f"No configured MCP servers found for profile '{profile_name}'. Check server names and .aider.conf.yml.") From 59da4ffd571cb626053f6c3d7410a582f1ea56ac Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 03:18:30 +0200 Subject: [PATCH 19/51] feat: Reuse MCP profile manager on coder switch --- aider/coders/base_coder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 1883774d0e3..e860c537a7d 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -190,6 +190,7 @@ def create( total_tokens_sent=from_coder.total_tokens_sent, total_tokens_received=from_coder.total_tokens_received, file_watcher=from_coder.file_watcher, + mcp_profile_manager=from_coder.mcp_profile_manager, ) use_kwargs.update(update) # override to complete the switch use_kwargs.update(kwargs) # override passed kwargs From 675698eb471e7efcb1ac2e6587ab32837e4d21b0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 May 2025 09:27:26 +0200 Subject: [PATCH 20/51] Cleanup --- aider/coders/base_coder.py | 16 ++++------------ aider/main.py | 8 -------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index e860c537a7d..556059e7c43 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -127,7 +127,6 @@ class Coder: ignore_mentions = None chat_language = None file_watcher = None - mcp_servers = None mcp_tools = None mcp_profile_manager = None @@ -198,11 +197,6 @@ def create( kwargs = use_kwargs from_coder.ok_to_warm_cache = False - # Ensure 'mcp_servers' is not passed as it's no longer a valid argument for Coder.__init__. - # This applies whether kwargs come from from_coder or directly to Coder.create. - if 'mcp_servers' in kwargs: - del kwargs['mcp_servers'] - for coder in coders.__all__: if hasattr(coder, "edit_format") and coder.edit_format == edit_format: res = coder(main_model, io, **kwargs) @@ -352,7 +346,6 @@ def __init__( file_watcher=None, auto_copy_context=False, auto_accept_architect=True, - # mcp_servers is no longer used directly by BaseCoder for initialization mcp_profile_manager=None, ): # Fill in a dummy Analytics if needed, but it is never .enable()'d @@ -381,7 +374,6 @@ def __init__( self.detect_urls = detect_urls self.num_cache_warming_pings = num_cache_warming_pings - # self.mcp_servers = mcp_servers # No longer stored directly for initialization if not fnames: fnames = [] @@ -1664,7 +1656,7 @@ def process_tool_calls(self, tool_call_response): llm_tool_calls = tool_call_response.choices[0].message.tool_calls if not llm_tool_calls: return False - + # Collect all tool calls grouped by server # The keys of server_tool_calls are McpServer instances server_tool_calls_map = self._gather_server_tool_calls(llm_tool_calls) @@ -1716,7 +1708,7 @@ def _gather_server_tool_calls(self, llm_tool_calls: List[Any]) -> Dict[McpServer if not self.mcp_profile_manager or not self.mcp_profile_manager.active_mcp_client_pool: return gathered_calls - + active_pool = self.mcp_profile_manager.active_mcp_client_pool # tools_by_server is Dict[server_name, List[tool_definition_dict]] tools_by_server = active_pool.get_cached_tools_by_server() @@ -1740,7 +1732,7 @@ def _gather_server_tool_calls(self, llm_tool_calls: List[Any]) -> Dict[McpServer break # Found the definition for this llm_tc, move to next llm_tc if found_tool_for_llm_tc: break # Moved to next llm_tc - + return gathered_calls def _execute_tool_calls(self, server_tool_calls_map: Dict[McpServer, List[Any]]): @@ -1792,7 +1784,7 @@ async def _execute_all_tool_calls_async(): # Potentially add a generic error tool_response for all tools in this batch elif isinstance(server_batch_result, list): tool_responses.extend(server_batch_result) - + return tool_responses def get_tool_list(self) -> List[Dict[str, Any]]: diff --git a/aider/main.py b/aider/main.py index d7d4296eb10..161a2bf6f01 100644 --- a/aider/main.py +++ b/aider/main.py @@ -30,7 +30,6 @@ from aider.history import ChatSummary from aider.io import InputOutput from aider.llm import litellm # noqa: F401; properly init litellm on launch -from aider.mcp import load_mcp_servers from aider.mcp.mcp_profile_manager import MCPProfileManager from aider.models import ModelSettings from aider.onboarding import offer_openrouter_oauth, select_default_model @@ -971,12 +970,6 @@ def get_io(pretty): analytics.event("auto_commits", enabled=bool(args.auto_commits)) try: - # Load MCP servers from config string or file - mcp_servers = load_mcp_servers(args.mcp_servers, args.mcp_servers_file, io, args.verbose) - - if not mcp_servers: - mcp_servers = [] - coder = Coder.create( main_model=main_model, edit_format=args.edit_format, @@ -1009,7 +1002,6 @@ def get_io(pretty): detect_urls=args.detect_urls, auto_copy_context=args.copy_paste, auto_accept_architect=args.auto_accept_architect, - mcp_servers=mcp_servers, mcp_profile_manager=mcp_profile_manager, ) except UnknownEditFormat as err: From a2a5beb96fa3a6e24005a7794c3213697d4d956b Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 13:58:37 +0200 Subject: [PATCH 21/51] refactor: Remove unused mcp_tools and use profile manager --- aider/coders/base_coder.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 556059e7c43..d67149b4912 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -127,7 +127,6 @@ class Coder: ignore_mentions = None chat_language = None file_watcher = None - mcp_tools = None mcp_profile_manager = None @classmethod @@ -1222,7 +1221,7 @@ def fmt_system_prompt(self, prompt): else: quad_backtick_reminder = "" - if self.mcp_tools and len(self.mcp_tools) > 0: + if self.mcp_profile_manager and self.mcp_profile_manager.active_profile_name: final_reminders.append(self.gpt_prompts.tool_prompt) final_reminders = "\n\n".join(final_reminders) From 7a57a160bfc469b5b3d34bbb5402a0151238a2ce Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 May 2025 14:51:41 +0200 Subject: [PATCH 22/51] Use McpProfileManager to provide tool prompt --- aider/coders/base_coder.py | 6 ++++-- aider/mcp/mcp_profile_manager.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index d67149b4912..58032116462 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1221,8 +1221,10 @@ def fmt_system_prompt(self, prompt): else: quad_backtick_reminder = "" - if self.mcp_profile_manager and self.mcp_profile_manager.active_profile_name: - final_reminders.append(self.gpt_prompts.tool_prompt) + if self.mcp_profile_manager: + tool_prompt_from_mcp = self.mcp_profile_manager.get_tool_prompt_if_active() + if tool_prompt_from_mcp: + final_reminders.append(tool_prompt_from_mcp) final_reminders = "\n\n".join(final_reminders) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index aa996e3e1f9..592e6b4b82c 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -6,6 +6,8 @@ import json # Added from aider.mcp import load_mcp_servers # Added from aider.mcp.mcp_client_pool import MCPClientPool +from aider.coders.base_prompts import CoderPrompts + MCP_PROFILES_YAML_PATH = Path.home() / ".aider.mcp.profiles.yml" @@ -175,6 +177,15 @@ def list_profile_names(self) -> List[str]: def list_profiles_details(self) -> List[Tuple[str, List[str]]]: return [(profile.name, profile.server_names) for profile in self.profiles.values()] + def get_tool_prompt_if_active(self) -> Optional[str]: + """ + Returns the standard tool prompt string if an MCP profile is active, + otherwise None. + """ + if self.active_profile_name: + return CoderPrompts.tool_prompt + return None + def enable_profile(self, profile_name: str, main_model, main_edit_format): if self.active_profile_name == profile_name and self.active_mcp_client_pool: self.io.tool_output(f"MCP profile '{profile_name}' is already active.") From 965be2340474245e5b65f96869be5e615b0c1995 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 May 2025 14:52:09 +0200 Subject: [PATCH 23/51] Update tool prompt --- aider/coders/base_prompts.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/aider/coders/base_prompts.py b/aider/coders/base_prompts.py index cd93b9ef807..a3211669352 100644 --- a/aider/coders/base_prompts.py +++ b/aider/coders/base_prompts.py @@ -57,17 +57,16 @@ class CoderPrompts: no_shell_cmd_reminder = "" tool_prompt = """ - -When solving problems, you have special tools available. Please follow these rules: +You have MCP (Model Context Protocol) tools available. +Follow these rules: 1. Always use the exact format required for each tool and include all needed information. 2. Only use tools that are currently available in this conversation. -3. Don't mention tool names when talking to people. Say "I'll check your code" instead - of "I'll use the code_analyzer tool." -4. Only use tools when necessary. If you know the answer, just respond directly. +3. Don't mention tool names unless explicitly requested. For example, if a code_analyzer tool is available, say "I'll check your code" before making the tool call instead of "I'll use the code_analyzer tool." unless explicitly requested. +4. Only use tools when necessary. If you know the answer, just respond directly unless explicitly requested. 5. Before using any tool, briefly explain why you need to use it. - """ rename_with_shell = "" go_ahead_tip = "" + From 67d609b76ea4b2aab775904db771280034611e90 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 15:15:56 +0200 Subject: [PATCH 24/51] refactor: Print MCP tool calls on single line refactor: Remove redundant MCP tool preparation message colors --- aider/coders/base_coder.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 58032116462..ea6dfb7a27e 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1681,21 +1681,21 @@ def process_tool_calls(self, tool_call_response): self.io.tool_warning(f"Only {self.max_tool_calls} tool calls allowed, stopping.") return False - def _print_tool_call_info(self, server_tool_calls): - """Print information about an MCP tool call.""" - self.io.tool_output("Preparing to run MCP tools", bold=True) - - for server, tool_calls in server_tool_calls.items(): + def _print_tool_call_info(self, server_tool_calls_map): + """Print information about an MCP tool call on a single line.""" + for server, tool_calls in server_tool_calls_map.items(): for tool_call in tool_calls: - self.io.tool_output(f"Tool Call: {tool_call.function.name}") - self.io.tool_output(f"Arguments: {tool_call.function.arguments}") - self.io.tool_output(f"MCP Server: {server.name}") + output_str = ( + f"[reverse][bold]MCP[/bold][/reverse] [yellow]{server.name}:" + f" {tool_call.function.name}[/yellow] ({tool_call.function.arguments})" + ) + self.io.tool_output(output_str) if self.verbose: - self.io.tool_output(f"Tool ID: {tool_call.id}") - self.io.tool_output(f"Tool type: {tool_call.type}") - - self.io.tool_output("\n") + self.io.tool_output( + f" (Verbose: ID={tool_call.id}, Type={tool_call.type})" + ) + self.io.tool_output("") # Add a newline after all tool call infos def _gather_server_tool_calls(self, llm_tool_calls: List[Any]) -> Dict[McpServer, List[Any]]: """Collect all tool calls grouped by McpServer instance. From e5c431c579bbb08e3fda4ee6e390684247cccf6b Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 15:29:38 +0200 Subject: [PATCH 25/51] feat: Handle Text objects in tool_output method fix: Parse Rich markup in tool output strings --- aider/io.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/aider/io.py b/aider/io.py index f02ed2b167f..8b3e4400990 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1031,22 +1031,41 @@ def tool_warning(self, message="", strip=True): def tool_output(self, *messages, log_only=False, bold=False): if messages: - hist = " ".join(messages) + hist_parts = [] + for m in messages: + if isinstance(m, Text): + hist_parts.append(m.plain) + else: + hist_parts.append(str(m)) + hist = " ".join(hist_parts) hist = f"{hist.strip()}" self.append_chat_history(hist, linebreak=True, blockquote=True) if log_only: return - messages = list(map(Text, messages)) - style = dict() + processed_messages = [] + for m in messages: + if not isinstance(m, Text): + # If m is a string, parse it for Rich markup + processed_messages.append(Text.from_markup(str(m))) + else: + # If m is already a Text object, use it as is + processed_messages.append(m) + + style_args = {} if self.pretty: if self.tool_output_color: - style["color"] = ensure_hash_prefix(self.tool_output_color) - style["reverse"] = bold + style_args["color"] = ensure_hash_prefix(self.tool_output_color) + if bold: # bold is a boolean, reverse is the RichStyle attribute + style_args["reverse"] = True + + style = RichStyle(**style_args) if style_args else None - style = RichStyle(**style) - self.console.print(*messages, style=style) + if style: + self.console.print(*processed_messages, style=style) + else: + self.console.print(*processed_messages) def get_assistant_mdstream(self): mdargs = dict(style=self.assistant_output_color, code_theme=self.code_theme) From e7686494c2b4aad3bd60fcbb7f957f13af98f794 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 17:25:13 +0200 Subject: [PATCH 26/51] refactor: Refactor MCP profiles to use 'servers' list with 'no_confirm' option refactor: Create constants for MCP profile YAML keys fix: Update cmd_mcp to display server names from list of dicts --- aider/commands.py | 5 +- aider/mcp/mcp_profile_manager.py | 95 ++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 13711ee5599..d790025cb78 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1604,9 +1604,10 @@ def cmd_mcp(self, args_str: str): profiles_details = self.mcp_profile_manager.list_profiles_details() self.io.tool_output("Available MCP profiles:") if profiles_details: - for name, servers in profiles_details: + for name, server_configs in profiles_details: + server_names_list = [s_conf.get("name", "UnknownServer") for s_conf in server_configs] self.io.tool_output( - f" - {name}: {', '.join(servers) if servers else ' (no servers)'}" + f" - {name}: {', '.join(server_names_list) if server_names_list else ' (no servers)'}" ) else: self.io.tool_output(" (No profiles defined)") diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 592e6b4b82c..91d3ba89ac6 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -11,10 +11,19 @@ MCP_PROFILES_YAML_PATH = Path.home() / ".aider.mcp.profiles.yml" +# Constants for YAML keys +PROFILE_NAME_KEY = "name" +PROFILE_SERVERS_KEY = "servers" +SERVER_NAME_KEY = "name" +SERVER_NO_CONFIRM_KEY = "no_confirm" +# Old key, for removal during load if present +OLD_PROFILE_SERVER_NAMES_KEY = "server_names" + + @dataclass class MCPProfile: name: str - server_names: List[str] + servers: List[Dict[str, Any]] # Each dict: {'name': str, 'no_confirm': bool} def load_mcp_profiles() -> Dict[str, MCPProfile]: try: @@ -27,22 +36,57 @@ def load_mcp_profiles() -> Dict[str, MCPProfile]: print(f"Warning: Skipping non-dictionary profile entry: {profile_dict}") continue try: - # Ensure server_names is a list. - loaded_server_names = profile_dict.get('server_names') - if not isinstance(loaded_server_names, list): - profile_name = profile_dict.get('name', 'UnknownProfile') - if loaded_server_names is not None: # Log if it was present but wrong type - print( - f"Warning: 'server_names' for profile '{profile_name}' is not a list" - f" (type: {type(loaded_server_names)}). Initializing as empty list." - ) - profile_dict['server_names'] = [] # Default to empty list - - profile = MCPProfile(**profile_dict) + # Ensure servers is a list of dicts with name and no_confirm. + loaded_servers_data = profile_dict.get(PROFILE_SERVERS_KEY) + parsed_servers_list = [] + profile_name_for_error = profile_dict.get(PROFILE_NAME_KEY, 'UnknownProfile') + + if isinstance(loaded_servers_data, list): + for server_entry in loaded_servers_data: + if isinstance(server_entry, str): + parsed_servers_list.append({SERVER_NAME_KEY: server_entry, SERVER_NO_CONFIRM_KEY: False}) + elif isinstance(server_entry, dict): + s_name = server_entry.get(SERVER_NAME_KEY) + if not isinstance(s_name, str) or not s_name: + print(f"Warning: Server entry in profile '{profile_name_for_error}' is missing a valid '{SERVER_NAME_KEY}': {server_entry}. Skipping this server.") + continue + s_no_confirm = server_entry.get(SERVER_NO_CONFIRM_KEY, False) + if not isinstance(s_no_confirm, bool): + print(f"Warning: '{SERVER_NO_CONFIRM_KEY}' for server '{s_name}' in profile '{profile_name_for_error}' is not a boolean (type: {type(s_no_confirm)}). Defaulting to False.") + s_no_confirm = False + parsed_servers_list.append({SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: s_no_confirm}) + else: + print(f"Warning: Invalid server entry type in profile '{profile_name_for_error}': {server_entry} (type: {type(server_entry)}). Skipping this server.") + continue + elif loaded_servers_data is not None: # It was present but not a list + print( + f"Warning: '{PROFILE_SERVERS_KEY}' for profile '{profile_name_for_error}' is not a list" + f" (type: {type(loaded_servers_data)}). Initializing as empty list." + ) + # parsed_servers_list remains empty + + profile_dict[PROFILE_SERVERS_KEY] = parsed_servers_list # Store the parsed list + if OLD_PROFILE_SERVER_NAMES_KEY in profile_dict: # Remove old key if present for clean object creation + del profile_dict[OLD_PROFILE_SERVER_NAMES_KEY] + + # Ensure 'name' key is present for MCPProfile dataclass + if PROFILE_NAME_KEY not in profile_dict: + print(f"Warning: Profile entry is missing '{PROFILE_NAME_KEY}': {profile_dict}. Skipping this profile.") + continue + + + # Rename 'servers' to 'servers' for MCPProfile dataclass if needed (already done by key const) + # profile_dict_for_dataclass = { + # 'name': profile_dict[PROFILE_NAME_KEY], + # 'servers': profile_dict[PROFILE_SERVERS_KEY] + # } + + + profile = MCPProfile(**profile_dict) profiles[profile.name] = profile - except TypeError as e: # Catches issues like missing 'name' or other fields - profile_name_for_error = profile_dict.get('name', str(profile_dict)) - print(f"Warning: Skipping malformed profile entry for '{profile_name_for_error}': {e}") + except TypeError as e: # Catches issues like missing 'name' or other fields for MCPProfile + profile_name_display = profile_dict.get(PROFILE_NAME_KEY, str(profile_dict)) + print(f"Warning: Skipping malformed profile entry for '{profile_name_display}': {e}") return profiles elif profiles_data is None: # File is empty return {} @@ -145,19 +189,20 @@ def load_or_initialize_profiles(self): old_all_profile_server_names_sorted = None if "all" in self.profiles: - # self.profiles["all"].server_names is guaranteed to be a list by robust load_mcp_profiles - old_all_profile_server_names_sorted = sorted(self.profiles["all"].server_names) + # self.profiles["all"].servers is List[Dict[str, Any]] + old_all_profile_server_names_sorted = sorted([s[SERVER_NAME_KEY] for s in self.profiles["all"].servers]) # Create/Update the 'all' profile in the in-memory dictionary to match current reality. # This ensures self.profiles["all"] is always up-to-date after this step. - self.profiles["all"] = MCPProfile(name="all", server_names=current_known_server_names) + current_servers_as_dicts = [{SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: False} for s_name in current_known_server_names] + self.profiles["all"] = MCPProfile(name="all", servers=current_servers_as_dicts) if old_all_profile_server_names_sorted is None: # The 'all' profile was not in the loaded file, so we created it. needs_save = True if self.io and hasattr(self.io, 'tool_output'): self.io.tool_output("Initialized default MCP profile 'all'.") - elif old_all_profile_server_names_sorted != current_known_server_names: + elif old_all_profile_server_names_sorted != current_known_server_names: # Compare with sorted list of names # The 'all' profile was in the loaded file, but its servers differed from current reality. needs_save = True if self.io and hasattr(self.io, 'tool_output'): @@ -174,8 +219,8 @@ def get_profile(self, name: str) -> Optional[MCPProfile]: def list_profile_names(self) -> List[str]: return list(self.profiles.keys()) - def list_profiles_details(self) -> List[Tuple[str, List[str]]]: - return [(profile.name, profile.server_names) for profile in self.profiles.values()] + def list_profiles_details(self) -> List[Tuple[str, List[Dict[str, Any]]]]: + return [(profile.name, profile.servers) for profile in self.profiles.values()] def get_tool_prompt_if_active(self) -> Optional[str]: """ @@ -200,7 +245,8 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): self.io.tool_error(f"MCP profile '{profile_name}' not found.") return - if not profile_to_enable.server_names: + server_configs_in_profile = profile_to_enable.servers + if not server_configs_in_profile: self.io.tool_warning(f"MCP profile '{profile_name}' has no servers configured. Profile enabled but no connections made.") self.active_profile_name = profile_name self.active_mcp_client_pool = None # Ensure it's cleared @@ -219,9 +265,10 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): ) # Filter these McpServer objects by name and get their config dictionaries + profile_server_names = [s_conf[SERVER_NAME_KEY] for s_conf in server_configs_in_profile] matched_server_configs = [ s_obj.config for s_obj in all_mcp_server_objects - if s_obj.name in profile_to_enable.server_names + if s_obj.name in profile_server_names ] if not matched_server_configs: From e8979e5fc71804b4aab5ae2d04466bfa29e9e2c3 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 17:30:33 +0200 Subject: [PATCH 27/51] refactor: Use per-server tool confirmation based on profile refactor: Encapsulate server no_confirm check in profile manager --- aider/coders/base_coder.py | 46 +++++++++++++++++++++++++++----- aider/mcp/mcp_profile_manager.py | 19 +++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index ea6dfb7a27e..72ecb6346c8 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1665,18 +1665,52 @@ def process_tool_calls(self, tool_call_response): if server_tool_calls_map and self.num_tool_calls < self.max_tool_calls: self._print_tool_call_info(server_tool_calls_map) - if self.io.confirm_ask("Run tools?"): - tool_responses = self._execute_tool_calls(server_tool_calls_map) + approved_server_tool_calls_map: Dict[McpServer, List[Any]] = {} + any_tool_cancelled = False - # Add the assistant message with tool calls - # Converting to a dict so it can be safely dumped to json + for mcp_server_instance, tool_calls_for_server in server_tool_calls_map.items(): + server_name = mcp_server_instance.name + server_no_confirm = False # Default to needing confirmation + + if self.mcp_profile_manager: + server_no_confirm = self.mcp_profile_manager.is_server_no_confirm(server_name) + + proceed_for_this_server = False + if server_no_confirm: + proceed_for_this_server = True + else: + if self.io.confirm_ask(f"Run tools for server '{server_name}'?"): + proceed_for_this_server = True + else: + self.io.tool_output(f"Tool execution for server '{server_name}' cancelled by user.") + any_tool_cancelled = True + + if proceed_for_this_server: + approved_server_tool_calls_map[mcp_server_instance] = tool_calls_for_server + + if approved_server_tool_calls_map: + tool_responses = self._execute_tool_calls(approved_server_tool_calls_map) + + # Add the assistant message with ALL original tool calls + # (even if some were cancelled, the LLM requested them) self.cur_messages.append(tool_call_response.choices[0].message.to_dict()) - # Add all tool responses + # Add responses only for the tools that were actually executed for tool_response in tool_responses: self.cur_messages.append(tool_response) + + return True # Indicates that some tools were processed or attempted + elif any_tool_cancelled: + # All tools were cancelled by the user, but we should still record the LLM's attempt + self.cur_messages.append(tool_call_response.choices[0].message.to_dict()) + # Add a generic response indicating cancellation if needed, or just return False + # For now, returning False implies no *successful* tool execution path was taken. + return False + else: + # No tools to execute (e.g., map was empty initially, or all were filtered out for other reasons) + return False + - return True elif self.num_tool_calls >= self.max_tool_calls: self.io.tool_warning(f"Only {self.max_tool_calls} tool calls allowed, stopping.") return False diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 91d3ba89ac6..9a76f44e4c4 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -222,6 +222,25 @@ def list_profile_names(self) -> List[str]: def list_profiles_details(self) -> List[Tuple[str, List[Dict[str, Any]]]]: return [(profile.name, profile.servers) for profile in self.profiles.values()] + def is_server_no_confirm(self, server_name_to_check: str) -> bool: + """ + Checks if a specific server in the currently active profile is set to 'no_confirm'. + Defaults to False (meaning, confirmation is required) if the profile is not active, + the server is not found in the profile, or 'no_confirm' is not set/False. + """ + if not self.active_profile_name: + return False + + active_profile = self.get_profile(self.active_profile_name) + if not active_profile: + return False + + for server_config in active_profile.servers: + if server_config.get(SERVER_NAME_KEY) == server_name_to_check: + return server_config.get(SERVER_NO_CONFIRM_KEY, False) + + return False # Server not found in profile or no_confirm not set + def get_tool_prompt_if_active(self) -> Optional[str]: """ Returns the standard tool prompt string if an MCP profile is active, From c57b79196b9ba372bd24f5ab984773713a871f59 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 17:44:56 +0200 Subject: [PATCH 28/51] fix: Preserve no_confirm in 'all' MCP profile on refresh --- aider/mcp/mcp_profile_manager.py | 48 ++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 9a76f44e4c4..cb6f090c919 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -186,30 +186,42 @@ def load_or_initialize_profiles(self): ) needs_save = False + old_all_profile_data = self.profiles.get("all") # This is an MCPProfile object or None + + # Construct the new 'all' profile's server list, preserving no_confirm where possible + new_all_profile_servers_list = [] + for s_name in current_known_server_names: + no_confirm_val = False # Default + if old_all_profile_data: + # Check if this server was in the old 'all' profile + existing_server_config = next( + (s_conf for s_conf in old_all_profile_data.servers if s_conf.get(SERVER_NAME_KEY) == s_name), + None + ) + if existing_server_config: + no_confirm_val = existing_server_config.get(SERVER_NO_CONFIRM_KEY, False) + new_all_profile_servers_list.append({SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: no_confirm_val}) + + # Sort for consistent comparison later + new_all_profile_servers_list_sorted = sorted(new_all_profile_servers_list, key=lambda x: x[SERVER_NAME_KEY]) - old_all_profile_server_names_sorted = None - if "all" in self.profiles: - # self.profiles["all"].servers is List[Dict[str, Any]] - old_all_profile_server_names_sorted = sorted([s[SERVER_NAME_KEY] for s in self.profiles["all"].servers]) - - # Create/Update the 'all' profile in the in-memory dictionary to match current reality. - # This ensures self.profiles["all"] is always up-to-date after this step. - current_servers_as_dicts = [{SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: False} for s_name in current_known_server_names] - self.profiles["all"] = MCPProfile(name="all", servers=current_servers_as_dicts) + # Create/Update the 'all' profile in the in-memory dictionary + self.profiles["all"] = MCPProfile(name="all", servers=new_all_profile_servers_list_sorted) - if old_all_profile_server_names_sorted is None: + if old_all_profile_data is None: # The 'all' profile was not in the loaded file, so we created it. needs_save = True if self.io and hasattr(self.io, 'tool_output'): self.io.tool_output("Initialized default MCP profile 'all'.") - elif old_all_profile_server_names_sorted != current_known_server_names: # Compare with sorted list of names - # The 'all' profile was in the loaded file, but its servers differed from current reality. - needs_save = True - if self.io and hasattr(self.io, 'tool_output'): - self.io.tool_output("Updated MCP profile 'all' with current server configurations.") - # If old_all_profile_server_names_sorted == current_known_server_names, then the loaded 'all' profile - # was already correct. No message, needs_save remains False. - + else: + # The 'all' profile was in the loaded file. Check if it changed. + # Sort old server data for consistent comparison + old_all_profile_servers_list_sorted = sorted(old_all_profile_data.servers, key=lambda x: x[SERVER_NAME_KEY]) + if old_all_profile_servers_list_sorted != new_all_profile_servers_list_sorted: + needs_save = True + if self.io and hasattr(self.io, 'tool_output'): + self.io.tool_output("Updated MCP profile 'all' with current server configurations (names or no_confirm values changed).") + if needs_save: save_mcp_profiles(self.profiles) From 4b6f3c9aba8ce8f59779a41898a2d7bd7f8a4d73 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 18:00:10 +0200 Subject: [PATCH 29/51] feat: add /mcp new command to create profiles --- aider/commands.py | 35 +++++++++++++++++++++++++++++--- aider/mcp/mcp_profile_manager.py | 25 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index d790025cb78..8989d8fc28e 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -12,6 +12,7 @@ from PIL import Image, ImageGrab from prompt_toolkit.completion import Completion, PathCompleter from prompt_toolkit.document import Document +from prompt_toolkit.shortcuts import checkboxlist_dialog from aider import models, prompts, voice from aider.editor import pipe_editor @@ -1619,17 +1620,45 @@ def cmd_mcp(self, args_str: str): self.io.tool_output("\nNo MCP profile is currently active.") self.io.tool_output("\nCommands:") + self.io.tool_output(" /mcp new - Create a new MCP profile") self.io.tool_output(" /mcp enable - Enable an MCP profile") self.io.tool_output(" /mcp disable - Disable the active MCP profile") # Add more subcommands as they are implemented - # self.io.tool_output(" /mcp create [server_name ...]") # self.io.tool_output(" /mcp delete ") # self.io.tool_output(" /mcp add ") # self.io.tool_output(" /mcp remove ") return sub_command = args[0] - if sub_command == "enable": + if sub_command == "new": + if len(args) > 1: + profile_name = args[1] + if self.mcp_profile_manager.get_profile(profile_name): + self.io.tool_error(f"Profile '{profile_name}' already exists.") + return + + known_server_names = self.mcp_profile_manager.get_known_server_names() + if not known_server_names: + self.io.tool_error("No MCP servers found. Configure servers first (e.g., in .aider.conf.yml).") + return + + dialog_choices = [(name, name) for name in known_server_names] + + selected_servers = checkboxlist_dialog( + title=f"Create MCP Profile: {profile_name}", + text="Select servers to include in this profile:", + values=dialog_choices, + ).run() + + if selected_servers: + self.mcp_profile_manager.create_new_profile(profile_name, selected_servers) + self.io.tool_output(f"MCP profile '{profile_name}' created with {len(selected_servers)} server(s).") + else: + self.io.tool_output("No servers selected. Profile not created.") + else: + self.io.tool_error("Usage: /mcp new ") + + elif sub_command == "enable": if len(args) > 1: profile_name = args[1] self.mcp_profile_manager.enable_profile(profile_name, self.coder.main_model, self.coder.main_model.edit_format) @@ -1642,7 +1671,7 @@ def cmd_mcp(self, args_str: str): self.io.tool_error("Usage: /mcp disable") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") - self.io.tool_output("Valid subcommands are: enable, disable") + self.io.tool_output("Valid subcommands are: new, enable, disable") def cmd_copy_context(self, args=None): diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index cb6f090c919..e66819f2bb6 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -234,6 +234,31 @@ def list_profile_names(self) -> List[str]: def list_profiles_details(self) -> List[Tuple[str, List[Dict[str, Any]]]]: return [(profile.name, profile.servers) for profile in self.profiles.values()] + def get_known_server_names(self) -> List[str]: + """Helper to get current known server names based on settings.""" + mcpservers_arg_val = getattr(self.settings, 'mcp_servers', None) + mcpservers_file_arg_val = getattr(self.settings, 'mcp_servers_file', None) + return get_known_mcp_server_names( + mcpservers_arg_val, + mcpservers_file_arg_val, + self.io + ) + + def create_new_profile(self, profile_name: str, selected_server_names: List[str]): + if profile_name in self.profiles: + self.io.tool_error(f"Profile '{profile_name}' already exists. Cannot create.") + return + + servers_config_list = [] + for server_name in selected_server_names: + servers_config_list.append({SERVER_NAME_KEY: server_name, SERVER_NO_CONFIRM_KEY: False}) + + new_profile = MCPProfile(name=profile_name, servers=servers_config_list) + self.profiles[profile_name] = new_profile + save_mcp_profiles(self.profiles) + self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") + + def is_server_no_confirm(self, server_name_to_check: str) -> bool: """ Checks if a specific server in the currently active profile is set to 'no_confirm'. From 857c8a09e2992917e10f37d4a8fa1be3ec72e38a Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 18:13:08 +0200 Subject: [PATCH 30/51] feat: Add two-step dialog for /mcp new command --- aider/commands.py | 82 ++++++++++++++++++++++++++++---- aider/mcp/mcp_profile_manager.py | 9 ++-- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 8989d8fc28e..eba2e1e2d98 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1642,19 +1642,83 @@ def cmd_mcp(self, args_str: str): self.io.tool_error("No MCP servers found. Configure servers first (e.g., in .aider.conf.yml).") return - dialog_choices = [(name, name) for name in known_server_names] - - selected_servers = checkboxlist_dialog( - title=f"Create MCP Profile: {profile_name}", + # First dialog: Select servers + server_selection_choices = [(name, name) for name in known_server_names] + selected_server_names = checkboxlist_dialog( + title=f"Create MCP Profile: {profile_name} (Step 1/2)", text="Select servers to include in this profile:", - values=dialog_choices, + values=server_selection_choices, ).run() - if selected_servers: - self.mcp_profile_manager.create_new_profile(profile_name, selected_servers) - self.io.tool_output(f"MCP profile '{profile_name}' created with {len(selected_servers)} server(s).") + if not selected_server_names: + self.io.tool_output("Profile creation cancelled or no servers selected.") + return + + # Second dialog: Configure no_confirm for selected servers + no_confirm_checkboxes = [Checkbox() for _ in selected_server_names] + dialog_body_elements = [] + for i, server_name in enumerate(selected_server_names): + dialog_body_elements.append( + HSplit( + [ + Label(text=f"{server_name:<30}"), # Adjust width as needed + Label(text="No Confirm:"), + no_confirm_checkboxes[i], + ] + ) + ) + + dialog_content = VSplit(dialog_body_elements, padding=1) + + def ok_handler_no_confirm(): + servers_with_no_confirm_config = [] + for i, server_name in enumerate(selected_server_names): + servers_with_no_confirm_config.append( + { + "name": server_name, + "no_confirm": no_confirm_checkboxes[i].checked, + } + ) + return servers_with_no_confirm_config + + def final_ok_handler_no_confirm(): + selected_config = ok_handler_no_confirm() + get_app().exit(result=selected_config) + + def final_cancel_handler_no_confirm(): + get_app().exit(result=None) + + dialog_container_no_confirm = Dialog( + title=f"Create MCP Profile: {profile_name} (Step 2/2)", + body=dialog_content, + buttons=[ + Button(text="OK", handler=final_ok_handler_no_confirm), + Button(text="Cancel", handler=final_cancel_handler_no_confirm), + ], + width=D(preferred=80), # Adjust width as needed + ) + + kb_no_confirm = KeyBindings() + @kb_no_confirm.add("escape", eager=True) + @kb_no_confirm.add("c-c", eager=True) + @kb_no_confirm.add("c-q", eager=True) + def _(event): + event.app.exit(result=None) + + application_no_confirm = Application( + layout=Layout(dialog_container_no_confirm), + key_bindings=kb_no_confirm, + mouse_support=True, + full_screen=True, + ) + + final_selected_servers_config = application_no_confirm.run() + + if final_selected_servers_config: + self.mcp_profile_manager.create_new_profile(profile_name, final_selected_servers_config) + self.io.tool_output(f"MCP profile '{profile_name}' created with {len(final_selected_servers_config)} server(s).") else: - self.io.tool_output("No servers selected. Profile not created.") + self.io.tool_output("Profile creation cancelled during no_confirm configuration.") else: self.io.tool_error("Usage: /mcp new ") diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index e66819f2bb6..8840c7bcad4 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -244,16 +244,15 @@ def get_known_server_names(self) -> List[str]: self.io ) - def create_new_profile(self, profile_name: str, selected_server_names: List[str]): + def create_new_profile(self, profile_name: str, selected_servers_config: List[Dict[str, Any]]): if profile_name in self.profiles: self.io.tool_error(f"Profile '{profile_name}' already exists. Cannot create.") return - servers_config_list = [] - for server_name in selected_server_names: - servers_config_list.append({SERVER_NAME_KEY: server_name, SERVER_NO_CONFIRM_KEY: False}) + # selected_servers_config is already in the format: [{'name': str, 'no_confirm': bool}, ...] + # So, we can use it directly. - new_profile = MCPProfile(name=profile_name, servers=servers_config_list) + new_profile = MCPProfile(name=profile_name, servers=selected_servers_config) self.profiles[profile_name] = new_profile save_mcp_profiles(self.profiles) self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") From 0ed6545ecc8e57c6e932fd14c8079bf1d73b42b4 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 18:13:36 +0200 Subject: [PATCH 31/51] fix: Import prompt_toolkit components for mcp command dialogs --- aider/commands.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/aider/commands.py b/aider/commands.py index eba2e1e2d98..0f5b16b96f1 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -13,6 +13,13 @@ from prompt_toolkit.completion import Completion, PathCompleter from prompt_toolkit.document import Document from prompt_toolkit.shortcuts import checkboxlist_dialog +from prompt_toolkit.shortcuts.dialogs import Dialog, Button +from prompt_toolkit.layout.containers import VSplit, HSplit, Window +from prompt_toolkit.widgets import Checkbox, Label +from prompt_toolkit.layout.dimension import D +from prompt_toolkit.application import Application, get_app +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.key_binding import KeyBindings from aider import models, prompts, voice from aider.editor import pipe_editor From 0bc906baaa2e1b0a34f97e410941b7359437600f Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 18:15:04 +0200 Subject: [PATCH 32/51] refactor: Reuse checkboxlist_dialog for /mcp new step 2 --- aider/commands.py | 87 ++++++++++++----------------------------------- 1 file changed, 21 insertions(+), 66 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 0f5b16b96f1..bf349378733 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -13,13 +13,6 @@ from prompt_toolkit.completion import Completion, PathCompleter from prompt_toolkit.document import Document from prompt_toolkit.shortcuts import checkboxlist_dialog -from prompt_toolkit.shortcuts.dialogs import Dialog, Button -from prompt_toolkit.layout.containers import VSplit, HSplit, Window -from prompt_toolkit.widgets import Checkbox, Label -from prompt_toolkit.layout.dimension import D -from prompt_toolkit.application import Application, get_app -from prompt_toolkit.layout.layout import Layout -from prompt_toolkit.key_binding import KeyBindings from aider import models, prompts, voice from aider.editor import pipe_editor @@ -1662,70 +1655,32 @@ def cmd_mcp(self, args_str: str): return # Second dialog: Configure no_confirm for selected servers - no_confirm_checkboxes = [Checkbox() for _ in selected_server_names] - dialog_body_elements = [] - for i, server_name in enumerate(selected_server_names): - dialog_body_elements.append( - HSplit( - [ - Label(text=f"{server_name:<30}"), # Adjust width as needed - Label(text="No Confirm:"), - no_confirm_checkboxes[i], - ] - ) - ) + no_confirm_choices = [(name, name) for name in selected_server_names] - dialog_content = VSplit(dialog_body_elements, padding=1) - - def ok_handler_no_confirm(): - servers_with_no_confirm_config = [] - for i, server_name in enumerate(selected_server_names): - servers_with_no_confirm_config.append( - { - "name": server_name, - "no_confirm": no_confirm_checkboxes[i].checked, - } - ) - return servers_with_no_confirm_config - - def final_ok_handler_no_confirm(): - selected_config = ok_handler_no_confirm() - get_app().exit(result=selected_config) - - def final_cancel_handler_no_confirm(): - get_app().exit(result=None) - - dialog_container_no_confirm = Dialog( + # checkboxlist_dialog returns None if Cancel is chosen + servers_for_no_confirm = checkboxlist_dialog( title=f"Create MCP Profile: {profile_name} (Step 2/2)", - body=dialog_content, - buttons=[ - Button(text="OK", handler=final_ok_handler_no_confirm), - Button(text="Cancel", handler=final_cancel_handler_no_confirm), - ], - width=D(preferred=80), # Adjust width as needed - ) + text="Select servers that should NOT require confirmation for tool use:", + values=no_confirm_choices, + ).run() + + # If the second dialog was cancelled, treat it as cancelling the whole operation + if servers_for_no_confirm is None: + self.io.tool_output("Profile creation cancelled during no_confirm configuration.") + return - kb_no_confirm = KeyBindings() - @kb_no_confirm.add("escape", eager=True) - @kb_no_confirm.add("c-c", eager=True) - @kb_no_confirm.add("c-q", eager=True) - def _(event): - event.app.exit(result=None) - - application_no_confirm = Application( - layout=Layout(dialog_container_no_confirm), - key_bindings=kb_no_confirm, - mouse_support=True, - full_screen=True, - ) + final_selected_servers_config = [] + for server_name in selected_server_names: + final_selected_servers_config.append( + { + "name": server_name, + "no_confirm": server_name in servers_for_no_confirm, + } + ) - final_selected_servers_config = application_no_confirm.run() + self.mcp_profile_manager.create_new_profile(profile_name, final_selected_servers_config) + self.io.tool_output(f"MCP profile '{profile_name}' created with {len(final_selected_servers_config)} server(s).") - if final_selected_servers_config: - self.mcp_profile_manager.create_new_profile(profile_name, final_selected_servers_config) - self.io.tool_output(f"MCP profile '{profile_name}' created with {len(final_selected_servers_config)} server(s).") - else: - self.io.tool_output("Profile creation cancelled during no_confirm configuration.") else: self.io.tool_error("Usage: /mcp new ") From 779b43e06a81a5006b933035399b3329f34bc758 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Wed, 28 May 2025 18:20:47 +0200 Subject: [PATCH 33/51] feat: Add /mcp rm command to delete profiles --- aider/commands.py | 10 ++++++++-- aider/mcp/mcp_profile_manager.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index bf349378733..930d3448a3f 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1623,8 +1623,8 @@ def cmd_mcp(self, args_str: str): self.io.tool_output(" /mcp new - Create a new MCP profile") self.io.tool_output(" /mcp enable - Enable an MCP profile") self.io.tool_output(" /mcp disable - Disable the active MCP profile") + self.io.tool_output(" /mcp rm - Delete an MCP profile") # Add more subcommands as they are implemented - # self.io.tool_output(" /mcp delete ") # self.io.tool_output(" /mcp add ") # self.io.tool_output(" /mcp remove ") return @@ -1695,9 +1695,15 @@ def cmd_mcp(self, args_str: str): self.mcp_profile_manager.disable_profile() else: self.io.tool_error("Usage: /mcp disable") + elif sub_command == "rm": + if len(args) > 1: + profile_name = args[1] + self.mcp_profile_manager.delete_profile(profile_name) + else: + self.io.tool_error("Usage: /mcp rm ") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") - self.io.tool_output("Valid subcommands are: new, enable, disable") + self.io.tool_output("Valid subcommands are: new, enable, disable, rm") def cmd_copy_context(self, args=None): diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 8840c7bcad4..9ea0d46a9a5 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -257,6 +257,21 @@ def create_new_profile(self, profile_name: str, selected_servers_config: List[Di save_mcp_profiles(self.profiles) self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") + def delete_profile(self, profile_name: str): + if profile_name == "all": + self.io.tool_error("The 'all' profile cannot be deleted.") + return + + if profile_name not in self.profiles: + self.io.tool_error(f"Profile '{profile_name}' not found.") + return + + if self.active_profile_name == profile_name: + self.disable_profile() # Ensure it's disabled if it was active + + del self.profiles[profile_name] + save_mcp_profiles(self.profiles) + self.io.tool_output(f"MCP profile '{profile_name}' deleted.") def is_server_no_confirm(self, server_name_to_check: str) -> bool: """ From 72bfd95136c5d55c49439a16dd22ea7ef7ed6657 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 14:58:37 +0200 Subject: [PATCH 34/51] feat: Persist active MCP profile name in config --- aider/mcp/mcp_profile_manager.py | 75 ++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 9ea0d46a9a5..b95fa77868a 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -18,6 +18,9 @@ SERVER_NO_CONFIRM_KEY = "no_confirm" # Old key, for removal during load if present OLD_PROFILE_SERVER_NAMES_KEY = "server_names" +# New top-level keys for the YAML file structure +YAML_PROFILES_KEY = "profiles" +YAML_ACTIVE_PROFILE_KEY = "active_profile_name" @dataclass @@ -25,14 +28,34 @@ class MCPProfile: name: str servers: List[Dict[str, Any]] # Each dict: {'name': str, 'no_confirm': bool} -def load_mcp_profiles() -> Dict[str, MCPProfile]: +def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: + profiles: Dict[str, MCPProfile] = {} + persisted_active_profile_name: Optional[str] = None try: with open(MCP_PROFILES_YAML_PATH, 'r') as f: - profiles_data = yaml.safe_load(f) - if isinstance(profiles_data, list): - profiles = {} - for profile_dict in profiles_data: - if not isinstance(profile_dict, dict): + raw_data = yaml.safe_load(f) + + profiles_list_from_yaml = [] + + if isinstance(raw_data, dict): + persisted_active_profile_name = raw_data.get(YAML_ACTIVE_PROFILE_KEY) + profiles_list_from_yaml = raw_data.get(YAML_PROFILES_KEY, []) + if not isinstance(profiles_list_from_yaml, list): + print(f"Warning: '{YAML_PROFILES_KEY}' key in MCP profiles YAML at {MCP_PROFILES_YAML_PATH} is not a list. Profiles section will be ignored.") + profiles_list_from_yaml = [] + elif isinstance(raw_data, list): # Old format compatibility + profiles_list_from_yaml = raw_data + # persisted_active_profile_name remains None + elif raw_data is None: # File is empty + # profiles_list_from_yaml remains [], persisted_active_profile_name remains None + pass + else: # File content is not a list or dict as expected + print(f"Warning: MCP profiles YAML content at {MCP_PROFILES_YAML_PATH} is not a list or dictionary. File will be treated as empty.") + # profiles_list_from_yaml remains [], persisted_active_profile_name remains None + + # Process the extracted list of profiles + for profile_dict in profiles_list_from_yaml: + if not isinstance(profile_dict, dict): print(f"Warning: Skipping non-dictionary profile entry: {profile_dict}") continue try: @@ -87,25 +110,23 @@ def load_mcp_profiles() -> Dict[str, MCPProfile]: except TypeError as e: # Catches issues like missing 'name' or other fields for MCPProfile profile_name_display = profile_dict.get(PROFILE_NAME_KEY, str(profile_dict)) print(f"Warning: Skipping malformed profile entry for '{profile_name_display}': {e}") - return profiles - elif profiles_data is None: # File is empty - return {} - else: # File content is not a list as expected - # Potentially log this - print(f"Warning: MCP profiles YAML content is not a list: {MCP_PROFILES_YAML_PATH}") - return {} + return profiles, persisted_active_profile_name except FileNotFoundError: - return {} + return {}, None except yaml.YAMLError as e: # Potentially log this, e.g., using io if available print(f"Warning: Error parsing MCP profiles YAML: {e}") - return {} + return {}, None -def save_mcp_profiles(profiles: Dict[str, MCPProfile]): +def save_mcp_profiles(profiles: Dict[str, MCPProfile], active_profile_name: Optional[str]): try: profiles_list = [asdict(profile) for profile in profiles.values()] + data_to_save = {YAML_PROFILES_KEY: profiles_list} + if active_profile_name is not None: + data_to_save[YAML_ACTIVE_PROFILE_KEY] = active_profile_name + with open(MCP_PROFILES_YAML_PATH, 'w') as f: - yaml.dump(profiles_list, f, sort_keys=False) + yaml.dump(data_to_save, f, sort_keys=False) except (IOError, yaml.YAMLError) as e: # Potentially log this, e.g., using io if available print(f"Warning: Error saving MCP profiles YAML: {e}") @@ -171,10 +192,11 @@ def __init__(self, io, settings): self.settings = settings self.profiles: Dict[str, MCPProfile] = {} self.active_profile_name: Optional[str] = None + self.persisted_active_profile_name: Optional[str] = None # For storing loaded active profile name self.active_mcp_client_pool: Optional[MCPClientPool] = None def load_or_initialize_profiles(self): - self.profiles = load_mcp_profiles() + self.profiles, self.persisted_active_profile_name = load_mcp_profiles() mcpservers_arg_val = getattr(self.settings, 'mcp_servers', None) mcpservers_file_arg_val = getattr(self.settings, 'mcp_servers_file', None) @@ -223,7 +245,9 @@ def load_or_initialize_profiles(self): self.io.tool_output("Updated MCP profile 'all' with current server configurations (names or no_confirm values changed).") if needs_save: - save_mcp_profiles(self.profiles) + # self.active_profile_name is None at this point, so this will either + # not write the active_profile_name key or write it as null if it was newly created. + save_mcp_profiles(self.profiles, self.active_profile_name) def get_profile(self, name: str) -> Optional[MCPProfile]: return self.profiles.get(name) @@ -254,7 +278,7 @@ def create_new_profile(self, profile_name: str, selected_servers_config: List[Di new_profile = MCPProfile(name=profile_name, servers=selected_servers_config) self.profiles[profile_name] = new_profile - save_mcp_profiles(self.profiles) + save_mcp_profiles(self.profiles, self.active_profile_name) self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") def delete_profile(self, profile_name: str): @@ -267,10 +291,12 @@ def delete_profile(self, profile_name: str): return if self.active_profile_name == profile_name: - self.disable_profile() # Ensure it's disabled if it was active + self.disable_profile() # This will also save the state with active_profile_name as None del self.profiles[profile_name] - save_mcp_profiles(self.profiles) + # Save the deletion of the profile, preserving the current active_profile_name + # (which would be None if the deleted profile was the active one). + save_mcp_profiles(self.profiles, self.active_profile_name) self.io.tool_output(f"MCP profile '{profile_name}' deleted.") def is_server_no_confirm(self, server_name_to_check: str) -> bool: @@ -366,6 +392,7 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): self.active_mcp_client_pool = pool self.active_profile_name = profile_name + save_mcp_profiles(self.profiles, self.active_profile_name) self.io.tool_output(f"MCP profile '{profile_name}' enabled with {len(matched_server_configs)} server(s).") def disable_profile(self): @@ -376,8 +403,10 @@ def disable_profile(self): self.active_mcp_client_pool.shutdown() self.active_mcp_client_pool = None - if self.active_profile_name: + was_active = self.active_profile_name is not None + if was_active: self.io.tool_output(f"MCP profile '{self.active_profile_name}' disabled.") self.active_profile_name = None + save_mcp_profiles(self.profiles, self.active_profile_name) else: self.io.tool_output("No MCP profile was active.") From 4b61383bb599217d2e41f29001f788f022a932ab Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 14:59:11 +0200 Subject: [PATCH 35/51] fix: Fix indentation in profile loading --- aider/mcp/mcp_profile_manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index b95fa77868a..b0491dc3574 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -56,10 +56,10 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: # Process the extracted list of profiles for profile_dict in profiles_list_from_yaml: if not isinstance(profile_dict, dict): - print(f"Warning: Skipping non-dictionary profile entry: {profile_dict}") - continue - try: - # Ensure servers is a list of dicts with name and no_confirm. + print(f"Warning: Skipping non-dictionary profile entry: {profile_dict}") + continue + try: + # Ensure servers is a list of dicts with name and no_confirm. loaded_servers_data = profile_dict.get(PROFILE_SERVERS_KEY) parsed_servers_list = [] profile_name_for_error = profile_dict.get(PROFILE_NAME_KEY, 'UnknownProfile') From d34cc55c9d61d83bdbad6a50316f515654113949 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 14:59:23 +0200 Subject: [PATCH 36/51] fix: Adjust indentation for profile loading error handling --- aider/mcp/mcp_profile_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index b0491dc3574..3eb1242bffc 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -107,9 +107,9 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: profile = MCPProfile(**profile_dict) profiles[profile.name] = profile - except TypeError as e: # Catches issues like missing 'name' or other fields for MCPProfile - profile_name_display = profile_dict.get(PROFILE_NAME_KEY, str(profile_dict)) - print(f"Warning: Skipping malformed profile entry for '{profile_name_display}': {e}") + except TypeError as e: # Catches issues like missing 'name' or other fields for MCPProfile + profile_name_display = profile_dict.get(PROFILE_NAME_KEY, str(profile_dict)) + print(f"Warning: Skipping malformed profile entry for '{profile_name_display}': {e}") return profiles, persisted_active_profile_name except FileNotFoundError: return {}, None From 97a1673c48011bdf1dae2ae94ef9942e4295f2df Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 15:02:41 +0200 Subject: [PATCH 37/51] feat: Add /mcp persist command --- aider/commands.py | 8 +++++++- aider/mcp/mcp_profile_manager.py | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/aider/commands.py b/aider/commands.py index 930d3448a3f..14d7ec67519 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1624,6 +1624,7 @@ def cmd_mcp(self, args_str: str): self.io.tool_output(" /mcp enable - Enable an MCP profile") self.io.tool_output(" /mcp disable - Disable the active MCP profile") self.io.tool_output(" /mcp rm - Delete an MCP profile") + self.io.tool_output(" /mcp persist - Save the active MCP profile as the default for next launch") # Add more subcommands as they are implemented # self.io.tool_output(" /mcp add ") # self.io.tool_output(" /mcp remove ") @@ -1701,9 +1702,14 @@ def cmd_mcp(self, args_str: str): self.mcp_profile_manager.delete_profile(profile_name) else: self.io.tool_error("Usage: /mcp rm ") + elif sub_command == "persist": + if len(args) == 1: + self.mcp_profile_manager.persist_active_profile() + else: + self.io.tool_error("Usage: /mcp persist") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") - self.io.tool_output("Valid subcommands are: new, enable, disable, rm") + self.io.tool_output("Valid subcommands are: new, enable, disable, rm, persist") def cmd_copy_context(self, args=None): diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 3eb1242bffc..31c1e7a0187 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -327,6 +327,14 @@ def get_tool_prompt_if_active(self) -> Optional[str]: return CoderPrompts.tool_prompt return None + def persist_active_profile(self): + """Saves the currently active MCP profile name to the configuration file.""" + if self.active_profile_name: + save_mcp_profiles(self.profiles, self.active_profile_name) + self.io.tool_output(f"MCP profile '{self.active_profile_name}' persisted as active.") + else: + self.io.tool_output("No active MCP profile to persist.") + def enable_profile(self, profile_name: str, main_model, main_edit_format): if self.active_profile_name == profile_name and self.active_mcp_client_pool: self.io.tool_output(f"MCP profile '{profile_name}' is already active.") From 0b518058aa59257228258332fa719b409fa1d0ef Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 15:03:27 +0200 Subject: [PATCH 38/51] fix: Remove duplicated lines in MCPProfileManager.__init__ --- aider/mcp/mcp_profile_manager.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 31c1e7a0187..5ad486f6cb4 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -194,6 +194,11 @@ def __init__(self, io, settings): self.active_profile_name: Optional[str] = None self.persisted_active_profile_name: Optional[str] = None # For storing loaded active profile name self.active_mcp_client_pool: Optional[MCPClientPool] = None + self.settings = settings + self.profiles: Dict[str, MCPProfile] = {} + self.active_profile_name: Optional[str] = None + self.persisted_active_profile_name: Optional[str] = None # For storing loaded active profile name + self.active_mcp_client_pool: Optional[MCPClientPool] = None def load_or_initialize_profiles(self): self.profiles, self.persisted_active_profile_name = load_mcp_profiles() From e8d33eaeb9990d5201222c00c3458e4e9330274c Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 15:19:49 +0200 Subject: [PATCH 39/51] refactor: Rename YAML_ACTIVE_PROFILE_KEY to YAML_DEFAULT_PROFILE_KEY --- aider/mcp/mcp_profile_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 5ad486f6cb4..f63ca35e85c 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -20,7 +20,7 @@ OLD_PROFILE_SERVER_NAMES_KEY = "server_names" # New top-level keys for the YAML file structure YAML_PROFILES_KEY = "profiles" -YAML_ACTIVE_PROFILE_KEY = "active_profile_name" +YAML_DEFAULT_PROFILE_KEY = "default_profile_name" @dataclass @@ -38,7 +38,7 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: profiles_list_from_yaml = [] if isinstance(raw_data, dict): - persisted_active_profile_name = raw_data.get(YAML_ACTIVE_PROFILE_KEY) + persisted_active_profile_name = raw_data.get(YAML_DEFAULT_PROFILE_KEY) profiles_list_from_yaml = raw_data.get(YAML_PROFILES_KEY, []) if not isinstance(profiles_list_from_yaml, list): print(f"Warning: '{YAML_PROFILES_KEY}' key in MCP profiles YAML at {MCP_PROFILES_YAML_PATH} is not a list. Profiles section will be ignored.") @@ -123,7 +123,7 @@ def save_mcp_profiles(profiles: Dict[str, MCPProfile], active_profile_name: Opti profiles_list = [asdict(profile) for profile in profiles.values()] data_to_save = {YAML_PROFILES_KEY: profiles_list} if active_profile_name is not None: - data_to_save[YAML_ACTIVE_PROFILE_KEY] = active_profile_name + data_to_save[YAML_DEFAULT_PROFILE_KEY] = active_profile_name with open(MCP_PROFILES_YAML_PATH, 'w') as f: yaml.dump(data_to_save, f, sort_keys=False) From 7946b468cafbb5ae5331ec911c7d027950fc0448 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 15:24:40 +0200 Subject: [PATCH 40/51] feat: Enable default MCP profile on startup --- aider/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/aider/main.py b/aider/main.py index 161a2bf6f01..3cc0a3d68a5 100644 --- a/aider/main.py +++ b/aider/main.py @@ -969,6 +969,19 @@ def get_io(pretty): # Track auto-commits configuration analytics.event("auto_commits", enabled=bool(args.auto_commits)) + # Enable persisted default MCP profile if one exists + if mcp_profile_manager.persisted_active_profile_name: + if args.verbose: + io.tool_output( + f"Attempting to enable persisted default MCP profile:" + f" {mcp_profile_manager.persisted_active_profile_name}" + ) + mcp_profile_manager.enable_profile( + mcp_profile_manager.persisted_active_profile_name, + main_model, + main_model.edit_format # Use the main model's default edit format + ) + try: coder = Coder.create( main_model=main_model, From 33be77f64dfc4c3c592b6bcaf8e53f2b9eb00fa1 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 15:27:37 +0200 Subject: [PATCH 41/51] feat: add /mcp persist clear command --- aider/commands.py | 7 +++++-- aider/mcp/mcp_profile_manager.py | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 14d7ec67519..9d496544707 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1625,6 +1625,7 @@ def cmd_mcp(self, args_str: str): self.io.tool_output(" /mcp disable - Disable the active MCP profile") self.io.tool_output(" /mcp rm - Delete an MCP profile") self.io.tool_output(" /mcp persist - Save the active MCP profile as the default for next launch") + self.io.tool_output(" /mcp persist clear - Clear the persisted default MCP profile") # Add more subcommands as they are implemented # self.io.tool_output(" /mcp add ") # self.io.tool_output(" /mcp remove ") @@ -1703,10 +1704,12 @@ def cmd_mcp(self, args_str: str): else: self.io.tool_error("Usage: /mcp rm ") elif sub_command == "persist": - if len(args) == 1: + if len(args) == 1: # /mcp persist self.mcp_profile_manager.persist_active_profile() + elif len(args) == 2 and args[1] == "clear": # /mcp persist clear + self.mcp_profile_manager.clear_persisted_default_profile() else: - self.io.tool_error("Usage: /mcp persist") + self.io.tool_error("Usage: /mcp persist [clear]") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") self.io.tool_output("Valid subcommands are: new, enable, disable, rm, persist") diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index f63ca35e85c..99dcb820af0 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -336,10 +336,23 @@ def persist_active_profile(self): """Saves the currently active MCP profile name to the configuration file.""" if self.active_profile_name: save_mcp_profiles(self.profiles, self.active_profile_name) - self.io.tool_output(f"MCP profile '{self.active_profile_name}' persisted as active.") + self.persisted_active_profile_name = self.active_profile_name + self.io.tool_output(f"MCP profile '{self.active_profile_name}' persisted as default.") else: self.io.tool_output("No active MCP profile to persist.") + def clear_persisted_default_profile(self): + """Clears the persisted default MCP profile name from the configuration file.""" + if self.persisted_active_profile_name is None: + self.io.tool_output("No default MCP profile is currently persisted.") + return + + cleared_profile_name = self.persisted_active_profile_name + self.persisted_active_profile_name = None + save_mcp_profiles(self.profiles, None) # Pass None to remove the key + self.io.tool_output(f"Cleared persisted default MCP profile '{cleared_profile_name}'.") + + def enable_profile(self, profile_name: str, main_model, main_edit_format): if self.active_profile_name == profile_name and self.active_mcp_client_pool: self.io.tool_output(f"MCP profile '{profile_name}' is already active.") From 12dee185c10bd109f9a796e387dc10fdc0de9feb Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 16:43:39 +0200 Subject: [PATCH 42/51] feat: Add support for enabling specific tools per server in profiles --- aider/mcp/mcp_profile_manager.py | 76 +++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 99dcb820af0..2342886801b 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -16,6 +16,7 @@ PROFILE_SERVERS_KEY = "servers" SERVER_NAME_KEY = "name" SERVER_NO_CONFIRM_KEY = "no_confirm" +PROFILE_SERVER_ENABLED_TOOLS_KEY = "enabled_tools" # New key for server entry in profile # Old key, for removal during load if present OLD_PROFILE_SERVER_NAMES_KEY = "server_names" # New top-level keys for the YAML file structure @@ -26,7 +27,7 @@ @dataclass class MCPProfile: name: str - servers: List[Dict[str, Any]] # Each dict: {'name': str, 'no_confirm': bool} + servers: List[Dict[str, Any]] # Each dict: {'name': str, 'no_confirm': bool, 'enabled_tools': Optional[List[str]]} def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: profiles: Dict[str, MCPProfile] = {} @@ -66,8 +67,13 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: if isinstance(loaded_servers_data, list): for server_entry in loaded_servers_data: - if isinstance(server_entry, str): - parsed_servers_list.append({SERVER_NAME_KEY: server_entry, SERVER_NO_CONFIRM_KEY: False}) + current_server_dict = {} + if isinstance(server_entry, str): # server_entry is just a name + current_server_dict = { + SERVER_NAME_KEY: server_entry, + SERVER_NO_CONFIRM_KEY: False, + PROFILE_SERVER_ENABLED_TOOLS_KEY: None # Default for string-only entry + } elif isinstance(server_entry, dict): s_name = server_entry.get(SERVER_NAME_KEY) if not isinstance(s_name, str) or not s_name: @@ -77,10 +83,25 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: if not isinstance(s_no_confirm, bool): print(f"Warning: '{SERVER_NO_CONFIRM_KEY}' for server '{s_name}' in profile '{profile_name_for_error}' is not a boolean (type: {type(s_no_confirm)}). Defaulting to False.") s_no_confirm = False - parsed_servers_list.append({SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: s_no_confirm}) + + current_server_dict = { + SERVER_NAME_KEY: s_name, + SERVER_NO_CONFIRM_KEY: s_no_confirm + } + + enabled_tools_val = server_entry.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) + if enabled_tools_val is not None: # Key is present + if isinstance(enabled_tools_val, list) and all(isinstance(tool_name, str) for tool_name in enabled_tools_val): + current_server_dict[PROFILE_SERVER_ENABLED_TOOLS_KEY] = enabled_tools_val + else: + print(f"Warning: '{PROFILE_SERVER_ENABLED_TOOLS_KEY}' for server '{s_name}' in profile '{profile_name_for_error}' is invalid (must be a list of strings). Defaulting to all tools enabled for this server in this profile.") + current_server_dict[PROFILE_SERVER_ENABLED_TOOLS_KEY] = None + else: # Key is not present + current_server_dict[PROFILE_SERVER_ENABLED_TOOLS_KEY] = None else: print(f"Warning: Invalid server entry type in profile '{profile_name_for_error}': {server_entry} (type: {type(server_entry)}). Skipping this server.") continue + parsed_servers_list.append(current_server_dict) elif loaded_servers_data is not None: # It was present but not a list print( f"Warning: '{PROFILE_SERVERS_KEY}' for profile '{profile_name_for_error}' is not a list" @@ -120,8 +141,23 @@ def load_mcp_profiles() -> Tuple[Dict[str, MCPProfile], Optional[str]]: def save_mcp_profiles(profiles: Dict[str, MCPProfile], active_profile_name: Optional[str]): try: - profiles_list = [asdict(profile) for profile in profiles.values()] - data_to_save = {YAML_PROFILES_KEY: profiles_list} + profiles_list_to_save = [] + for profile_obj in profiles.values(): + profile_dict = asdict(profile_obj) # Converts MCPProfile to dict + + # Ensure 'servers' key exists and is a list before iterating + if PROFILE_SERVERS_KEY in profile_dict and isinstance(profile_dict[PROFILE_SERVERS_KEY], list): + processed_servers = [] + for server_conf_dict in profile_dict[PROFILE_SERVERS_KEY]: + # server_conf_dict is a new dict created by asdict + if PROFILE_SERVER_ENABLED_TOOLS_KEY in server_conf_dict and \ + server_conf_dict[PROFILE_SERVER_ENABLED_TOOLS_KEY] is None: + del server_conf_dict[PROFILE_SERVER_ENABLED_TOOLS_KEY] + processed_servers.append(server_conf_dict) + profile_dict[PROFILE_SERVERS_KEY] = processed_servers + profiles_list_to_save.append(profile_dict) + + data_to_save = {YAML_PROFILES_KEY: profiles_list_to_save} if active_profile_name is not None: data_to_save[YAML_DEFAULT_PROFILE_KEY] = active_profile_name @@ -227,7 +263,11 @@ def load_or_initialize_profiles(self): ) if existing_server_config: no_confirm_val = existing_server_config.get(SERVER_NO_CONFIRM_KEY, False) - new_all_profile_servers_list.append({SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: no_confirm_val}) + new_all_profile_servers_list.append({ + SERVER_NAME_KEY: s_name, + SERVER_NO_CONFIRM_KEY: no_confirm_val, + PROFILE_SERVER_ENABLED_TOOLS_KEY: None # 'all' profile enables all tools by default + }) # Sort for consistent comparison later new_all_profile_servers_list_sorted = sorted(new_all_profile_servers_list, key=lambda x: x[SERVER_NAME_KEY]) @@ -323,6 +363,28 @@ def is_server_no_confirm(self, server_name_to_check: str) -> bool: return False # Server not found in profile or no_confirm not set + def get_enabled_tools_for_server(self, server_name_to_check: str) -> Optional[List[str]]: + """ + Returns the list of enabled tool names for a specific server in the + currently active profile. Returns None if all tools are implicitly enabled + (i.e., 'enabled_tools' was not specified or was null for this server in the profile). + Returns an empty list if the server is found but explicitly has no tools enabled (e.g. enabled_tools: []). + Returns None if the profile is not active or the server is not found in the active profile. + """ + if not self.active_profile_name: + return None + + active_profile = self.get_profile(self.active_profile_name) + if not active_profile: + return None + + for server_config in active_profile.servers: + if server_config.get(SERVER_NAME_KEY) == server_name_to_check: + # .get will return None if PROFILE_SERVER_ENABLED_TOOLS_KEY is missing + return server_config.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) + + return None # Server not found in profile + def get_tool_prompt_if_active(self) -> Optional[str]: """ Returns the standard tool prompt string if an MCP profile is active, From 0275fc66ac32f51c86f793ac1e34ab0399e417d3 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:01:51 +0200 Subject: [PATCH 43/51] feat: Add /mcp tools command to configure server tools --- aider/commands.py | 15 ++++++++++----- aider/mcp/mcp_profile_manager.py | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/aider/commands.py b/aider/commands.py index 9d496544707..c1c65fc895f 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -1621,14 +1621,12 @@ def cmd_mcp(self, args_str: str): self.io.tool_output("\nCommands:") self.io.tool_output(" /mcp new - Create a new MCP profile") + self.io.tool_output(" /mcp tools - Configure enabled tools for a server in the active profile") self.io.tool_output(" /mcp enable - Enable an MCP profile") self.io.tool_output(" /mcp disable - Disable the active MCP profile") self.io.tool_output(" /mcp rm - Delete an MCP profile") self.io.tool_output(" /mcp persist - Save the active MCP profile as the default for next launch") self.io.tool_output(" /mcp persist clear - Clear the persisted default MCP profile") - # Add more subcommands as they are implemented - # self.io.tool_output(" /mcp add ") - # self.io.tool_output(" /mcp remove ") return sub_command = args[0] @@ -1681,10 +1679,17 @@ def cmd_mcp(self, args_str: str): ) self.mcp_profile_manager.create_new_profile(profile_name, final_selected_servers_config) - self.io.tool_output(f"MCP profile '{profile_name}' created with {len(final_selected_servers_config)} server(s).") + # self.io.tool_output(f"MCP profile '{profile_name}' created with {len(final_selected_servers_config)} server(s).") # Redundant with manager's output else: self.io.tool_error("Usage: /mcp new ") + + elif sub_command == "tools": + if len(args) > 1: + server_name = args[1] + self.mcp_profile_manager.configure_server_tools(server_name) + else: + self.io.tool_error("Usage: /mcp tools ") elif sub_command == "enable": if len(args) > 1: @@ -1712,7 +1717,7 @@ def cmd_mcp(self, args_str: str): self.io.tool_error("Usage: /mcp persist [clear]") else: self.io.tool_error(f"Unknown /mcp subcommand: {sub_command}") - self.io.tool_output("Valid subcommands are: new, enable, disable, rm, persist") + self.io.tool_output("Valid subcommands are: new, tools, enable, disable, rm, persist") def cmd_copy_context(self, args=None): diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 2342886801b..8eab37e828f 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, asdict import yaml import json # Added +from prompt_toolkit.shortcuts import checkboxlist_dialog # Added from aider.mcp import load_mcp_servers # Added from aider.mcp.mcp_client_pool import MCPClientPool from aider.coders.base_prompts import CoderPrompts From 7be704922554ccea97a4284ee9623772eeeee500 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:02:10 +0200 Subject: [PATCH 44/51] feat: Add command to configure MCP server tools via dialog --- aider/mcp/mcp_profile_manager.py | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 8eab37e828f..52fcbbf8406 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -327,6 +327,98 @@ def create_new_profile(self, profile_name: str, selected_servers_config: List[Di save_mcp_profiles(self.profiles, self.active_profile_name) self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") + def configure_server_tools(self, target_server_name: str): + if not self.active_profile_name: + self.io.tool_error("No MCP profile is currently active. Enable a profile first with `/mcp enable `.") + return + + active_profile = self.get_profile(self.active_profile_name) + if not active_profile: + # This case should ideally not happen if active_profile_name is set + self.io.tool_error(f"Internal error: Active profile '{self.active_profile_name}' data not found.") + return + + server_config_in_profile = None + server_config_index = -1 + for i, s_conf in enumerate(active_profile.servers): + if s_conf.get(SERVER_NAME_KEY) == target_server_name: + server_config_in_profile = s_conf + server_config_index = i + break + + if not server_config_in_profile: + self.io.tool_error(f"Server '{target_server_name}' not found in active profile '{self.active_profile_name}'.") + return + + if not self.active_mcp_client_pool: + self.io.tool_error(f"MCP client pool not initialized for active profile '{self.active_profile_name}'. Cannot fetch tool list.") + return + + mcp_client = self.active_mcp_client_pool.get_client_by_name(target_server_name) + if not mcp_client: + self.io.tool_error(f"MCP client for server '{target_server_name}' not found in the active pool. It might have failed to connect.") + return + + # Assuming mcp_client.tools_available is a list of tool definition dicts + # Each dict: {"type": "function", "function": {"name": "tool_name", ...}} + all_tool_names = [] + if hasattr(mcp_client, 'tools_available') and mcp_client.tools_available: + for tool_def in mcp_client.tools_available: + if isinstance(tool_def, dict) and \ + tool_def.get('type') == 'function' and \ + isinstance(tool_def.get('function'), dict) and \ + isinstance(tool_def['function'].get('name'), str): + all_tool_names.append(tool_def['function']['name']) + + if not all_tool_names: + self.io.tool_output(f"No tools reported by server '{target_server_name}'. Nothing to configure.") + # Ensure enabled_tools is empty or None if no tools are available + if server_config_in_profile.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) is not None: + server_config_in_profile[PROFILE_SERVER_ENABLED_TOOLS_KEY] = None # Or [] if explicit empty is preferred + save_mcp_profiles(self.profiles, self.active_profile_name) + self.io.tool_output(f"Cleared enabled tools for server '{target_server_name}' as it reports no available tools.") + return + + all_tool_names.sort() + + current_enabled_tools_in_profile = server_config_in_profile.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) + + pre_selected_tool_names = [] + if current_enabled_tools_in_profile is None: # None means all tools are implicitly enabled + pre_selected_tool_names = list(all_tool_names) + else: # It's a list of explicitly enabled tools + pre_selected_tool_names = [ + tool_name for tool_name in all_tool_names if tool_name in current_enabled_tools_in_profile + ] + + choices = [(name, name) for name in all_tool_names] + + # checkboxlist_dialog expects default_values to be a list of the *values* of the selected choices + selected_tools_from_dialog = checkboxlist_dialog( + title=f"Configure Tools for Server: {target_server_name}", + text=f"Select tools to enable for server '{target_server_name}' in profile '{self.active_profile_name}':\n(Space to toggle, Enter to confirm)", + values=choices, + default_values=pre_selected_tool_names # Pass the names that should be pre-checked + ).run() + + if selected_tools_from_dialog is None: # User cancelled the dialog + self.io.tool_output("Tool configuration cancelled.") + return + + # Update the profile + # If all tools are selected, store None to signify "all enabled by default" + # Otherwise, store the explicit list. + if set(selected_tools_from_dialog) == set(all_tool_names): + new_enabled_tools_for_profile = None + else: + new_enabled_tools_for_profile = selected_tools_from_dialog + + active_profile.servers[server_config_index][PROFILE_SERVER_ENABLED_TOOLS_KEY] = new_enabled_tools_for_profile + + save_mcp_profiles(self.profiles, self.active_profile_name) + self.io.tool_output(f"Tool configuration updated for server '{target_server_name}' in profile '{self.active_profile_name}'.") + + def delete_profile(self, profile_name: str): if profile_name == "all": self.io.tool_error("The 'all' profile cannot be deleted.") From 3de2f0d786be836b1a38f3870f4637e1bac808ae Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:11:52 +0200 Subject: [PATCH 45/51] fix: Find MCP client by iterating pool clients --- aider/mcp/mcp_profile_manager.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 52fcbbf8406..ca55cf89137 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -354,9 +354,17 @@ def configure_server_tools(self, target_server_name: str): self.io.tool_error(f"MCP client pool not initialized for active profile '{self.active_profile_name}'. Cannot fetch tool list.") return - mcp_client = self.active_mcp_client_pool.get_client_by_name(target_server_name) + mcp_client = None + # Assuming active_mcp_client_pool has a list of client objects (e.g., in an attribute like 'clients'), + # and each client object has a 'name' attribute. + if hasattr(self.active_mcp_client_pool, 'clients') and isinstance(self.active_mcp_client_pool.clients, list): + for client_in_pool in self.active_mcp_client_pool.clients: + if hasattr(client_in_pool, 'name') and client_in_pool.name == target_server_name: + mcp_client = client_in_pool + break + if not mcp_client: - self.io.tool_error(f"MCP client for server '{target_server_name}' not found in the active pool. It might have failed to connect.") + self.io.tool_error(f"MCP client for server '{target_server_name}' not found in the active pool. It might have failed to connect, or the pool's client list attribute is not named 'clients'.") return # Assuming mcp_client.tools_available is a list of tool definition dicts From e0b4495ed06324003ffbbc9343d687df8cd57d18 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:15:10 +0200 Subject: [PATCH 46/51] fix: Only persist default profile via /mcp persist command --- aider/mcp/mcp_profile_manager.py | 43 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index ca55cf89137..3262d0e8e0c 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -291,9 +291,9 @@ def load_or_initialize_profiles(self): self.io.tool_output("Updated MCP profile 'all' with current server configurations (names or no_confirm values changed).") if needs_save: - # self.active_profile_name is None at this point, so this will either - # not write the active_profile_name key or write it as null if it was newly created. - save_mcp_profiles(self.profiles, self.active_profile_name) + # self.active_profile_name is None at this point. + # We pass self.persisted_active_profile_name to preserve the loaded default. + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) def get_profile(self, name: str) -> Optional[MCPProfile]: return self.profiles.get(name) @@ -324,7 +324,7 @@ def create_new_profile(self, profile_name: str, selected_servers_config: List[Di new_profile = MCPProfile(name=profile_name, servers=selected_servers_config) self.profiles[profile_name] = new_profile - save_mcp_profiles(self.profiles, self.active_profile_name) + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) self.io.tool_output(f"MCP Profile '{profile_name}' created and saved.") def configure_server_tools(self, target_server_name: str): @@ -383,7 +383,7 @@ def configure_server_tools(self, target_server_name: str): # Ensure enabled_tools is empty or None if no tools are available if server_config_in_profile.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) is not None: server_config_in_profile[PROFILE_SERVER_ENABLED_TOOLS_KEY] = None # Or [] if explicit empty is preferred - save_mcp_profiles(self.profiles, self.active_profile_name) + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) self.io.tool_output(f"Cleared enabled tools for server '{target_server_name}' as it reports no available tools.") return @@ -423,7 +423,7 @@ def configure_server_tools(self, target_server_name: str): active_profile.servers[server_config_index][PROFILE_SERVER_ENABLED_TOOLS_KEY] = new_enabled_tools_for_profile - save_mcp_profiles(self.profiles, self.active_profile_name) + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) self.io.tool_output(f"Tool configuration updated for server '{target_server_name}' in profile '{self.active_profile_name}'.") @@ -437,12 +437,20 @@ def delete_profile(self, profile_name: str): return if self.active_profile_name == profile_name: - self.disable_profile() # This will also save the state with active_profile_name as None + # This will set self.active_profile_name = None in memory. + # The save call within disable_profile will use self.persisted_active_profile_name. + self.disable_profile() + + profile_was_persisted_default = (self.persisted_active_profile_name == profile_name) + + del self.profiles[profile_name] # Delete from in-memory dict - del self.profiles[profile_name] - # Save the deletion of the profile, preserving the current active_profile_name - # (which would be None if the deleted profile was the active one). - save_mcp_profiles(self.profiles, self.active_profile_name) + if profile_was_persisted_default: + self.persisted_active_profile_name = None # Update in-memory persisted name + self.io.tool_output(f"MCP profile '{profile_name}' was the persisted default and has been cleared as default.") + + # Save the updated profiles list and the current (possibly updated) persisted_active_profile_name + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) self.io.tool_output(f"MCP profile '{profile_name}' deleted.") def is_server_no_confirm(self, server_name_to_check: str) -> bool: @@ -581,7 +589,10 @@ def enable_profile(self, profile_name: str, main_model, main_edit_format): self.active_mcp_client_pool = pool self.active_profile_name = profile_name - save_mcp_profiles(self.profiles, self.active_profile_name) + # Enabling a profile does not change the persisted default profile. + # Only /mcp persist command does that. + # We still save the profiles list in case other changes (like 'all' profile update) occurred. + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) self.io.tool_output(f"MCP profile '{profile_name}' enabled with {len(matched_server_configs)} server(s).") def disable_profile(self): @@ -594,8 +605,12 @@ def disable_profile(self): was_active = self.active_profile_name is not None if was_active: - self.io.tool_output(f"MCP profile '{self.active_profile_name}' disabled.") + disabled_profile_name = self.active_profile_name self.active_profile_name = None - save_mcp_profiles(self.profiles, self.active_profile_name) + # Disabling a profile does not change the persisted default profile. + # Only /mcp persist clear command does that. + # We still save the profiles list in case other changes occurred. + save_mcp_profiles(self.profiles, self.persisted_active_profile_name) + self.io.tool_output(f"MCP profile '{disabled_profile_name}' disabled.") else: self.io.tool_output("No MCP profile was active.") From ca4bb576de6133f9455be6839457e2f3f27cfa69 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:20:42 +0200 Subject: [PATCH 47/51] fix: Check mcp_servers and clients attributes for MCP pool --- aider/mcp/mcp_profile_manager.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 3262d0e8e0c..f4b320155e2 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -355,16 +355,28 @@ def configure_server_tools(self, target_server_name: str): return mcp_client = None - # Assuming active_mcp_client_pool has a list of client objects (e.g., in an attribute like 'clients'), - # and each client object has a 'name' attribute. - if hasattr(self.active_mcp_client_pool, 'clients') and isinstance(self.active_mcp_client_pool.clients, list): - for client_in_pool in self.active_mcp_client_pool.clients: + pool = self.active_mcp_client_pool + + # Try iterating over 'mcp_servers' attribute if it's a list + if hasattr(pool, 'mcp_servers') and isinstance(pool.mcp_servers, list): + for client_in_pool in pool.mcp_servers: if hasattr(client_in_pool, 'name') and client_in_pool.name == target_server_name: mcp_client = client_in_pool break + # Fallback: Try iterating over 'clients' attribute if it's a list (previous attempt) + if not mcp_client and hasattr(pool, 'clients') and isinstance(pool.clients, list): + for client_in_pool in pool.clients: + if hasattr(client_in_pool, 'name') and client_in_pool.name == target_server_name: + mcp_client = client_in_pool + break + if not mcp_client: - self.io.tool_error(f"MCP client for server '{target_server_name}' not found in the active pool. It might have failed to connect, or the pool's client list attribute is not named 'clients'.") + self.io.tool_error( + f"MCP client for server '{target_server_name}' not found in the active pool. " + "It might have failed to connect, or the pool's internal client list attribute " + "is not recognized (tried 'mcp_servers', 'clients')." + ) return # Assuming mcp_client.tools_available is a list of tool definition dicts From 62fe67edf2acb6e67b9b75ebfce36538d94a93b6 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:22:52 +0200 Subject: [PATCH 48/51] fix: Access server tools from pool cache, not server instance --- aider/mcp/mcp_profile_manager.py | 45 ++++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index f4b320155e2..5e281b47a9d 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -354,36 +354,35 @@ def configure_server_tools(self, target_server_name: str): self.io.tool_error(f"MCP client pool not initialized for active profile '{self.active_profile_name}'. Cannot fetch tool list.") return - mcp_client = None pool = self.active_mcp_client_pool + # The existence of active_mcp_client_pool is already checked earlier in this function. - # Try iterating over 'mcp_servers' attribute if it's a list - if hasattr(pool, 'mcp_servers') and isinstance(pool.mcp_servers, list): - for client_in_pool in pool.mcp_servers: - if hasattr(client_in_pool, 'name') and client_in_pool.name == target_server_name: - mcp_client = client_in_pool - break - - # Fallback: Try iterating over 'clients' attribute if it's a list (previous attempt) - if not mcp_client and hasattr(pool, 'clients') and isinstance(pool.clients, list): - for client_in_pool in pool.clients: - if hasattr(client_in_pool, 'name') and client_in_pool.name == target_server_name: - mcp_client = client_in_pool - break - - if not mcp_client: + if not hasattr(pool, 'cached_tools_by_server') or pool.cached_tools_by_server is None: + self.io.tool_error( + f"Tool information cache is not available for active profile '{self.active_profile_name}'. " + "Tools might not have been fetched successfully when the profile was enabled." + ) + return + + # Check if the target server was even part of the successfully initialized clients in the pool. + # pool.clients is Dict[str, McpServer], mapping server name to McpServer instance. + if not hasattr(pool, 'clients') or target_server_name not in pool.clients: self.io.tool_error( - f"MCP client for server '{target_server_name}' not found in the active pool. " - "It might have failed to connect, or the pool's internal client list attribute " - "is not recognized (tried 'mcp_servers', 'clients')." + f"Server '{target_server_name}' was not successfully initialized or found in the active MCP client pool. " + "Check server configuration or connection issues during profile enabling." ) return + + # Get tool definitions from the pool's cache for the specific server. + # This will be None if the server isn't in the cache (e.g., failed tool fetch, or reported no tools). + # It will be a list (possibly empty) if tools were fetched. + server_tool_definitions = pool.cached_tools_by_server.get(target_server_name) - # Assuming mcp_client.tools_available is a list of tool definition dicts - # Each dict: {"type": "function", "function": {"name": "tool_name", ...}} all_tool_names = [] - if hasattr(mcp_client, 'tools_available') and mcp_client.tools_available: - for tool_def in mcp_client.tools_available: + # Process server_tool_definitions if it's a non-empty list. + # The existing parsing logic for tool_def to populate all_tool_names follows. + if isinstance(server_tool_definitions, list) and server_tool_definitions: + for tool_def in server_tool_definitions: if isinstance(tool_def, dict) and \ tool_def.get('type') == 'function' and \ isinstance(tool_def.get('function'), dict) and \ From 10bffa9a4e84a96983497b8f8115e8877de3442d Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:31:03 +0200 Subject: [PATCH 49/51] docs: Add comment explaining tool selection logic --- aider/mcp/mcp_profile_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 5e281b47a9d..9afa474483e 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -402,6 +402,10 @@ def configure_server_tools(self, target_server_name: str): current_enabled_tools_in_profile = server_config_in_profile.get(PROFILE_SERVER_ENABLED_TOOLS_KEY) + # Determine which tools should be pre-selected in the dialog: + # - If current_enabled_tools_in_profile is None, all available tools are pre-selected. + # - Otherwise, only tools present in both all_tool_names (from server) + # and current_enabled_tools_in_profile (from saved profile) are pre-selected. pre_selected_tool_names = [] if current_enabled_tools_in_profile is None: # None means all tools are implicitly enabled pre_selected_tool_names = list(all_tool_names) From 14b25266b5dbb67ef961697944adcf51135a468a Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 19:34:02 +0200 Subject: [PATCH 50/51] fix: Preserve enabled tools config for 'all' profile --- aider/mcp/mcp_profile_manager.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/aider/mcp/mcp_profile_manager.py b/aider/mcp/mcp_profile_manager.py index 9afa474483e..a5a3cd5de6e 100644 --- a/aider/mcp/mcp_profile_manager.py +++ b/aider/mcp/mcp_profile_manager.py @@ -264,10 +264,20 @@ def load_or_initialize_profiles(self): ) if existing_server_config: no_confirm_val = existing_server_config.get(SERVER_NO_CONFIRM_KEY, False) + # Preserve enabled_tools if it was set for this server in the old 'all' profile + # Defaults to None (all tools enabled) if not previously set or server is new + enabled_tools_val = existing_server_config.get(PROFILE_SERVER_ENABLED_TOOLS_KEY, None) + else: + # Server is new to the 'all' profile, or 'all' profile itself is new + enabled_tools_val = None + else: + # old_all_profile_data does not exist (first run or empty profiles file) + enabled_tools_val = None + new_all_profile_servers_list.append({ SERVER_NAME_KEY: s_name, SERVER_NO_CONFIRM_KEY: no_confirm_val, - PROFILE_SERVER_ENABLED_TOOLS_KEY: None # 'all' profile enables all tools by default + PROFILE_SERVER_ENABLED_TOOLS_KEY: enabled_tools_val }) # Sort for consistent comparison later From d849d786052bede728bc0bc1ad335f769b228688 Mon Sep 17 00:00:00 2001 From: "Your Name (aider)" Date: Thu, 29 May 2025 20:43:01 +0200 Subject: [PATCH 51/51] feat: Filter MCP tools based on profile enabled settings fix: Fix TypeError in get_tool_list --- aider/coders/base_coder.py | 2 +- aider/mcp/mcp_client_pool.py | 109 ++++++++++++++++++++++++++++++++--- aider/mcp/tool_filter.py | 38 ++++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 aider/mcp/tool_filter.py diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 72ecb6346c8..b0742777455 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -1825,7 +1825,7 @@ async def _execute_all_tool_calls_async(): def get_tool_list(self) -> List[Dict[str, Any]]: """Get a flattened list of all MCP tools from the active profile.""" if self.mcp_profile_manager and self.mcp_profile_manager.active_mcp_client_pool: - return self.mcp_profile_manager.active_mcp_client_pool.get_cached_tools_flat_list() + return self.mcp_profile_manager.active_mcp_client_pool.get_cached_tools_flat_list(self.mcp_profile_manager) return [] def reply_completed(self): diff --git a/aider/mcp/mcp_client_pool.py b/aider/mcp/mcp_client_pool.py index 0a696491a37..d11d8f31684 100644 --- a/aider/mcp/mcp_client_pool.py +++ b/aider/mcp/mcp_client_pool.py @@ -1,8 +1,11 @@ import asyncio import logging -from typing import List, Dict, Any, Optional +import asyncio +import logging +from typing import List, Dict, Any, Optional, Tuple from aider.mcp.server import McpServer # Assuming McpServer is in this path +from aider.mcp.tool_filter import filter_tools_for_server class MCPClientPool: @@ -119,17 +122,105 @@ async def fetch_and_cache_tools(self, context: Optional[Dict[str, Any]] = None): self.io.tool_output(f"Fetched {num_tools} tool(s) from {num_servers} server(s).") - def get_cached_tools_flat_list(self) -> List[Dict[str, Any]]: + def get_cached_tools_flat_list(self, mcp_profile_manager) -> List[Dict[str, Any]]: + """ + Returns a flat list of tool definitions from all servers in the pool, + filtered by the enabled_tools setting in the active MCP profile for each server. + """ + all_effective_tools_flat = [] + if self.cached_tools_by_server and mcp_profile_manager and mcp_profile_manager.active_profile_name: + for server_name, tools_definitions_for_server in self.cached_tools_by_server.items(): + if tools_definitions_for_server: # Ensure the list is not empty + # Get enabled tool names for this server from the active profile + enabled_tool_names = mcp_profile_manager.get_enabled_tools_for_server(server_name) + + # Filter the tools for this server + filtered_server_tools = filter_tools_for_server(tools_definitions_for_server, enabled_tool_names) + all_effective_tools_flat.extend(filtered_server_tools) + + # Basic deduplication by function name, preferring the first encountered. + # More sophisticated deduplication might be needed if tools can have same name but different defs. + seen_tool_names = set() + deduplicated_tools = [] + for tool_def in all_effective_tools_flat: + try: + if isinstance(tool_def, dict) and isinstance(tool_def.get("function"), dict): + tool_name = tool_def["function"].get("name") + if tool_name and tool_name not in seen_tool_names: + seen_tool_names.add(tool_name) + deduplicated_tools.append(tool_def) + except (KeyError, TypeError): + continue # Skip malformed tool_def + return deduplicated_tools + + def get_effective_tools_and_tool_choice( + self, + mcp_profile_manager, # MCPProfileManager instance + current_tool_choice: Optional[Any], # The tool_choice that would be used if no MCP filtering + io: Optional[Any] # For logging warnings + ) -> Tuple[List[Dict[str, Any]], Optional[Any]]: """ - Returns a flat list of all cached tool definitions. + Gets the list of MCP tool definitions, filtered by enabled status in the profile, + and logs a warning if current_tool_choice refers to a disabled MCP tool. + + Args: + mcp_profile_manager: The MCPProfileManager to get enabled tool lists. + current_tool_choice: The tool_choice value that would be active. + io: InputOutput object for logging. + + Returns: + A tuple containing: + - List of effective (filtered) MCP tool definitions. + - The original current_tool_choice (adjustment is for logging only here). """ - if not self.cached_tools_by_server: - return [] + effective_mcp_tools_flat_list = self.get_cached_tools_flat_list(mcp_profile_manager) + + # Check if the current_tool_choice (if it's specific) refers to an MCP tool that got disabled. + if isinstance(current_tool_choice, dict) and current_tool_choice.get("type") == "function": + function_spec = current_tool_choice.get("function") + if isinstance(function_spec, dict): + chosen_tool_name = function_spec.get("name") + if chosen_tool_name: + # Check if this chosen tool was an MCP tool by seeing if it was defined by any MCP server + # (regardless of its current enabled status). + was_an_mcp_tool = False + if self.cached_tools_by_server: + for _, tools_definitions_for_server in self.cached_tools_by_server.items(): + if tools_definitions_for_server: + for tool_def in tools_definitions_for_server: + try: + if isinstance(tool_def, dict) and \ + isinstance(tool_def.get("function"), dict) and \ + tool_def["function"].get("name") == chosen_tool_name: + was_an_mcp_tool = True + break + except (KeyError, TypeError): + continue + if was_an_mcp_tool: + break + + if was_an_mcp_tool: + # Now check if this chosen MCP tool is in the *effective* (enabled) list + is_chosen_mcp_tool_enabled = False + for tool_def in effective_mcp_tools_flat_list: + try: + if isinstance(tool_def, dict) and \ + isinstance(tool_def.get("function"), dict) and \ + tool_def["function"].get("name") == chosen_tool_name: + is_chosen_mcp_tool_enabled = True + break + except (KeyError, TypeError): + continue + + if not is_chosen_mcp_tool_enabled: + if io and hasattr(io, 'tool_warning'): + io.tool_warning( + f"Warning: Tool '{chosen_tool_name}' is specified by `tool_choice` but is not " + f"enabled for any server in the active MCP profile. " + f"The `tool_choice` may effectively be 'auto' or 'none' by the LLM." + ) - all_tools = [] - for server_tools in self.cached_tools_by_server.values(): - all_tools.extend(server_tools) - return all_tools + return effective_mcp_tools_flat_list, current_tool_choice def get_cached_tools_by_server(self) -> Optional[Dict[str, List[Dict[str, Any]]]]: """ diff --git a/aider/mcp/tool_filter.py b/aider/mcp/tool_filter.py new file mode 100644 index 00000000000..eaa1ad53208 --- /dev/null +++ b/aider/mcp/tool_filter.py @@ -0,0 +1,38 @@ +from typing import List, Dict, Optional, Any + +def filter_tools_for_server( + original_tool_definitions: List[Dict[str, Any]], + enabled_tool_names: Optional[List[str]] +) -> List[Dict[str, Any]]: + """ + Filters a list of tool definitions based on a list of enabled tool names. + + Args: + original_tool_definitions: The original list of tool definition dictionaries. + enabled_tool_names: An optional list of tool names that are enabled. + If None, all original_tool_definitions are considered enabled. + + Returns: + A new list containing only the tool definitions that are enabled. + """ + if enabled_tool_names is None: + # If enabled_tool_names is None, it means all tools are implicitly enabled for this server in the profile. + return list(original_tool_definitions) # Return a copy + + filtered_tools = [] + if original_tool_definitions: # Ensure there are tools to iterate over + for tool_def in original_tool_definitions: + try: + # Ensure tool_def is a dict and has the expected structure + if isinstance(tool_def, dict) and \ + tool_def.get("type") == "function" and \ + isinstance(tool_def.get("function"), dict) and \ + isinstance(tool_def["function"].get("name"), str) and \ + tool_def["function"]["name"] in enabled_tool_names: + filtered_tools.append(tool_def) + except (KeyError, TypeError): + # Handle cases where tool_def might not have the expected structure + # This might happen if tool definitions are malformed. + # Optionally, log a warning here if io is available. + continue + return filtered_tools