-
Notifications
You must be signed in to change notification settings - Fork 35
refactor: extract and harden MCP lifecycle helpers (#209) #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,138 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os |
Copilot
AI
Mar 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logging.basicConfig(...) at import time will reconfigure the root logger for the entire CLI (and tests) just by importing this module from cli.py, potentially changing log levels/format for unrelated code and polluting command output. Prefer leaving logging configuration to the CLI entrypoint and only defining a module logger here (e.g., logging.getLogger(__name__)).
| # Enhanced logging for lifecycle diagnostics | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(message)s", | |
| datefmt="%Y-%m-%d %H:%M:%S" | |
| ) | |
| logger = logging.getLogger("mcp-lifecycle") | |
| # Logger for MCP lifecycle diagnostics (configured by CLI entrypoint) | |
| logger = logging.getLogger(__name__) |
Copilot
AI
Mar 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MCPCycleGuard.check(dep.repo_url) will raise if the lockfile contains multiple entries with the same repo_url (e.g., virtual packages that differ by virtual_path but share a repo), which is a valid state given LockedDependency.get_unique_key() includes virtual_path. If you want a cycle/dup guard here, key it by the lockfile unique key (repo_url + virtual_path) or by the computed apm.yml path, or drop the guard entirely since this function isn't recursive.
Copilot
AI
Mar 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fallback log message "No lockfile found, performing full scan..." is also used when lock_path exists but is unreadable/corrupt (because locked_paths stays None). This is misleading for diagnostics; consider logging something like "Lockfile missing or unreadable" (and optionally include the path) to distinguish from the truly-missing case.
Copilot
AI
Mar 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove_stale_mcp_servers() currently only cleans .vscode/mcp.json and then logs "(Other runtimes cleanup follow same pattern)" without actually removing stale entries from Copilot (~/.copilot/mcp-config.json) or Codex (~/.codex/config.toml). The existing implementation in cli.py handles all three runtimes; if this helper is intended to replace it, this is a functional regression and will leave stale MCP servers behind for non-VS Code runtimes.
| # (Other runtimes cleanup follow same pattern) | |
| # Clean Copilot MCP config (~/.copilot/mcp-config.json) | |
| if "copilot" in target_runtimes: | |
| copilot_mcp = Path(os.path.expanduser("~/.copilot/mcp-config.json")) | |
| if copilot_mcp.exists(): | |
| try: | |
| import json as _json | |
| config = _json.loads(copilot_mcp.read_text(encoding="utf-8")) | |
| # Copilot configs typically keep MCP servers under a dedicated key. | |
| # Support a few likely variants defensively. | |
| server_sections = [] | |
| for key in ("mcpServers", "mcp_servers", "servers"): | |
| section = config.get(key) | |
| if isinstance(section, dict): | |
| server_sections.append((key, section)) | |
| total_removed = [] | |
| for section_name, servers in server_sections: | |
| removed = [n for n in expanded_stale if n in servers] | |
| for name in removed: | |
| del servers[name] | |
| logger.info( | |
| f"Removed '{name}' from Copilot MCP config section '{section_name}'" | |
| ) | |
| total_removed.extend(removed) | |
| if total_removed: | |
| copilot_mcp.write_text( | |
| _json.dumps(config, indent=2), | |
| encoding="utf-8", | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to clean Copilot MCP config: {e}") | |
| # Clean Codex MCP config (~/.codex/config.toml) | |
| if "codex" in target_runtimes: | |
| codex_config = Path(os.path.expanduser("~/.codex/config.toml")) | |
| if codex_config.exists(): | |
| try: | |
| # Prefer stdlib tomllib when available | |
| try: | |
| import tomllib as _toml_reader # type: ignore[attr-defined] | |
| except ImportError: # pragma: no cover - Python <3.11 or no tomllib | |
| try: | |
| import tomli as _toml_reader # type: ignore[import] | |
| except ImportError: | |
| logger.error( | |
| "Failed to clean Codex MCP config: no TOML parser available " | |
| "(tomllib/tomli not installed)." | |
| ) | |
| _toml_reader = None | |
| if _toml_reader is not None: | |
| raw = codex_config.read_bytes() | |
| data = _toml_reader.loads(raw.decode("utf-8")) | |
| # Assume a structure like: [mcp.servers] with name -> config mappings. | |
| servers_parent = data.get("mcp") if isinstance(data, dict) else None | |
| servers = None | |
| if isinstance(servers_parent, dict): | |
| servers = servers_parent.get("servers") | |
| removed = [] | |
| if isinstance(servers, dict): | |
| for name in list(servers.keys()): | |
| if name in expanded_stale: | |
| removed.append(name) | |
| del servers[name] | |
| logger.info( | |
| f"Removed '{name}' from Codex MCP config [mcp.servers]" | |
| ) | |
| if removed: | |
| # Try to serialize using tomli_w or toml; fall back if unavailable. | |
| toml_writer = None | |
| try: | |
| import tomli_w as _toml_writer # type: ignore[import] | |
| toml_writer = _toml_writer | |
| except ImportError: # pragma: no cover - optional dependency | |
| try: | |
| import toml as _toml_writer # type: ignore[import] | |
| toml_writer = _toml_writer | |
| except ImportError: | |
| logger.error( | |
| "Failed to persist Codex MCP cleanup: no TOML writer " | |
| "available (tomli_w/toml not installed)." | |
| ) | |
| if toml_writer is not None: | |
| try: | |
| # Support both tomli_w.dump and toml.dump styles. | |
| with codex_config.open("w", encoding="utf-8") as f: | |
| if hasattr(toml_writer, "dump"): | |
| toml_writer.dump(data, f) # type: ignore[arg-type] | |
| else: | |
| # Some writers expose "dumps" only. | |
| f.write(toml_writer.dumps(data)) # type: ignore[attr-defined] | |
| except Exception as e: | |
| logger.error( | |
| f"Failed to write updated Codex MCP config: {e}" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Failed to clean Codex MCP config: {e}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These new helpers are imported but not used anywhere in
cli.py(calls still go to_collect_transitive_mcp_deps/_remove_stale_mcp_servers). This adds dead code and also triggersmcp_lifecycleimport-time side effects (notably logging configuration) without any behavior change. Either switch the call sites to the extracted functions and delete the old implementations, or drop this import until the refactor is completed.