11import json
2+ import re
23from pathlib import Path
3- from typing import Any , Dict , List , Optional
4+ from typing import Any , Dict , Iterator , List , Optional
45
56import click
67
7- from ..models import Agent , EventType , HookResult
8+ from ggshield .core .dirs import get_user_home_dir
9+
10+ from ..models import (
11+ Agent ,
12+ AIDiscovery ,
13+ EventType ,
14+ HookPayload ,
15+ HookResult ,
16+ MCPActivityRequest ,
17+ MCPConfiguration ,
18+ Scope ,
19+ )
820
921
1022class Claude (Agent ):
@@ -18,6 +30,10 @@ def name(self) -> str:
1830 def display_name (self ) -> str :
1931 return "Claude Code"
2032
33+ @property
34+ def config_folder (self ) -> Path :
35+ return get_user_home_dir () / ".claude"
36+
2137 def output_result (self , result : HookResult ) -> int :
2238 response = {}
2339 if result .block :
@@ -106,3 +122,77 @@ def settings_locate(
106122 if "ggshield" in command or "<COMMAND>" in command :
107123 return obj
108124 return None
125+
126+ def project_mcp_file (self , directory : Path ) -> Path :
127+ return directory / ".mcp.json"
128+
129+ def _get_user_mcp_configurations (self ) -> Iterator [MCPConfiguration ]:
130+ """Look into ~/.claude.json for both user-level and project-level MCP server entries."""
131+ # Load config file
132+ filepath = get_user_home_dir () / ".claude.json"
133+ if not (data := self ._load_json_file (filepath )):
134+ return
135+
136+ # User-level mcpServers
137+ yield from self ._parse_servers_block (data , Scope .USER , None )
138+
139+ # Per-project entries in projects dict
140+ projects = data .get ("projects" , {})
141+ if not isinstance (projects , dict ):
142+ return
143+ for project_key , project_data in projects .items ():
144+ if not isinstance (project_data , dict ):
145+ continue
146+ yield from self ._parse_servers_block (
147+ project_data , Scope .USER , Path (project_key )
148+ )
149+
150+ def discover_project_directories (self ) -> Iterator [Path ]:
151+ """Discover project directories by scraping config files."""
152+ history_file = self .config_folder / "history.jsonl"
153+ projects = set ()
154+ for line in self ._load_jsonl_file (history_file ):
155+ if "project" in line :
156+ projects .add (Path (line ["project" ]))
157+ for project in projects :
158+ if project .is_dir ():
159+ yield project .resolve ()
160+
161+ def parse_mcp_activity (
162+ self , payload : HookPayload , ai_config : AIDiscovery
163+ ) -> MCPActivityRequest :
164+ """Parse the MCP activity from an MCP hook payload."""
165+
166+ # Claude Code's hook tool name is "mcp__{server}__{tool}"
167+ raw_tool_name : str = payload .raw .get ("tool_name" , "" )
168+ parts = raw_tool_name .split ("__" )
169+ # The server name can be anything, but we assume no MCP tool has a "__" in its name
170+ tool = parts [- 1 ]
171+ server_cfg_name = "__" .join (parts [1 :- 1 ])
172+
173+ # Lookup the server name based on its configuration name
174+ # Fallback to the server name if not found
175+ server_name = server_cfg_name
176+ for server in ai_config .servers :
177+ for configuration in server .configurations :
178+ if _mangle_server_name (configuration .name ) == server_cfg_name :
179+ server_name = server .name
180+ break
181+
182+ return MCPActivityRequest (
183+ user = ai_config .user ,
184+ tool = tool ,
185+ server = server_name ,
186+ agent = self .name ,
187+ model = "" ,
188+ cwd = Path (payload .raw .get ("cwd" , "" )),
189+ input = payload .raw .get ("tool_input" , {}),
190+ )
191+
192+
193+ MANGLING_PATTERN = re .compile (r"[^A-Za-z0-9-]" )
194+
195+
196+ def _mangle_server_name (name : str ) -> str :
197+ """Mangle a server name in the same way Claude Code does."""
198+ return MANGLING_PATTERN .sub ("_" , name )
0 commit comments