From bb9f6409c421e6b511f346c0e2ffbd616affe891 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 4 Apr 2026 00:06:00 +0900 Subject: [PATCH] fix(plugin): extend runtime_mode to check settings.json and project .mcp.json (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add project_dir parameter to detect_runtime_mode() and is_mcp_available() - Check 3 locations in order: mcp.json → settings.json → project .mcp.json - Extract _check_mcp_json() and _check_settings_json() helper functions - Add 18 new tests covering settings.json, project .mcp.json, priority order Closes #1224 --- .../hooks/lib/runtime_mode.py | 70 ++++++-- .../tests/test_runtime_mode.py | 156 ++++++++++++++++++ 2 files changed, 210 insertions(+), 16 deletions(-) diff --git a/packages/claude-code-plugin/hooks/lib/runtime_mode.py b/packages/claude-code-plugin/hooks/lib/runtime_mode.py index a6af9c65..90defd23 100644 --- a/packages/claude-code-plugin/hooks/lib/runtime_mode.py +++ b/packages/claude-code-plugin/hooks/lib/runtime_mode.py @@ -1,6 +1,10 @@ """Runtime mode detection — MCP vs standalone. -Checks ~/.claude/mcp.json for codingbuddy MCP server entry. +Checks multiple locations for codingbuddy MCP server entry: +1. ~/.claude/mcp.json (global MCP config) +2. ~/.claude/settings.json → mcpServers (global settings) +3. {project_dir}/.mcp.json (project-level config) + Used by prompt_injection, user-prompt-submit, and health_check. """ import json @@ -8,33 +12,67 @@ from typing import Optional -def detect_runtime_mode(home_dir: Optional[str] = None) -> str: +def _check_mcp_json(path: str) -> bool: + """Check if file has mcpServers with a codingbuddy entry.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + mcp_servers = data.get("mcpServers", {}) + return any("codingbuddy" in key.lower() for key in mcp_servers) + except (OSError, json.JSONDecodeError, TypeError): + return False + + +def _check_settings_json(path: str) -> bool: + """Check settings.json mcpServers for a codingbuddy entry.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + mcp_servers = data.get("mcpServers", {}) + return any("codingbuddy" in key.lower() for key in mcp_servers) + except (OSError, json.JSONDecodeError, TypeError): + return False + + +def detect_runtime_mode( + home_dir: Optional[str] = None, + project_dir: Optional[str] = None, +) -> str: """Detect if CodingBuddy MCP server is configured. - Checks ~/.claude/mcp.json for an entry containing 'codingbuddy'. + Checks three locations in order and returns 'mcp' on first match: + 1. ~/.claude/mcp.json (global MCP config) + 2. ~/.claude/settings.json → mcpServers (global settings) + 3. {project_dir}/.mcp.json (project-level config) Args: home_dir: Override home directory (for testing). + project_dir: Project directory to check for .mcp.json. Returns: 'mcp' if codingbuddy MCP server found, 'standalone' otherwise. """ home = home_dir or os.path.expanduser("~") - mcp_json_path = os.path.join(home, ".claude", "mcp.json") - try: - with open(mcp_json_path, "r", encoding="utf-8") as f: - data = json.load(f) + # 1. ~/.claude/mcp.json (global MCP config) + if _check_mcp_json(os.path.join(home, ".claude", "mcp.json")): + return "mcp" - mcp_servers = data.get("mcpServers", {}) - for key in mcp_servers: - if "codingbuddy" in key.lower(): - return "mcp" - return "standalone" - except (OSError, json.JSONDecodeError, TypeError): - return "standalone" + # 2. ~/.claude/settings.json → mcpServers + if _check_settings_json(os.path.join(home, ".claude", "settings.json")): + return "mcp" + + # 3. {project_dir}/.mcp.json (project-level) + if project_dir: + if _check_mcp_json(os.path.join(project_dir, ".mcp.json")): + return "mcp" + + return "standalone" -def is_mcp_available(home_dir: Optional[str] = None) -> bool: +def is_mcp_available( + home_dir: Optional[str] = None, + project_dir: Optional[str] = None, +) -> bool: """Convenience wrapper: True if MCP mode detected.""" - return detect_runtime_mode(home_dir) == "mcp" + return detect_runtime_mode(home_dir, project_dir) == "mcp" diff --git a/packages/claude-code-plugin/tests/test_runtime_mode.py b/packages/claude-code-plugin/tests/test_runtime_mode.py index b4e67a97..270fc797 100644 --- a/packages/claude-code-plugin/tests/test_runtime_mode.py +++ b/packages/claude-code-plugin/tests/test_runtime_mode.py @@ -15,12 +15,32 @@ def fake_home(tmp_path): return tmp_path +@pytest.fixture() +def fake_project(tmp_path): + """Create a fake project directory.""" + project_dir = tmp_path / "my-project" + project_dir.mkdir() + return project_dir + + def _write_mcp_json(home_path, data): mcp_path = os.path.join(str(home_path), ".claude", "mcp.json") with open(mcp_path, "w", encoding="utf-8") as f: json.dump(data, f) +def _write_settings_json(home_path, data): + settings_path = os.path.join(str(home_path), ".claude", "settings.json") + with open(settings_path, "w", encoding="utf-8") as f: + json.dump(data, f) + + +def _write_project_mcp_json(project_path, data): + mcp_path = os.path.join(str(project_path), ".mcp.json") + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(data, f) + + class TestDetectRuntimeMode: def test_returns_mcp_when_codingbuddy_entry_exists(self, fake_home): _write_mcp_json(fake_home, { @@ -61,6 +81,126 @@ def test_returns_standalone_when_mcp_servers_missing(self, fake_home): assert detect_runtime_mode(str(fake_home)) == "standalone" +class TestSettingsJsonDetection: + def test_returns_mcp_from_settings_json(self, fake_home): + """settings.json with codingbuddy mcpServers → 'mcp'.""" + _write_settings_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx", "args": ["codingbuddy"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_settings_json_case_insensitive(self, fake_home): + _write_settings_json(fake_home, { + "mcpServers": { + "CodingBuddy-MCP": {"command": "node", "args": ["server.js"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_settings_json_without_codingbuddy_returns_standalone(self, fake_home): + """settings.json without codingbuddy → still checks next, returns standalone.""" + _write_settings_json(fake_home, { + "mcpServers": { + "other-tool": {"command": "npx", "args": ["other"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "standalone" + + def test_settings_json_invalid_json(self, fake_home): + settings_path = os.path.join(str(fake_home), ".claude", "settings.json") + with open(settings_path, "w", encoding="utf-8") as f: + f.write("not json!") + assert detect_runtime_mode(str(fake_home)) == "standalone" + + def test_settings_json_no_mcp_servers_key(self, fake_home): + _write_settings_json(fake_home, {"permissions": {"allow": []}}) + assert detect_runtime_mode(str(fake_home)) == "standalone" + + +class TestProjectMcpJsonDetection: + def test_returns_mcp_from_project_mcp_json(self, fake_home, fake_project): + """project .mcp.json with codingbuddy → 'mcp'.""" + _write_project_mcp_json(fake_project, { + "mcpServers": { + "codingbuddy": {"command": "npx", "args": ["codingbuddy"]} + } + }) + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "mcp" + + def test_project_mcp_json_case_insensitive(self, fake_home, fake_project): + _write_project_mcp_json(fake_project, { + "mcpServers": { + "CodingBuddy-Rules": {"command": "npx"} + } + }) + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "mcp" + + def test_project_mcp_json_without_codingbuddy(self, fake_home, fake_project): + _write_project_mcp_json(fake_project, { + "mcpServers": { + "some-other-server": {"command": "npx"} + } + }) + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "standalone" + + def test_project_dir_none_skips_check(self, fake_home): + """When project_dir is None, skip project-level check.""" + assert detect_runtime_mode(str(fake_home), None) == "standalone" + + def test_project_mcp_json_missing(self, fake_home, fake_project): + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "standalone" + + +class TestPriorityOrder: + def test_mcp_json_takes_precedence_over_settings(self, fake_home): + """mcp.json is checked first — settings.json is not needed.""" + _write_mcp_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + _write_settings_json(fake_home, { + "mcpServers": { + "other-server": {"command": "npx"} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_settings_json_checked_when_mcp_json_absent(self, fake_home): + """Falls through to settings.json when mcp.json has no match.""" + _write_mcp_json(fake_home, { + "mcpServers": { + "other-server": {"command": "npx"} + } + }) + _write_settings_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_project_mcp_json_checked_last(self, fake_home, fake_project): + """Falls through to project .mcp.json when home configs have no match.""" + _write_mcp_json(fake_home, {"mcpServers": {"other": {"command": "x"}}}) + _write_settings_json(fake_home, {"mcpServers": {"other2": {"command": "x"}}}) + _write_project_mcp_json(fake_project, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "mcp" + + def test_all_three_empty_returns_standalone(self, fake_home, fake_project): + """All 3 locations empty → 'standalone'.""" + _write_mcp_json(fake_home, {"mcpServers": {}}) + _write_settings_json(fake_home, {"mcpServers": {}}) + _write_project_mcp_json(fake_project, {"mcpServers": {}}) + assert detect_runtime_mode(str(fake_home), str(fake_project)) == "standalone" + + class TestIsMcpAvailable: def test_returns_true_when_mcp(self, fake_home): _write_mcp_json(fake_home, { @@ -72,3 +212,19 @@ def test_returns_true_when_mcp(self, fake_home): def test_returns_false_when_standalone(self, tmp_path): assert is_mcp_available(str(tmp_path)) is False + + def test_returns_true_from_settings_json(self, fake_home): + _write_settings_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + assert is_mcp_available(str(fake_home)) is True + + def test_returns_true_from_project_mcp_json(self, fake_home, fake_project): + _write_project_mcp_json(fake_project, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + assert is_mcp_available(str(fake_home), str(fake_project)) is True