Skip to content

refactor: extract and harden MCP lifecycle helpers (#209)#211

Closed
Deepak8858 wants to merge 1 commit intomicrosoft:mainfrom
Deepak8858:fix/mcp-lifecycle-hardening
Closed

refactor: extract and harden MCP lifecycle helpers (#209)#211
Deepak8858 wants to merge 1 commit intomicrosoft:mainfrom
Deepak8858:fix/mcp-lifecycle-hardening

Conversation

@Deepak8858
Copy link

This PR addresses issue #209 by extracting the MCP lifecycle logic into a dedicated module and adding recursion guards and enhanced logging.

Changes:

  • Extracted lifecycle helpers to src/apm_cli/utils/mcp_lifecycle.py
  • Added MCPCycleGuard to prevent infinite recursion during dependency collection
  • Added structured logging for better diagnostics during server configuration and removal

- Moved lifecycle helpers to apm_cli/utils/mcp_lifecycle.py
- Added MCPCycleGuard to prevent infinite recursion
- Added structured logging for diagnostics
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extracts MCP lifecycle-related helper functions into a dedicated utility module as groundwork for moving MCP orchestration out of the cli.py monolith, with additional diagnostics/guards intended to harden behavior.

Changes:

  • Added src/apm_cli/utils/mcp_lifecycle.py with collect_transitive_mcp_deps() and remove_stale_mcp_servers() helpers.
  • Added imports in src/apm_cli/cli.py to reference the extracted helpers.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
src/apm_cli/utils/mcp_lifecycle.py New MCP lifecycle helper module (transitive collection + stale cleanup) with logging and a cycle guard.
src/apm_cli/cli.py Imports the new lifecycle helpers (not yet wired into existing call sites).

Comment on lines +53 to +57
for dep in lockfile.get_all_dependencies():
if dep.repo_url:
guard.check(dep.repo_url)
yml = apm_modules_dir / dep.repo_url / dep.virtual_path / "apm.yml" if dep.virtual_path else apm_modules_dir / dep.repo_url / "apm.yml"
locked_paths.add(yml.resolve())
Copy link

Copilot AI Mar 9, 2026

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 uses AI. Check for mistakes.
Comment on lines +63 to +67
if locked_paths is not None:
apm_yml_paths = [path for path in sorted(locked_paths) if path.exists()]
else:
logger.info("No lockfile found, performing full scan of apm_modules.")
apm_yml_paths = list(apm_modules_dir.rglob("apm.yml"))
Copy link

Copilot AI Mar 9, 2026

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 uses AI. Check for mistakes.
except Exception as e:
logger.error(f"Failed to clean .vscode/mcp.json: {e}")

# (Other runtimes cleanup follow same pattern)
Copy link

Copilot AI Mar 9, 2026

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.

Suggested change
# (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}")

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,138 @@
import os
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import os. Please remove it to keep the new module minimal and avoid confusing readers about side effects.

Suggested change
import os

Copilot uses AI. Check for mistakes.
show_download_spinner,
)
from apm_cli.utils.github_host import is_valid_fqdn, default_host
from apm_cli.utils.mcp_lifecycle import collect_transitive_mcp_deps, remove_stale_mcp_servers
Copy link

Copilot AI Mar 9, 2026

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 triggers mcp_lifecycle import-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.

Suggested change
from apm_cli.utils.mcp_lifecycle import collect_transitive_mcp_deps, remove_stale_mcp_servers

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +12
# 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")
Copy link

Copilot AI Mar 9, 2026

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__)).

Suggested change
# 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 uses AI. Check for mistakes.
@danielmeppiel
Copy link
Collaborator

Thank you @Deepak8858, we had to move fast on this one, solved in #215

@Deepak8858 Deepak8858 deleted the fix/mcp-lifecycle-hardening branch March 10, 2026 00:54
@Deepak8858
Copy link
Author

Deepak8858 commented Mar 10, 2026 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants