-
Notifications
You must be signed in to change notification settings - Fork 96
feat: MCP install target for Claude Code (project + user scope) #655
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
base: main
Are you sure you want to change the base?
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,209 @@ | ||
| """Claude Code MCP client adapter. | ||
|
|
||
| Project scope: ``.mcp.json`` at the workspace root with top-level ``mcpServers`` | ||
| (``--scope project`` in Claude Code). Writes are opt-in when ``.claude/`` exists, | ||
| matching Cursor-style directory detection. | ||
|
|
||
| User scope: top-level ``mcpServers`` in ``~/.claude.json`` (``--scope user``). | ||
|
|
||
| See https://code.claude.com/docs/en/mcp | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| from ...core.scope import InstallScope | ||
| from .copilot import CopilotClientAdapter | ||
|
|
||
|
|
||
| class ClaudeClientAdapter(CopilotClientAdapter): | ||
| """MCP configuration for Claude Code (``mcpServers`` schema). | ||
|
|
||
| Registry formatting reuses :class:`CopilotClientAdapter`, then entries are | ||
| normalized for Claude Code's on-disk shape (stdio servers omit Copilot-only | ||
| keys like ``type: "local"``, default ``tools``, and empty ``id``). | ||
| """ | ||
|
|
||
| @staticmethod | ||
| def _normalize_mcp_entry_for_claude_code(entry: dict) -> dict: | ||
| """Drop Copilot-CLI-only fields that Claude Code does not use for stdio. | ||
|
|
||
| Remote servers keep ``type``/``url`` (and related keys) per Claude Code | ||
| docs. See https://code.claude.com/docs/en/mcp | ||
| """ | ||
| if not isinstance(entry, dict): | ||
| return entry | ||
| out = dict(entry) | ||
| url = out.get("url") | ||
| t = out.get("type") | ||
| is_remote = bool(url) or t in ("http", "sse", "streamable-http") | ||
|
|
||
| if is_remote: | ||
| if out.get("id") in ("", None): | ||
| out.pop("id", None) | ||
| if out.get("tools") == ["*"]: | ||
| out.pop("tools", None) | ||
| return out | ||
|
|
||
| if out.get("type") == "local": | ||
| out.pop("type", None) | ||
| if out.get("tools") == ["*"]: | ||
| out.pop("tools", None) | ||
| if out.get("id") in ("", None): | ||
| out.pop("id", None) | ||
| return out | ||
|
|
||
| @staticmethod | ||
| def _merge_mcp_server_dicts(existing_servers: dict, config_updates: dict) -> None: | ||
| """Merge *config_updates* into *existing_servers* in place. | ||
|
|
||
| Per-server entries are shallow-merged: ``{**old, **new}`` so keys present | ||
| only on plugin- or hand-authored configs (e.g. ``type``, OAuth blocks) | ||
| survive when an update omits them. Keys in *new* overwrite *old* on | ||
| conflict so APM/registry installs still refresh ``command``/``args``/etc. | ||
| """ | ||
| for name, new_cfg in config_updates.items(): | ||
| if not isinstance(new_cfg, dict): | ||
| existing_servers[name] = new_cfg | ||
| continue | ||
| prev = existing_servers.get(name) | ||
| if isinstance(prev, dict): | ||
| merged = {**prev, **new_cfg} | ||
| existing_servers[name] = merged | ||
| else: | ||
| existing_servers[name] = dict(new_cfg) | ||
|
|
||
| def _merge_and_normalize_updates(self, data: dict, config_updates: dict) -> None: | ||
| if "mcpServers" not in data: | ||
| data["mcpServers"] = {} | ||
| self._merge_mcp_server_dicts(data["mcpServers"], config_updates) | ||
| for name in config_updates: | ||
| ent = data["mcpServers"].get(name) | ||
| if isinstance(ent, dict): | ||
| data["mcpServers"][name] = self._normalize_mcp_entry_for_claude_code( | ||
| ent | ||
| ) | ||
|
|
||
| def _workspace_root(self) -> Path: | ||
| """Project paths follow the same cwd convention as other repo-local adapters.""" | ||
| return Path(os.getcwd()) | ||
|
|
||
| def _is_user_scope(self) -> bool: | ||
| return getattr(self, "mcp_install_scope", None) is InstallScope.USER | ||
|
|
||
| def _project_mcp_path(self) -> Path: | ||
| return self._workspace_root() / ".mcp.json" | ||
|
|
||
| def _user_claude_json_path(self) -> Path: | ||
| return Path.home() / ".claude.json" | ||
|
|
||
| def _should_write_project(self) -> bool: | ||
| return (self._workspace_root() / ".claude").is_dir() | ||
|
|
||
| def get_config_path(self): | ||
| if self._is_user_scope(): | ||
| return str(self._user_claude_json_path()) | ||
| return str(self._project_mcp_path()) | ||
|
|
||
| def get_current_config(self): | ||
| if self._is_user_scope(): | ||
| path = self._user_claude_json_path() | ||
| if not path.is_file(): | ||
| return {"mcpServers": {}} | ||
| try: | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| if not isinstance(data, dict): | ||
| return {"mcpServers": {}} | ||
| return {"mcpServers": dict(data.get("mcpServers") or {})} | ||
| except (json.JSONDecodeError, OSError): | ||
| return {"mcpServers": {}} | ||
| path = self._project_mcp_path() | ||
| if not path.is_file(): | ||
| return {"mcpServers": {}} | ||
| try: | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| if not isinstance(data, dict): | ||
| return {"mcpServers": {}} | ||
| return {"mcpServers": dict(data.get("mcpServers") or {})} | ||
| except (json.JSONDecodeError, OSError): | ||
| return {"mcpServers": {}} | ||
|
|
||
| def update_config(self, config_updates, enabled=True): | ||
| if self._is_user_scope(): | ||
| return self._merge_user_mcp(config_updates) | ||
| if not self._should_write_project(): | ||
| return True | ||
| path = self._project_mcp_path() | ||
| try: | ||
| if path.is_file(): | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| if not isinstance(data, dict): | ||
| data = {} | ||
| else: | ||
| data = {} | ||
| self._merge_and_normalize_updates(data, config_updates) | ||
| path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") | ||
| return True | ||
| except OSError: | ||
| return False | ||
|
|
||
| def _merge_user_mcp(self, config_updates) -> bool: | ||
| path = self._user_claude_json_path() | ||
| try: | ||
| if path.is_file(): | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| if not isinstance(data, dict): | ||
| data = {} | ||
| else: | ||
| data = {} | ||
| self._merge_and_normalize_updates(data, config_updates) | ||
| path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") | ||
| return True | ||
| except OSError: | ||
| return False | ||
|
|
||
| def configure_mcp_server( | ||
| self, | ||
| server_url, | ||
| server_name=None, | ||
| enabled=True, | ||
| env_overrides=None, | ||
| server_info_cache=None, | ||
| runtime_vars=None, | ||
| ): | ||
| if not server_url: | ||
| print("Error: server_url cannot be empty") | ||
| return False | ||
|
|
||
| if not self._is_user_scope() and not self._should_write_project(): | ||
| return True | ||
|
|
||
| try: | ||
| if server_info_cache and server_url in server_info_cache: | ||
| server_info = server_info_cache[server_url] | ||
| else: | ||
| server_info = self.registry_client.find_server_by_reference(server_url) | ||
|
|
||
| if not server_info: | ||
| print(f"Error: MCP server '{server_url}' not found in registry") | ||
| return False | ||
|
|
||
| if server_name: | ||
| config_key = server_name | ||
| elif "/" in server_url: | ||
| config_key = server_url.split("/")[-1] | ||
| else: | ||
| config_key = server_url | ||
|
|
||
| server_config = self._format_server_config( | ||
| server_info, env_overrides, runtime_vars | ||
| ) | ||
| self.update_config({config_key: server_config}) | ||
|
|
||
| print(f"Successfully configured MCP server '{config_key}' for Claude Code") | ||
| return True | ||
|
|
||
| except Exception as e: | ||
| print(f"Error configuring MCP server: {e}") | ||
| return False | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -572,7 +572,11 @@ def _check_repo_fallback(token, git_env): | |
| help="Install APM and MCP dependencies (auto-creates apm.yml when installing packages)" | ||
| ) | ||
| @click.argument("packages", nargs=-1) | ||
| @click.option("--runtime", help="Target specific runtime only (copilot, codex, vscode)") | ||
| @click.option( | ||
| "--runtime", | ||
| help="Target specific MCP runtime only " | ||
| "(copilot, codex, vscode, cursor, opencode, claude)", | ||
| ) | ||
| @click.option("--exclude", help="Exclude specific runtime from installation") | ||
| @click.option( | ||
| "--only", | ||
|
|
@@ -652,7 +656,15 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo | |
| logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) | ||
|
|
||
| # Resolve scope | ||
| from ..core.scope import InstallScope, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope | ||
| from ..core.scope import ( | ||
| InstallScope, | ||
| ensure_user_dirs, | ||
| get_apm_dir, | ||
| get_deploy_root, | ||
| get_manifest_path, | ||
| get_modules_dir, | ||
| warn_unsupported_user_scope, | ||
| ) | ||
| scope = InstallScope.USER if global_ else InstallScope.PROJECT | ||
|
|
||
| if scope is InstallScope.USER: | ||
|
|
@@ -740,13 +752,6 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo | |
| # Determine what to install based on install mode | ||
| should_install_apm = install_mode != InstallMode.MCP | ||
| should_install_mcp = install_mode != InstallMode.APM | ||
| # MCP servers are workspace-scoped (.vscode/mcp.json); skip at user scope | ||
| if scope is InstallScope.USER: | ||
| should_install_mcp = False | ||
| if logger: | ||
| logger.verbose_detail( | ||
| "MCP servers skipped at user scope (workspace-scoped concept)" | ||
| ) | ||
|
|
||
| # Show what will be installed if dry run | ||
| if dry_run: | ||
|
|
@@ -851,24 +856,41 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo | |
| new_mcp_servers: builtins.set = builtins.set() | ||
| if should_install_mcp and mcp_deps: | ||
| mcp_count = MCPIntegrator.install( | ||
| mcp_deps, runtime, exclude, verbose, | ||
| mcp_deps, | ||
| runtime, | ||
| exclude, | ||
| verbose, | ||
| stored_mcp_configs=old_mcp_configs, | ||
| diagnostics=apm_diagnostics, | ||
| workspace_root=get_deploy_root(scope), | ||
| install_scope=scope, | ||
| ) | ||
|
Comment on lines
863
to
867
|
||
| new_mcp_servers = MCPIntegrator.get_server_names(mcp_deps) | ||
| new_mcp_configs = MCPIntegrator.get_server_configs(mcp_deps) | ||
|
|
||
| # Remove stale MCP servers that are no longer needed | ||
| stale_servers = old_mcp_servers - new_mcp_servers | ||
| if stale_servers: | ||
| MCPIntegrator.remove_stale(stale_servers, runtime, exclude) | ||
| MCPIntegrator.remove_stale( | ||
| stale_servers, | ||
| runtime, | ||
| exclude, | ||
| workspace_root=get_deploy_root(scope), | ||
| install_scope=scope, | ||
| ) | ||
|
|
||
| # Persist the new MCP server set and configs in the lockfile | ||
| MCPIntegrator.update_lockfile(new_mcp_servers, mcp_configs=new_mcp_configs) | ||
| elif should_install_mcp and not mcp_deps: | ||
| # No MCP deps at all — remove any old APM-managed servers | ||
| if old_mcp_servers: | ||
| MCPIntegrator.remove_stale(old_mcp_servers, runtime, exclude) | ||
| MCPIntegrator.remove_stale( | ||
| old_mcp_servers, | ||
| runtime, | ||
| exclude, | ||
| workspace_root=get_deploy_root(scope), | ||
| install_scope=scope, | ||
| ) | ||
| MCPIntegrator.update_lockfile(builtins.set(), mcp_configs={}) | ||
| logger.verbose_detail("No MCP dependencies found in apm.yml") | ||
| elif not should_install_mcp and old_mcp_servers: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.