diff --git a/CHANGELOG.md b/CHANGELOG.md
index face3126f..133e72832 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- **Claude Code as MCP install target.** `apm install --runtime claude` (and auto-detection when `.claude/` exists or the `claude` binary is on PATH) writes MCP server entries to project-scope `.mcp.json` and user-scope `~/.claude.json` per the [Claude Code MCP schema](https://code.claude.com/docs/en/mcp). Project writes are opt-in (gated on the presence of `.claude/`, mirroring Cursor/OpenCode) and user writes are atomic with `0o600` permissions on first creation so concurrent Claude Code sessions cannot truncate the shared config or leak embedded OAuth state. Stdio entries are emitted with explicit `type: "stdio"` so `claude mcp list` renders them identically to entries installed via `claude mcp add --transport stdio`. Schema fidelity is regression-locked by golden fixtures captured live from the upstream `claude` CLI (see `tests/integration/test_claude_mcp_schema_fidelity.py`). Stale-cleanup defaults conservatively when the install scope is unspecified -- only the project file is touched without an explicit `-g/--global` opt-in. Note: Claude Code's third LOCAL scope (per-project private under `~/.claude.json -> projects.<path>.mcpServers`) is intentionally not implemented, since APM packages target reproducible team installs (PROJECT/USER), not per-user-per-project private state. (#1104, closes #643) -- by @dmartinol
 - **`apm install <local-bundle-path>` deploys a previously packed bundle (directory or `.tar.gz`) air-gapped, with sha256 integrity verification against the bundle's embedded `apm.lock.yaml`.** Honours `--target`, `--global`, `--force`, `--dry-run`, plus a new `--as ALIAS` log/display label flag. Local installs record written paths in the project lockfile (`local_deployed_files` / `local_deployed_file_hashes`) and never mutate `apm.yml`. `apm unpack` is now `[Deprecated]` and points at this entrypoint. (#1098)
 - **`apm pack` now embeds `apm.lock.yaml` inside the bundle** with two new `pack:` fields (`target`, `bundle_files`: a `path -> sha256` manifest) so installs can verify integrity and detect target mismatches before writing any file. (#1098)
 - **`apm compile -t copilot` now emits `.github/copilot-instructions.md` with zero user configuration** -- APM's first Copilot-native compile target. Global instructions in `.apm/instructions/` are assembled into the file VS Code and GitHub Copilot read automatically; switching targets cleans it up. APM dogfoods this target. (#1048)
diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md
index dcbb67e03..bb7e0fe81 100644
--- a/docs/src/content/docs/integrations/ide-tool-integration.md
+++ b/docs/src/content/docs/integrations/ide-tool-integration.md
@@ -523,11 +523,16 @@ APM configures MCP servers in the native config format for each supported client
 | GitHub Copilot CLI | `~/.copilot/mcp-config.json` | JSON `mcpServers` object |
 | Codex CLI (project) | `.codex/config.toml` | TOML `mcp_servers` section |
 | Codex CLI (`--global`) | `~/.codex/config.toml` | TOML `mcp_servers` section |
-| Claude | `.claude/settings.json` | JSON `mcpServers` object |
+| Claude Code (project) | `.mcp.json` | JSON `mcpServers` object (opt-in: requires `.claude/`) |
+| Claude Code (`-g`/`--global`) | `~/.claude.json` | JSON `mcpServers` object (atomic write; `0o600` on first create) |
 | Cursor | `.cursor/mcp.json` | JSON `mcpServers` object |
 | Gemini CLI | `.gemini/settings.json` | JSON `mcpServers` object |
 
-**Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime <name>` or `--exclude <name>` to control which clients receive configuration.
+**Runtime targeting**: APM detects which runtimes are installed and configures MCP servers for all of them. Use `--runtime <name>` or `--exclude <name>` to control which clients receive configuration. Supported runtime names: `copilot`, `codex`, `vscode`, `cursor`, `opencode`, `gemini`, `claude`.
+
+**Claude Code detection**: APM considers Claude Code available when either the `claude` CLI command is on PATH **or** a `.claude/` directory exists in the resolved project root. Project-scope writes are gated on `.claude/` being present (mirroring Cursor / OpenCode), so `apm install` will not create `.mcp.json` until you opt in by creating the directory.
+
+**Claude Code scopes**: Claude Code itself supports three scopes -- LOCAL (the default for `claude mcp add`, per-project private under `~/.claude.json -> projects.<path>.mcpServers`), PROJECT (`.mcp.json`, VCS-shared), and USER (`~/.claude.json` top-level `mcpServers`, cross-project). APM intentionally targets PROJECT and USER (mapping to `apm install` and `apm install -g/--global`) and does not implement LOCAL: APM packages are designed for reproducible team installs, which aligns with PROJECT (shared via VCS) and USER (private but cross-project), not LOCAL (private to one user, one project).
 
 **Codex CLI**: Project installs write MCP configuration to `.codex/config.toml` only when Codex is an active project target. `--global` installs write to `~/.codex/config.toml`.
 
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index ba07db3bc..d9de84fff 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -88,7 +88,7 @@ apm install [PACKAGES...] [OPTIONS]
 - `PACKAGES` - Optional APM packages to add and install. Accepts shorthand (`owner/repo`), HTTPS URLs, SSH URLs, FQDN shorthand (`host/owner/repo`), local filesystem paths (`./path`, `../path`, `/absolute/path`, `~/path`), or marketplace references (`NAME@MARKETPLACE[#ref]`). All forms are normalized to canonical format in `apm.yml`.
 
 **Options:**
-- `--runtime TEXT` - Target specific runtime only (copilot, codex, gemini, vscode)
+- `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode, cursor, opencode, gemini, claude)
 - `--exclude TEXT` - Exclude specific runtime from installation
 - `--only [apm|mcp]` - Install only specific dependency type
 - `--target [copilot|claude|cursor|codex|opencode|gemini|copilot-cowork|all]` - Force deployment to specific target(s). Accepts comma-separated values for multiple targets (e.g., `-t claude,copilot`). Overrides auto-detection
diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh
index 88f3fe18f..8bc6b7ccd 100755
--- a/scripts/test-integration.sh
+++ b/scripts/test-integration.sh
@@ -413,6 +413,18 @@ run_e2e_tests() {
         exit 1
     fi
 
+    # Run Claude Code MCP schema-fidelity tests -- offline, golden fixtures
+    # captured from the upstream `claude` CLI (see fixtures/README.md)
+    log_info "Running Claude Code MCP schema-fidelity tests..."
+    echo "Command: pytest tests/integration/test_claude_mcp_schema_fidelity.py -v -s --tb=short"
+
+    if pytest tests/integration/test_claude_mcp_schema_fidelity.py -v -s --tb=short; then
+        log_success "Claude Code MCP schema-fidelity tests passed!"
+    else
+        log_error "Claude Code MCP schema-fidelity tests failed!"
+        exit 1
+    fi
+
     # Run local-bundle install E2E tests -- offline, no tokens needed
     log_info "Running local-bundle install E2E tests..."
     echo "Command: pytest tests/integration/test_install_local_bundle_e2e.py -v -s --tb=short"
diff --git a/src/apm_cli/adapters/client/claude.py b/src/apm_cli/adapters/client/claude.py
new file mode 100644
index 000000000..0625e3b36
--- /dev/null
+++ b/src/apm_cli/adapters/client/claude.py
@@ -0,0 +1,232 @@
+"""Claude Code MCP client adapter.
+
+Project scope: ``.mcp.json`` at the project root with top-level ``mcpServers``
+(``--scope project`` in Claude Code). Writes are opt-in when ``.claude/`` exists,
+mirroring the Cursor/OpenCode directory-presence convention.
+
+User scope: top-level ``mcpServers`` in ``~/.claude.json`` (``--scope user``).
+Writes are atomic and create the file with ``0o600`` permissions on first
+write so the shared Claude Code config is not silently truncated by a
+concurrent writer and cannot leak any embedded OAuth state to other users
+on a multi-user host.
+
+Local scope (Claude Code's third scope -- the default for ``claude mcp add``,
+storing per-project private config under ``~/.claude.json -> projects.<abs_path>
+.mcpServers``) is intentionally NOT supported: APM packages are designed for
+reproducible team installs, which aligns with PROJECT (VCS-shared) and USER
+(cross-project), not LOCAL (per-project private to one user).
+
+See https://code.claude.com/docs/en/mcp
+"""
+
+import json
+from pathlib import Path
+
+from ...utils.atomic_io import atomic_write_text
+from ...utils.console import _rich_error, _rich_success, _rich_warning
+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``).
+
+    Scope routing is governed by the ``user_scope`` constructor flag inherited
+    from :class:`MCPClientAdapter` -- not by post-construction monkey-patching.
+    """
+
+    supports_user_scope: bool = True
+
+    @staticmethod
+    def _normalize_mcp_entry_for_claude_code(entry: dict) -> dict:
+        """Normalize a server entry to Claude Code's on-disk shape.
+
+        For remote servers, keep ``type``/``url``/``headers`` per Claude
+        Code docs.  For stdio servers, drop Copilot-CLI-only fields
+        (``type: "local"``, default ``tools``, empty ``id``) and emit
+        an explicit ``type: "stdio"`` so ``claude mcp list`` renders
+        the entry the same way it would if installed via
+        ``claude mcp add --transport stdio``.
+
+        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") in (None, "local", "stdio"):
+            out["type"] = "stdio"
+        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 _is_user_scope(self) -> bool:
+        return bool(self.user_scope)
+
+    def _project_mcp_path(self) -> Path:
+        return self.project_root / ".mcp.json"
+
+    def _user_claude_json_path(self) -> Path:
+        return Path.home() / ".claude.json"
+
+    def _should_write_project(self) -> bool:
+        return (self.project_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):
+        path = self._user_claude_json_path() if self._is_user_scope() else 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():
+            _rich_warning(
+                f"Skipped Claude Code project MCP -- .claude/ not found in {self.project_root}"
+            )
+            return False
+        path = self._project_mcp_path()
+        try:
+            if path.is_file():
+                try:
+                    data = json.loads(path.read_text(encoding="utf-8"))
+                except json.JSONDecodeError:
+                    _rich_warning(f"Existing {path} is not valid JSON; rewriting from scratch")
+                    data = {}
+                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():
+                try:
+                    data = json.loads(path.read_text(encoding="utf-8"))
+                except json.JSONDecodeError:
+                    _rich_warning(f"Existing {path} is not valid JSON; rewriting from scratch")
+                    data = {}
+                if not isinstance(data, dict):
+                    data = {}
+            else:
+                data = {}
+            self._merge_and_normalize_updates(data, config_updates)
+            payload = json.dumps(data, indent=2) + "\n"
+            path.parent.mkdir(parents=True, exist_ok=True)
+            atomic_write_text(path, payload, new_file_mode=0o600)
+            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:
+            _rich_error("server_url cannot be empty")
+            return False
+
+        if not self._is_user_scope() and not self._should_write_project():
+            _rich_warning(
+                f"Skipped Claude Code project MCP -- .claude/ not found in {self.project_root}"
+            )
+            return False
+
+        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:
+                _rich_error(f"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)
+            ok = self.update_config({config_key: server_config})
+            if not ok:
+                _rich_error(f"Failed to write MCP config for '{config_key}' to Claude Code")
+                return False
+
+            _rich_success(f"Successfully configured MCP server '{config_key}' for Claude Code")
+            return True
+
+        except Exception:
+            # Do not interpolate the exception message: registry URLs and
+            # other inputs may carry embedded credentials.
+            _rich_error("Error configuring MCP server")
+            return False
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index f7648b352..75343193c 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -793,7 +793,12 @@ def _handle_mcp_install(
     help="Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json); auto-creates apm.yml; use --allow-insecure for http:// packages)"
 )
 @click.argument("packages", nargs=-1)
-@click.option("--runtime", help="Target specific runtime only (copilot, codex, vscode)")
+@click.option(
+    "--runtime",
+    help=(
+        "Target specific runtime only (copilot, codex, vscode, cursor, opencode, gemini, claude)"
+    ),
+)
 @click.option("--exclude", help="Exclude specific runtime from installation")
 @click.option(
     "--only",
diff --git a/src/apm_cli/core/conflict_detector.py b/src/apm_cli/core/conflict_detector.py
index b33a3915c..910c655b9 100644
--- a/src/apm_cli/core/conflict_detector.py
+++ b/src/apm_cli/core/conflict_detector.py
@@ -125,6 +125,8 @@ def get_existing_server_configs(self) -> dict[str, Any]:
             return servers
         elif "vscode" in adapter_class_name:
             return existing_config.get("servers", {})
+        elif "claude" in adapter_class_name:
+            return existing_config.get("mcpServers", {})
 
         return {}
 
diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py
index ed2d10982..2efb5cff5 100644
--- a/src/apm_cli/factory.py
+++ b/src/apm_cli/factory.py
@@ -2,6 +2,7 @@
 
 from pathlib import Path
 
+from .adapters.client.claude import ClaudeClientAdapter
 from .adapters.client.codex import CodexClientAdapter
 from .adapters.client.copilot import CopilotClientAdapter
 from .adapters.client.cursor import CursorClientAdapter
@@ -41,6 +42,7 @@ def create_client(
             "cursor": CursorClientAdapter,
             "gemini": GeminiClientAdapter,
             "opencode": OpenCodeClientAdapter,
+            "claude": ClaudeClientAdapter,
             # Add more clients as needed
         }
 
diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py
index 6ec40d9a1..eee17e639 100644
--- a/src/apm_cli/integration/mcp_integrator.py
+++ b/src/apm_cli/integration/mcp_integrator.py
@@ -458,7 +458,7 @@ def remove_stale(
             return
 
         # Determine which runtimes to clean, mirroring install-time logic.
-        all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode", "gemini"}
+        all_runtimes = {"vscode", "copilot", "codex", "cursor", "opencode", "gemini", "claude"}
         if runtime:  # noqa: SIM108
             target_runtimes = {runtime}
         else:
@@ -481,6 +481,18 @@ def remove_stale(
                     pass
             target_runtimes = supported
 
+        # Claude Code: when scope is unspecified, fail safely toward the project
+        # config only -- never touch ~/.claude.json on the user's behalf without
+        # an explicit USER scope, since that file is shared across all Claude
+        # Code projects on the host.
+        clean_claude_project = "claude" in target_runtimes and scope is not InstallScope.USER
+        clean_claude_user = "claude" in target_runtimes and scope is InstallScope.USER
+        if "claude" in target_runtimes and scope is None:
+            logger.progress(
+                "Claude Code stale cleanup: scope unspecified -- defaulting to "
+                "project .mcp.json only; pass -g/--global to also clean ~/.claude.json"
+            )
+
         # Build an expanded set that includes both the full reference and the
         # last-segment short name so we match config keys in every runtime.
         expanded_stale: builtins.set = builtins.set()
@@ -650,6 +662,61 @@ def remove_stale(
                         exc_info=True,
                     )
 
+        # Clean Claude Code project .mcp.json (only if .claude/ directory exists)
+        if clean_claude_project:
+            claude_mcp = project_root_path / ".mcp.json"
+            if claude_mcp.exists() and (project_root_path / ".claude").is_dir():
+                try:
+                    import json as _json
+
+                    config = _json.loads(claude_mcp.read_text(encoding="utf-8"))
+                    servers = config.get("mcpServers", {})
+                    if not isinstance(servers, dict):
+                        servers = {}
+                    removed = [n for n in expanded_stale if n in servers]
+                    for name in removed:
+                        del servers[name]
+                    if removed:
+                        claude_mcp.write_text(
+                            _json.dumps(config, indent=2) + "\n", encoding="utf-8"
+                        )
+                        for name in removed:
+                            logger.progress(f"Removed stale MCP server '{name}' from .mcp.json")
+                except Exception:
+                    _log.debug(
+                        "Failed to clean stale MCP servers from .mcp.json",
+                        exc_info=True,
+                    )
+
+        # Clean Claude Code user ~/.claude.json (USER scope only)
+        if clean_claude_user:
+            claude_user = Path.home() / ".claude.json"
+            if claude_user.exists():
+                try:
+                    import json as _json
+
+                    config = _json.loads(claude_user.read_text(encoding="utf-8"))
+                    if isinstance(config, dict):
+                        servers = config.get("mcpServers", {})
+                        if not isinstance(servers, dict):
+                            servers = {}
+                        removed = [n for n in expanded_stale if n in servers]
+                        for name in removed:
+                            del servers[name]
+                        if removed:
+                            claude_user.write_text(
+                                _json.dumps(config, indent=2) + "\n", encoding="utf-8"
+                            )
+                            for name in removed:
+                                logger.progress(
+                                    f"Removed stale MCP server '{name}' from ~/.claude.json"
+                                )
+                except Exception:
+                    _log.debug(
+                        "Failed to clean stale MCP servers from ~/.claude.json",
+                        exc_info=True,
+                    )
+
     # ------------------------------------------------------------------
     # Lockfile persistence
     # ------------------------------------------------------------------
@@ -708,6 +775,8 @@ def _detect_runtimes(scripts: dict) -> list[str]:
                 detected.add("codex")
             if re.search(r"\bgemini\b", command):
                 detected.add("gemini")
+            if re.search(r"\bclaude\b", command):
+                detected.add("claude")
             if re.search(r"\bllm\b", command):
                 detected.add("llm")
 
@@ -745,7 +814,7 @@ def _filter_runtimes(detected_runtimes: list[str]) -> list[str]:
             mcp_compatible = [
                 rt
                 for rt in detected_runtimes
-                if rt in ["vscode", "copilot", "codex", "cursor", "opencode", "gemini"]
+                if rt in ["vscode", "copilot", "codex", "cursor", "opencode", "gemini", "claude"]
             ]
             return [rt for rt in mcp_compatible if shutil.which(rt)]
 
@@ -817,7 +886,7 @@ def _install_for_runtime(
         except ValueError as e:
             logger.warning(f"Runtime {runtime} not supported: {e}")
             logger.progress(
-                "Supported runtimes: vscode, copilot, codex, cursor, opencode, gemini, llm"
+                "Supported runtimes: vscode, copilot, codex, cursor, opencode, gemini, claude, llm"
             )
             return False
         except Exception as e:
@@ -829,6 +898,43 @@ def _install_for_runtime(
     # Main orchestrator
     # ------------------------------------------------------------------
 
+    _PROJECT_SCOPED_RUNTIMES: tuple[str, ...] = ("codex", "claude")
+
+    @staticmethod
+    def _gate_project_scoped_runtimes(
+        target_runtimes: list[str],
+        *,
+        user_scope: bool,
+        project_root,
+        apm_config: dict | None,
+        explicit_target: str | None,
+    ) -> list[str]:
+        """Drop project-scoped runtimes that are not active project targets.
+
+        Codex and Claude Code both write project-scoped MCP config files
+        (``.codex/config.toml`` and ``.mcp.json``) whose creation should be
+        opt-in.  When auto-detection brought one of them in but the project's
+        own targets do not include it, we silently strip it -- mirroring the
+        Cursor/OpenCode/Gemini directory-presence convention.
+        """
+        if user_scope:
+            return target_runtimes
+        gated = [rt for rt in MCPIntegrator._PROJECT_SCOPED_RUNTIMES if rt in target_runtimes]
+        if not gated:
+            return target_runtimes
+
+        from apm_cli.integration.targets import active_targets
+
+        root = project_root or Path.cwd()
+        config_target = explicit_target or (apm_config.get("target") if apm_config else None)
+        active = {t.name for t in active_targets(root, config_target)}
+        out = list(target_runtimes)
+        for rt in gated:
+            if rt not in active:
+                _log.debug("%s gated out: active_targets=%s", rt.capitalize(), sorted(active))
+                out = [r for r in out if r != rt]
+        return out
+
     @staticmethod
     def install(
         mcp_deps: list,
@@ -948,7 +1054,15 @@ def install(
                 manager = RuntimeManager()
                 installed_runtimes = []
 
-                for runtime_name in ["copilot", "codex", "vscode", "cursor", "opencode", "gemini"]:
+                for runtime_name in [
+                    "copilot",
+                    "codex",
+                    "vscode",
+                    "cursor",
+                    "opencode",
+                    "gemini",
+                    "claude",
+                ]:
                     try:
                         if runtime_name == "vscode":
                             if _is_vscode_available(project_root=project_root_path):
@@ -969,6 +1083,18 @@ def install(
                             if (Path.cwd() / ".gemini").is_dir():
                                 ClientFactory.create_client(runtime_name)
                                 installed_runtimes.append(runtime_name)
+                        elif runtime_name == "claude":
+                            # Claude Code is opt-in: target when .claude/ exists
+                            # in the project (project-scope writes) OR when the
+                            # `claude` binary is on PATH (user-scope writes).
+                            # The PATH check is the gate that prevents the
+                            # adapter from writing to ~/.claude.json on hosts
+                            # where Claude Code was never installed.
+                            if (project_root_path / ".claude").is_dir() or (
+                                shutil.which("claude") is not None
+                            ):
+                                ClientFactory.create_client(runtime_name)
+                                installed_runtimes.append(runtime_name)
                         else:  # noqa: PLR5501
                             if manager.is_runtime_available(runtime_name):
                                 ClientFactory.create_client(runtime_name)
@@ -991,6 +1117,9 @@ def install(
                 # Gemini CLI is directory-presence based
                 if (Path.cwd() / ".gemini").is_dir():
                     installed_runtimes.append("gemini")
+                # Claude Code: directory-presence OR binary-on-PATH
+                if (project_root_path / ".claude").is_dir() or (shutil.which("claude") is not None):
+                    installed_runtimes.append("claude")
 
             # Step 2: Get runtimes referenced in apm.yml scripts
             script_runtimes = MCPIntegrator._detect_runtimes(
@@ -1054,15 +1183,15 @@ def install(
 
         # Codex MCP is project-scoped: only configure it when Codex is an
         # active project target (silent skip, same as Cursor/OpenCode/Gemini).
-        if not user_scope and "codex" in target_runtimes:
-            from apm_cli.integration.targets import active_targets
-
-            root = project_root or Path.cwd()
-            config_target = explicit_target or (apm_config.get("target") if apm_config else None)
-            active = {t.name for t in active_targets(root, config_target)}
-            if "codex" not in active:
-                _log.debug("Codex gated out: active_targets=%s", sorted(active))
-                target_runtimes = [r for r in target_runtimes if r != "codex"]
+        # Claude Code is gated identically: a host-wide `claude` binary should
+        # not opt every APM project into `.mcp.json` writes.
+        target_runtimes = MCPIntegrator._gate_project_scoped_runtimes(
+            target_runtimes,
+            user_scope=user_scope,
+            project_root=project_root,
+            apm_config=apm_config,
+            explicit_target=explicit_target,
+        )
 
         # Explicit runtime/exclusion/gating can leave nothing to configure.
         if not target_runtimes:
diff --git a/src/apm_cli/registry/operations.py b/src/apm_cli/registry/operations.py
index 8fa9738cc..9fac5bb73 100644
--- a/src/apm_cli/registry/operations.py
+++ b/src/apm_cli/registry/operations.py
@@ -162,6 +162,17 @@ def _get_installed_server_ids(
                                     if server_id:
                                         installed_ids.add(server_id)
 
+                    elif runtime == "claude":
+                        # Claude Code stores servers under top-level mcpServers in either
+                        # project .mcp.json or user ~/.claude.json -- the adapter normalizes
+                        # both shapes to {"mcpServers": {...}} via get_current_config.
+                        mcp_servers = config.get("mcpServers", {})
+                        for server_name, server_config in mcp_servers.items():  # noqa: B007
+                            if isinstance(server_config, dict):
+                                server_id = server_config.get("id")
+                                if server_id:
+                                    installed_ids.add(server_id)
+
             except Exception:  # noqa: S112
                 # If we can't read a runtime's config, skip it
                 continue
diff --git a/src/apm_cli/utils/atomic_io.py b/src/apm_cli/utils/atomic_io.py
index 0f87ef23e..7163587ab 100644
--- a/src/apm_cli/utils/atomic_io.py
+++ b/src/apm_cli/utils/atomic_io.py
@@ -11,23 +11,36 @@
 ``apm_cli.compilation.output_writer`` route through here.
 """
 
+import contextlib
 import os
 import tempfile
 from pathlib import Path
 
 
-def atomic_write_text(path: Path, data: str) -> None:
+def atomic_write_text(path: Path, data: str, *, new_file_mode: int | None = None) -> None:
     """Atomically write ``data`` (UTF-8) to ``path``.
 
     The temp file is created in ``path.parent`` so the eventual
     ``os.replace`` is a same-filesystem rename. Caller is responsible
     for ensuring the parent directory exists.
 
+    If ``new_file_mode`` is given and ``path`` does not yet exist,
+    the temp file's POSIX mode bits are set to that value before
+    the rename so the destination is created with the requested
+    permissions. Existing files keep their pre-existing mode (we
+    do not downgrade nor upgrade perms). The mode hint is silently
+    ignored on platforms where ``os.fchmod`` is unavailable
+    (e.g. Windows), where POSIX mode bits are not enforced anyway.
+
     On any failure, the temp file is removed and the original target
     file (if any) remains untouched.
     """
+    existed = path.exists()
     fd, tmp_name = tempfile.mkstemp(prefix="apm-atomic-", dir=str(path.parent))
     try:
+        if new_file_mode is not None and not existed and hasattr(os, "fchmod"):
+            with contextlib.suppress(OSError):
+                os.fchmod(fd, new_file_mode)
         with os.fdopen(fd, "w", encoding="utf-8") as fh:
             fh.write(data)
         os.replace(tmp_name, path)
diff --git a/tests/integration/fixtures/claude_mcp_golden/README.md b/tests/integration/fixtures/claude_mcp_golden/README.md
new file mode 100644
index 000000000..c8ab46523
--- /dev/null
+++ b/tests/integration/fixtures/claude_mcp_golden/README.md
@@ -0,0 +1,66 @@
+# Claude Code MCP golden fixtures
+
+Captured live from the `claude` CLI shipped with Claude Code 2.1.126
+(probed locally on macOS, 2025) by running `claude mcp add` against a
+throwaway project + a backed-up `~/.claude.json`. These are the
+canonical on-disk shapes the Claude Code runtime accepts and emits;
+the APM `ClaudeClientAdapter` is asserted byte-equivalent against
+them in `tests/integration/test_claude_mcp_schema_fidelity.py`.
+
+We deliberately do NOT take a runtime dependency on the `claude` CLI
+in CI — these fixtures freeze the contract at probe time and CI runs
+the equivalence checks offline. When Claude Code ships a new schema
+version, re-run the probe locally, refresh the fixtures, and
+re-assert.
+
+## Probe commands (Claude Code 2.1.126)
+
+PROJECT scope, HTTP:
+
+```
+claude mcp add --scope project --transport http p-http https://example.invalid/mcp
+```
+
+PROJECT scope, SSE:
+
+```
+claude mcp add --scope project --transport sse p-sse https://example.invalid/sse
+```
+
+PROJECT scope, stdio (note: env uses `-e KEY=VAL`, NOT `--env KEY=VAL`):
+
+```
+claude mcp add --scope project --transport stdio p-stdio \
+    -e FOO=bar -e BAZ=qux -- npx -y some-pkg --flag arg2
+```
+
+PROJECT scope, HTTP with auth header:
+
+```
+claude mcp add --scope project --transport http p-http-auth \
+    https://api.example.invalid/mcp \
+    --header "Authorization: Bearer XYZ"
+```
+
+USER scope, HTTP:
+
+```
+claude mcp add --scope user --transport http u-http https://example.invalid/mcp
+```
+
+USER scope, stdio:
+
+```
+claude mcp add --scope user --transport stdio u-stdio \
+    -e BAZ=qux -- npx -y stdio-pkg --foo
+```
+
+LOCAL scope (default), HTTP — NOT implemented by APM (intentional, see
+adapter docstring), captured here for completeness:
+
+```
+claude mcp add --transport http l-http https://example.invalid/mcp
+```
+
+Local scope writes under `projects.<absolute_project_path>.mcpServers`
+in `~/.claude.json`.
diff --git a/tests/integration/fixtures/claude_mcp_golden/project_mcp.json b/tests/integration/fixtures/claude_mcp_golden/project_mcp.json
new file mode 100644
index 000000000..f0e3fcbc1
--- /dev/null
+++ b/tests/integration/fixtures/claude_mcp_golden/project_mcp.json
@@ -0,0 +1,33 @@
+{
+  "mcpServers": {
+    "p-http": {
+      "type": "http",
+      "url": "https://example.invalid/mcp"
+    },
+    "p-sse": {
+      "type": "sse",
+      "url": "https://example.invalid/sse"
+    },
+    "p-stdio": {
+      "type": "stdio",
+      "command": "npx",
+      "args": [
+        "-y",
+        "some-pkg",
+        "--flag",
+        "arg2"
+      ],
+      "env": {
+        "FOO": "bar",
+        "BAZ": "qux"
+      }
+    },
+    "p-http-auth": {
+      "type": "http",
+      "url": "https://api.example.invalid/mcp",
+      "headers": {
+        "Authorization": "Bearer XYZ"
+      }
+    }
+  }
+}
diff --git a/tests/integration/fixtures/claude_mcp_golden/user_claude_mcpServers.json b/tests/integration/fixtures/claude_mcp_golden/user_claude_mcpServers.json
new file mode 100644
index 000000000..27bc98efd
--- /dev/null
+++ b/tests/integration/fixtures/claude_mcp_golden/user_claude_mcpServers.json
@@ -0,0 +1,20 @@
+{
+  "mcpServers": {
+    "u-http": {
+      "type": "http",
+      "url": "https://example.invalid/mcp"
+    },
+    "u-stdio": {
+      "type": "stdio",
+      "command": "npx",
+      "args": [
+        "-y",
+        "stdio-pkg",
+        "--foo"
+      ],
+      "env": {
+        "BAZ": "qux"
+      }
+    }
+  }
+}
diff --git a/tests/integration/test_claude_mcp_schema_fidelity.py b/tests/integration/test_claude_mcp_schema_fidelity.py
new file mode 100644
index 000000000..5990aaefa
--- /dev/null
+++ b/tests/integration/test_claude_mcp_schema_fidelity.py
@@ -0,0 +1,224 @@
+"""Schema-fidelity integration tests for the Claude Code MCP adapter.
+
+These tests assert that ``ClaudeClientAdapter`` writes on-disk JSON
+that is BYTE-EQUIVALENT to what the upstream ``claude`` CLI emits
+when an end-user runs ``claude mcp add``.  The reference fixtures
+were captured live from Claude Code 2.1.126; see
+``tests/integration/fixtures/claude_mcp_golden/README.md`` for the
+exact probe commands.
+
+Why the golden-fixture approach (no ``claude`` CLI dependency in CI):
+  * APM cannot take a runtime dependency on the ``claude`` binary in
+    integration tests -- contributors must be able to run the suite
+    without installing Claude Code.
+  * Capturing the schema as static fixtures freezes the contract at
+    a known Claude Code version.  When Claude Code ships a new
+    schema, re-run the probe locally and refresh the fixtures.
+
+Coverage matrix:
+  * PROJECT scope, HTTP transport
+  * PROJECT scope, SSE transport
+  * PROJECT scope, stdio transport (env + args)
+  * PROJECT scope, HTTP with auth headers
+  * USER scope, HTTP transport
+  * USER scope, stdio transport
+  * LOCAL scope is intentionally NOT implemented; a guard test
+    asserts that the adapter never writes under
+    ``projects.<path>.mcpServers`` so we cannot regress into it.
+"""
+
+from __future__ import annotations
+
+import json
+import tempfile
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from apm_cli.adapters.client.claude import ClaudeClientAdapter
+
+FIXTURES = Path(__file__).parent / "fixtures" / "claude_mcp_golden"
+
+
+def _load_golden(name: str) -> dict:
+    return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
+
+
+@pytest.mark.integration
+class TestClaudeProjectScopeSchemaFidelity:
+    """``.mcp.json`` at project root, top-level ``mcpServers``."""
+
+    def setup_method(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self._tmp.name)
+        (self.root / ".claude").mkdir()
+        self.adapter = ClaudeClientAdapter(project_root=str(self.root), user_scope=False)
+
+    def teardown_method(self):
+        self._tmp.cleanup()
+
+    def test_http_transport_matches_claude_cli_output(self):
+        golden = _load_golden("project_mcp.json")
+        entry = golden["mcpServers"]["p-http"]
+        ok = self.adapter.update_config({"p-http": entry})
+        assert ok is True
+        on_disk = json.loads((self.root / ".mcp.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["p-http"] == entry
+
+    def test_sse_transport_matches_claude_cli_output(self):
+        golden = _load_golden("project_mcp.json")
+        entry = golden["mcpServers"]["p-sse"]
+        ok = self.adapter.update_config({"p-sse": entry})
+        assert ok is True
+        on_disk = json.loads((self.root / ".mcp.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["p-sse"] == entry
+
+    def test_stdio_transport_matches_claude_cli_output(self):
+        golden = _load_golden("project_mcp.json")
+        entry = golden["mcpServers"]["p-stdio"]
+        ok = self.adapter.update_config({"p-stdio": entry})
+        assert ok is True
+        on_disk = json.loads((self.root / ".mcp.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["p-stdio"] == entry
+
+    def test_http_with_auth_headers_matches_claude_cli_output(self):
+        golden = _load_golden("project_mcp.json")
+        entry = golden["mcpServers"]["p-http-auth"]
+        ok = self.adapter.update_config({"p-http-auth": entry})
+        assert ok is True
+        on_disk = json.loads((self.root / ".mcp.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["p-http-auth"] == entry
+
+    def test_full_golden_project_file_round_trips_byte_equivalent(self):
+        """Write the entire golden fixture set; on-disk file must
+        deep-equal the canonical Claude Code project config."""
+        golden = _load_golden("project_mcp.json")
+        ok = self.adapter.update_config(golden["mcpServers"])
+        assert ok is True
+        on_disk = json.loads((self.root / ".mcp.json").read_text(encoding="utf-8"))
+        assert on_disk == golden
+
+
+@pytest.mark.integration
+class TestClaudeUserScopeSchemaFidelity:
+    """``~/.claude.json`` top-level ``mcpServers``."""
+
+    def setup_method(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self._tmp.name)
+        self.fake_home = self.root / "home"
+        self.fake_home.mkdir()
+        self._home_patch = mock.patch(
+            "apm_cli.adapters.client.claude.Path.home",
+            return_value=self.fake_home,
+        )
+        self._home_patch.start()
+        self.adapter = ClaudeClientAdapter(project_root=str(self.root), user_scope=True)
+
+    def teardown_method(self):
+        self._home_patch.stop()
+        self._tmp.cleanup()
+
+    def test_http_transport_matches_claude_cli_output(self):
+        golden = _load_golden("user_claude_mcpServers.json")
+        entry = golden["mcpServers"]["u-http"]
+        ok = self.adapter.update_config({"u-http": entry})
+        assert ok is True
+        on_disk = json.loads((self.fake_home / ".claude.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["u-http"] == entry
+
+    def test_stdio_transport_matches_claude_cli_output(self):
+        golden = _load_golden("user_claude_mcpServers.json")
+        entry = golden["mcpServers"]["u-stdio"]
+        ok = self.adapter.update_config({"u-stdio": entry})
+        assert ok is True
+        on_disk = json.loads((self.fake_home / ".claude.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"]["u-stdio"] == entry
+
+    def test_full_golden_user_file_round_trips_byte_equivalent(self):
+        """Write the full golden user-scope set; on-disk top-level
+        ``mcpServers`` must deep-equal canonical Claude Code output."""
+        golden = _load_golden("user_claude_mcpServers.json")
+        ok = self.adapter.update_config(golden["mcpServers"])
+        assert ok is True
+        on_disk = json.loads((self.fake_home / ".claude.json").read_text(encoding="utf-8"))
+        assert on_disk["mcpServers"] == golden["mcpServers"]
+
+    def test_user_scope_writes_at_top_level_not_under_projects(self):
+        """Regression guard: the LOCAL scope (default for ``claude
+        mcp add``) writes under ``projects.<abs_path>.mcpServers``;
+        APM intentionally targets USER scope (cross-project) when
+        ``user_scope=True`` is requested.  Confirm we never collide
+        with the LOCAL scope key path."""
+        self.adapter.update_config(
+            {"u-http": {"type": "http", "url": "https://example.invalid/mcp"}}
+        )
+        data = json.loads((self.fake_home / ".claude.json").read_text(encoding="utf-8"))
+        assert "mcpServers" in data, "USER scope must write at top-level mcpServers"
+        assert "projects" not in data, (
+            "USER scope must not synthesize a `projects` key (that is the LOCAL scope key path)"
+        )
+
+    def test_user_scope_preserves_unrelated_top_level_keys(self):
+        """``~/.claude.json`` is shared with Claude Code's full user
+        config (auth tokens, project list, settings).  An APM write
+        must not clobber those keys."""
+        path = self.fake_home / ".claude.json"
+        path.write_text(
+            json.dumps(
+                {
+                    "userID": "abc-123",
+                    "oauthAccount": {"token": "do-not-touch"},
+                    "projects": {"/some/path": {"mcpServers": {"keep": {"command": "k"}}}},
+                }
+            ),
+            encoding="utf-8",
+        )
+        ok = self.adapter.update_config(
+            {"new": {"type": "http", "url": "https://example.invalid/mcp"}}
+        )
+        assert ok is True
+        data = json.loads(path.read_text(encoding="utf-8"))
+        assert data["userID"] == "abc-123"
+        assert data["oauthAccount"] == {"token": "do-not-touch"}
+        assert data["projects"]["/some/path"]["mcpServers"]["keep"] == {"command": "k"}
+        assert data["mcpServers"]["new"] == {"type": "http", "url": "https://example.invalid/mcp"}
+
+
+@pytest.mark.integration
+class TestClaudeLocalScopeNotImplemented:
+    """The LOCAL scope (Claude's default ``claude mcp add`` scope)
+    writes per-project private config under
+    ``~/.claude.json -> projects.<abs_path>.mcpServers``.  APM does
+    NOT implement LOCAL scope -- APM packages are designed to be
+    reproducible across teammates, which aligns with PROJECT (VCS)
+    and USER (cross-project), not LOCAL (per-project private).
+
+    These tests document the intentional omission so a future
+    refactor cannot silently introduce a third scope mode.
+    """
+
+    def setup_method(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self._tmp.name)
+
+    def teardown_method(self):
+        self._tmp.cleanup()
+
+    def test_no_local_scope_constructor_flag(self):
+        """``ClaudeClientAdapter`` exposes ``user_scope`` only --
+        no ``local_scope`` parameter, no third mode."""
+        adapter = ClaudeClientAdapter(project_root=str(self.root), user_scope=False)
+        assert hasattr(adapter, "user_scope")
+        assert not hasattr(adapter, "local_scope")
+
+    def test_default_construction_targets_project_scope(self):
+        """Default ``user_scope=False`` resolves to project-scope
+        (``.mcp.json``), NOT to local-scope under ``projects.<path>``
+        in ``~/.claude.json``."""
+        adapter = ClaudeClientAdapter(project_root=str(self.root), user_scope=False)
+        cfg_path = Path(adapter.get_config_path())
+        assert cfg_path.name == ".mcp.json"
+        assert cfg_path.parent == self.root
+        assert ".claude.json" not in str(cfg_path)
diff --git a/tests/unit/test_claude_mcp.py b/tests/unit/test_claude_mcp.py
new file mode 100644
index 000000000..86522e7f1
--- /dev/null
+++ b/tests/unit/test_claude_mcp.py
@@ -0,0 +1,301 @@
+"""Tests for the Claude Code MCP client adapter and integrator wiring."""
+
+import json
+import os
+import stat
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from apm_cli.adapters.client.claude import ClaudeClientAdapter
+from apm_cli.core.scope import InstallScope
+from apm_cli.factory import ClientFactory
+
+
+class TestClaudeClientFactory(unittest.TestCase):
+    """Factory wiring for the ``claude`` client type."""
+
+    def test_factory_creates_claude_adapter(self):
+        client = ClientFactory.create_client("claude")
+        self.assertIsInstance(client, ClaudeClientAdapter)
+
+    def test_factory_threads_user_scope(self):
+        client = ClientFactory.create_client("claude", user_scope=True)
+        self.assertTrue(client._is_user_scope())
+
+    def test_factory_threads_project_root(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            client = ClientFactory.create_client("claude", project_root=tmp)
+            self.assertEqual(Path(client.get_config_path()), Path(tmp) / ".mcp.json")
+
+
+class TestClaudeClientAdapterProject(unittest.TestCase):
+    """Project scope: ``<root>/.mcp.json`` with top-level ``mcpServers``."""
+
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self.tmp.name)
+        (self.root / ".claude").mkdir()
+        self.adapter = ClaudeClientAdapter(project_root=self.root, user_scope=False)
+        self.mcp_path = self.root / ".mcp.json"
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_get_config_path_uses_project_root(self):
+        self.assertEqual(Path(self.adapter.get_config_path()), self.mcp_path)
+
+    def test_update_config_creates_mcp_json(self):
+        ok = self.adapter.update_config({"srv": {"command": "node", "args": ["s.js"]}})
+        self.assertTrue(ok)
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        self.assertIn("srv", data["mcpServers"])
+        self.assertEqual(data["mcpServers"]["srv"]["command"], "node")
+
+    def test_update_config_warns_and_returns_false_without_claude_dir(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            adapter = ClaudeClientAdapter(project_root=tmp, user_scope=False)
+            ok = adapter.update_config({"srv": {"command": "x"}})
+            self.assertFalse(ok)
+            self.assertFalse((Path(tmp) / ".mcp.json").exists())
+
+    def test_update_config_normalizes_stdio_entry(self):
+        """Stdio entries: drop Copilot-only ``tools``/``id``; rewrite
+        Copilot's ``type: "local"`` to Claude Code's canonical
+        ``type: "stdio"`` so ``claude mcp list`` renders identically
+        to entries installed via ``claude mcp add --transport stdio``."""
+        self.adapter.update_config(
+            {"srv": {"command": "node", "type": "local", "tools": ["*"], "id": ""}}
+        )
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        srv = data["mcpServers"]["srv"]
+        self.assertEqual(srv["type"], "stdio")
+        self.assertNotIn("tools", srv)
+        self.assertNotIn("id", srv)
+
+    def test_update_config_sets_explicit_stdio_type_when_missing(self):
+        """An entry with ``command`` but no ``type`` (e.g. older
+        registry data) must be rewritten with explicit
+        ``type: "stdio"`` to match the canonical Claude Code shape."""
+        self.adapter.update_config({"srv": {"command": "node", "args": ["s.js"]}})
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        self.assertEqual(data["mcpServers"]["srv"]["type"], "stdio")
+
+    def test_update_config_preserves_remote_type_url(self):
+        self.adapter.update_config({"remote": {"type": "http", "url": "https://example.com/mcp"}})
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        self.assertEqual(data["mcpServers"]["remote"]["type"], "http")
+        self.assertEqual(data["mcpServers"]["remote"]["url"], "https://example.com/mcp")
+
+    def test_get_current_config_returns_empty_when_missing(self):
+        self.assertEqual(self.adapter.get_current_config(), {"mcpServers": {}})
+
+    def test_update_config_idempotent(self):
+        """Re-applying the same update produces a byte-equal file."""
+        cfg = {"srv": {"command": "node", "args": ["s.js"]}}
+        self.adapter.update_config(cfg)
+        first = self.mcp_path.read_bytes()
+        self.adapter.update_config(cfg)
+        second = self.mcp_path.read_bytes()
+        self.assertEqual(first, second)
+
+    def test_update_config_preserves_other_servers(self):
+        self.mcp_path.write_text(
+            json.dumps({"mcpServers": {"keep": {"command": "k"}}}), encoding="utf-8"
+        )
+        self.adapter.update_config({"new": {"command": "n"}})
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        self.assertIn("keep", data["mcpServers"])
+        self.assertIn("new", data["mcpServers"])
+
+    def test_update_config_tolerates_malformed_project_mcp_json(self):
+        """Corrupted .mcp.json must not abort install; treat as empty + rewrite."""
+        self.mcp_path.write_text("{not valid json", encoding="utf-8")
+        ok = self.adapter.update_config({"srv": {"command": "x"}})
+        self.assertTrue(ok)
+        data = json.loads(self.mcp_path.read_text(encoding="utf-8"))
+        self.assertIn("srv", data["mcpServers"])
+
+
+class TestClaudeClientAdapterUser(unittest.TestCase):
+    """User scope: ``~/.claude.json`` top-level ``mcpServers``."""
+
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.home = Path(self.tmp.name)
+        self.claude_json = self.home / ".claude.json"
+        self.adapter = ClaudeClientAdapter(user_scope=True)
+        self._home = patch.object(Path, "home", return_value=self.home)
+        self._home.start()
+
+    def tearDown(self):
+        self._home.stop()
+        self.tmp.cleanup()
+
+    def test_merge_user_claude_json_preserves_other_keys(self):
+        self.claude_json.write_text(
+            json.dumps({"projects": {}, "mcpServers": {"x": {"command": "a"}}}),
+            encoding="utf-8",
+        )
+        self.adapter.update_config({"y": {"command": "b"}})
+        data = json.loads(self.claude_json.read_text(encoding="utf-8"))
+        self.assertIn("projects", data)
+        self.assertIn("x", data["mcpServers"])
+        self.assertIn("y", data["mcpServers"])
+
+    def test_new_user_claude_json_created_with_0600_perms(self):
+        """New ~/.claude.json must be created with 0o600 to avoid leaking OAuth state."""
+        if os.name != "posix":
+            self.skipTest("POSIX permission bits not observable on Windows")
+        self.assertFalse(self.claude_json.exists())
+        ok = self.adapter.update_config({"srv": {"command": "x"}})
+        self.assertTrue(ok)
+        mode = stat.S_IMODE(os.stat(self.claude_json).st_mode)
+        self.assertEqual(mode, 0o600)
+
+    def test_user_scope_write_is_atomic_no_temp_left_behind(self):
+        self.adapter.update_config({"srv": {"command": "x"}})
+        leftovers = list(self.home.glob(".claude.json.*")) + list(self.home.glob("apm-atomic-*"))
+        self.assertEqual(leftovers, [])
+
+    def test_update_config_tolerates_malformed_user_claude_json(self):
+        """Corrupted ~/.claude.json must not abort install; treat as empty + rewrite."""
+        self.claude_json.write_text("{not valid json", encoding="utf-8")
+        ok = self.adapter.update_config({"srv": {"command": "x"}})
+        self.assertTrue(ok)
+        data = json.loads(self.claude_json.read_text(encoding="utf-8"))
+        self.assertIn("srv", data["mcpServers"])
+
+    def test_configure_mcp_server_returns_false_when_update_fails(self):
+        """If update_config returns False, configure_mcp_server must surface the failure."""
+        from unittest.mock import MagicMock
+
+        self.adapter.registry_client = MagicMock()
+        self.adapter.registry_client.find_server_by_reference.return_value = {
+            "name": "srv",
+            "packages": [{"registry_name": "npm", "name": "x", "version": "1.0.0"}],
+        }
+        with patch.object(self.adapter, "update_config", return_value=False):
+            ok = self.adapter.configure_mcp_server("srv")
+        self.assertFalse(ok)
+
+
+@pytest.mark.parametrize(
+    "transport",
+    [
+        {"type": "sse", "url": "https://example.com/sse"},
+        {"type": "streamable-http", "url": "https://example.com/sh"},
+    ],
+)
+def test_normalize_sse_and_streamable_http(transport):
+    """Remote SSE / streamable-http entries keep ``type`` and ``url``."""
+    out = ClaudeClientAdapter._normalize_mcp_entry_for_claude_code(
+        {**transport, "id": "", "tools": ["*"]}
+    )
+    assert out["type"] == transport["type"]
+    assert out["url"] == transport["url"]
+    assert "id" not in out
+    assert "tools" not in out
+
+
+class TestMCPIntegratorClaudeStaleCleanup(unittest.TestCase):
+    """``MCPIntegrator.remove_stale`` for Claude project / user files."""
+
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_remove_stale_claude_project_mcp_json(self):
+        from apm_cli.integration.mcp_integrator import MCPIntegrator
+
+        (self.root / ".claude").mkdir()
+        mcp = self.root / ".mcp.json"
+        mcp.write_text(
+            json.dumps({"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}}),
+            encoding="utf-8",
+        )
+        MCPIntegrator.remove_stale(
+            {"stale"},
+            runtime="claude",
+            project_root=self.root,
+            scope=InstallScope.PROJECT,
+        )
+        data = json.loads(mcp.read_text(encoding="utf-8"))
+        self.assertIn("keep", data["mcpServers"])
+        self.assertNotIn("stale", data["mcpServers"])
+
+    def test_remove_stale_claude_project_skips_without_claude_dir(self):
+        from apm_cli.integration.mcp_integrator import MCPIntegrator
+
+        mcp = self.root / ".mcp.json"
+        raw = json.dumps({"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}})
+        mcp.write_text(raw, encoding="utf-8")
+        MCPIntegrator.remove_stale(
+            {"stale"},
+            runtime="claude",
+            project_root=self.root,
+            scope=InstallScope.PROJECT,
+        )
+        self.assertEqual(mcp.read_text(encoding="utf-8"), raw)
+
+    def test_remove_stale_claude_user_claude_json(self):
+        from apm_cli.integration.mcp_integrator import MCPIntegrator
+
+        with patch.object(Path, "home", return_value=self.root):
+            cfg = self.root / ".claude.json"
+            cfg.write_text(
+                json.dumps({"mcpServers": {"keep": {"command": "k"}, "stale": {"command": "s"}}}),
+                encoding="utf-8",
+            )
+            MCPIntegrator.remove_stale({"stale"}, runtime="claude", scope=InstallScope.USER)
+            data = json.loads(cfg.read_text(encoding="utf-8"))
+            self.assertIn("keep", data["mcpServers"])
+            self.assertNotIn("stale", data["mcpServers"])
+
+    def test_remove_stale_scope_none_defaults_safely(self):
+        """When scope is unspecified, only project .mcp.json is touched."""
+        from apm_cli.integration.mcp_integrator import MCPIntegrator
+
+        (self.root / ".claude").mkdir()
+        proj = self.root / ".mcp.json"
+        proj.write_text(json.dumps({"mcpServers": {"stale": {"command": "s"}}}), encoding="utf-8")
+
+        with patch.object(Path, "home", return_value=self.root):
+            user_cfg = self.root / ".claude.json"
+            user_raw = json.dumps({"mcpServers": {"stale": {"command": "u"}}})
+            user_cfg.write_text(user_raw, encoding="utf-8")
+
+            MCPIntegrator.remove_stale(
+                {"stale"}, runtime="claude", project_root=self.root, scope=None
+            )
+
+            self.assertNotIn(
+                "stale",
+                json.loads(proj.read_text(encoding="utf-8")).get("mcpServers", {}),
+            )
+            self.assertEqual(user_cfg.read_text(encoding="utf-8"), user_raw)
+
+
+class TestClaudeAutoDetection(unittest.TestCase):
+    """Auto-detection gating for Claude in MCPIntegrator."""
+
+    def test_claude_not_auto_targeted_when_binary_absent_and_no_claude_dir(self):
+        """Claude must NOT appear in filtered runtimes when neither gate is satisfied."""
+        from apm_cli.integration.mcp_integrator import MCPIntegrator
+
+        scripts = {"start": "claude --interactive"}
+        detected = MCPIntegrator._detect_runtimes(scripts)
+        self.assertIn("claude", detected)
+        with patch("apm_cli.integration.mcp_integrator.shutil.which", return_value=None):
+            filtered = MCPIntegrator._filter_runtimes(detected)
+            self.assertNotIn("claude", filtered)
+
+
+if __name__ == "__main__":
+    unittest.main()
