Skip to content

Commit 1d37f18

Browse files
feat(mcp): discovery
get machine information monitor mcp activity monitor discovery time call ai discovery endpoint
1 parent 17f9363 commit 1d37f18

File tree

17 files changed

+1201
-13
lines changed

17 files changed

+1201
-13
lines changed

.importlinter

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ name = ggshield-layers
99
type = layers
1010
layers =
1111
ggshield.__main__
12-
ggshield.cmd.auth | ggshield.cmd.config | ggshield.cmd.hmsl | ggshield.cmd.honeytoken | ggshield.cmd.install | ggshield.cmd.plugin | ggshield.cmd.quota | ggshield.cmd.secret | ggshield.cmd.status | ggshield.cmd.utils
12+
ggshield.cmd.ai | ggshield.cmd.auth | ggshield.cmd.config | ggshield.cmd.hmsl | ggshield.cmd.honeytoken | ggshield.cmd.install | ggshield.cmd.plugin | ggshield.cmd.quota | ggshield.cmd.secret | ggshield.cmd.status | ggshield.cmd.utils
1313
ggshield.verticals.ai | ggshield.verticals.auth | ggshield.verticals.hmsl | ggshield.verticals.secret
1414
ggshield.core
1515
click | ggshield.utils | pygitguardian
@@ -22,6 +22,7 @@ unmatched_ignore_imports_alerting = warn
2222
name = verticals-cmd-transversals
2323
type = forbidden
2424
source_modules =
25+
ggshield.cmd.ai
2526
ggshield.cmd.auth
2627
ggshield.cmd.config
2728
ggshield.cmd.hmsl
@@ -38,6 +39,8 @@ forbidden_modules =
3839
ggshield.verticals.hmsl
3940
ggshield.verticals.secret
4041
ignore_imports =
42+
ggshield.cmd.ai.** -> ggshield.verticals.ai
43+
ggshield.cmd.ai.** -> ggshield.verticals.ai.**
4144
ggshield.cmd.auth.** -> ggshield.verticals.auth
4245
ggshield.cmd.auth.** -> ggshield.verticals.auth.**
4346
ggshield.cmd.auth.** -> ggshield.verticals.hmsl.**

ggshield/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import click
1111

1212
from ggshield import __version__
13+
from ggshield.cmd.ai import ai_group
1314
from ggshield.cmd.auth import auth_group
1415
from ggshield.cmd.config import config_group
1516
from ggshield.cmd.hmsl import hmsl_group
@@ -88,6 +89,7 @@ def _load_plugins() -> PluginRegistry:
8889
@click.group(
8990
context_settings={"help_option_names": ["-h", "--help"]},
9091
commands={
92+
"ai": ai_group,
9193
"auth": auth_group,
9294
"config": config_group,
9395
"plugin": plugin_group,

ggshield/cmd/ai/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Any
2+
3+
import click
4+
5+
from ggshield.cmd.ai.discover import discover_cmd
6+
from ggshield.cmd.utils.common_options import add_common_options
7+
8+
9+
@click.group(commands={"discover": discover_cmd})
10+
@add_common_options()
11+
def ai_group(**kwargs: Any) -> None:
12+
"""Commands to work with MCP (Model Context Protocol) servers."""

ggshield/cmd/ai/discover.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
MCP Discover command - Discovers MCP servers and optionally probes them
3+
for tools, resources, and prompts.
4+
"""
5+
6+
import json
7+
from typing import Any
8+
9+
import click
10+
from rich import print
11+
12+
from ggshield.cmd.utils.common_options import add_common_options
13+
from ggshield.cmd.utils.context_obj import ContextObj
14+
from ggshield.core import ui
15+
from ggshield.core.client import create_client_from_config
16+
from ggshield.core.errors import APIKeyCheckError, UnknownInstanceError
17+
from ggshield.verticals.ai.discovery import (
18+
discover_ai_configuration,
19+
save_discovery_cache,
20+
submit_ai_discovery,
21+
)
22+
23+
24+
@click.command(name="discover")
25+
@click.option(
26+
"--json",
27+
"use_json",
28+
is_flag=True,
29+
default=False,
30+
help="Output as JSON",
31+
)
32+
@add_common_options()
33+
@click.pass_context
34+
def discover_cmd(
35+
ctx: click.Context,
36+
use_json: bool,
37+
**kwargs: Any,
38+
) -> None:
39+
"""
40+
Discover MCP servers and their configuration.
41+
42+
Parses MCP configuration files from supported assistants
43+
44+
Examples:
45+
ggshield mcp discover
46+
ggshield mcp discover --json
47+
"""
48+
49+
config = discover_ai_configuration()
50+
51+
if use_json:
52+
click.echo(json.dumps(config.model_dump(mode="json"), indent=2))
53+
else:
54+
print(config)
55+
56+
ctx_obj = ContextObj.get(ctx)
57+
try:
58+
client = create_client_from_config(ctx_obj.config)
59+
except (APIKeyCheckError, UnknownInstanceError) as exc:
60+
ui.display_warning(
61+
f"Skipping upload of AI discovery to GitGuardian ({exc}). "
62+
"Authenticate with `ggshield auth login` to enable upload."
63+
)
64+
return
65+
66+
try:
67+
config = submit_ai_discovery(client, config)
68+
save_discovery_cache(config)
69+
except Exception as exc:
70+
ui.display_warning(f"Could not upload AI discovery to GitGuardian: {exc}")

ggshield/verticals/ai/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
from .agents import AGENTS
2+
from .config import load_mcp_config
3+
from .discovery import (
4+
discover_ai_configuration,
5+
load_discovery_cache,
6+
save_discovery_cache,
7+
)
28
from .hooks import AIHookScanner
39
from .installation import install_hooks
410

511

612
__all__ = [
713
"AGENTS",
814
"AIHookScanner",
15+
"discover_ai_configuration",
916
"install_hooks",
17+
"load_discovery_cache",
18+
"load_mcp_config",
19+
"save_discovery_cache",
1020
]
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
from typing import Dict
2+
3+
from ..models import Agent
14
from .claude_code import Claude
25
from .copilot import Copilot
36
from .cursor import Cursor
47

58

6-
AGENTS = {agent.name: agent for agent in [Cursor(), Claude(), Copilot()]}
9+
AGENTS: Dict[str, Agent] = {
10+
agent.name: agent for agent in [Cursor(), Claude(), Copilot()]
11+
}
712

813

914
__all__ = ["AGENTS", "Claude", "Copilot", "Cursor"]

ggshield/verticals/ai/agents/claude_code.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import json
2+
import re
23
from pathlib import Path
3-
from typing import Any, Dict, List, Optional
4+
from typing import Any, Dict, Iterator, List, Optional
45

56
import 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

1022
class 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)

ggshield/verticals/ai/agents/copilot.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import json
22
from pathlib import Path
3+
from typing import Iterator
34

45
import click
56

6-
from ..models import EventType, HookResult
7+
from ggshield.core.dirs import get_user_home_dir
8+
9+
from ..models import AIDiscovery, EventType, HookPayload, HookResult, MCPActivityRequest
710
from .claude_code import Claude
811

912

@@ -21,6 +24,10 @@ def name(self) -> str:
2124
def display_name(self) -> str:
2225
return "Copilot Chat"
2326

27+
@property
28+
def config_folder(self) -> Path:
29+
return get_user_home_dir() / ".config" / "Code" / "User"
30+
2431
def output_result(self, result: HookResult) -> int:
2532
response = {}
2633
if result.block:
@@ -45,3 +52,43 @@ def output_result(self, result: HookResult) -> int:
4552
@property
4653
def settings_path(self) -> Path:
4754
return Path(".github") / "hooks" / "hooks.json"
55+
56+
def project_mcp_file(self, directory: Path) -> Path:
57+
return directory / ".vscode" / "mcp.json"
58+
59+
def discover_project_directories(self) -> Iterator[Path]:
60+
# Try to parse workspaces settings.
61+
for file in self.config_folder.glob("workspaceStorage/*/workspace.json"):
62+
if (data := self._load_json_file(file)) and "folder" in data:
63+
path = Path(data["folder"].removeprefix("file://"))
64+
if path.is_dir():
65+
yield path.resolve()
66+
67+
def parse_mcp_activity(
68+
self, payload: HookPayload, ai_config: AIDiscovery
69+
) -> MCPActivityRequest:
70+
"""Parse the MCP activity from an MCP hook payload."""
71+
72+
# Copilot's hook tool name is "mcp_{server}_{tool}"
73+
# which is unfortunate because a lot of tools have a "_" in their name.
74+
raw_tool_name: str = payload.raw.get("tool_name", "")
75+
server_cfg_name, tool = raw_tool_name.split("_")
76+
77+
# Lookup the server name based on its configuration name
78+
# Fallback to the server name if not found
79+
server_name = server_cfg_name
80+
for server in ai_config.servers:
81+
for configuration in server.configurations:
82+
if configuration.name == server_cfg_name:
83+
server_name = configuration.name
84+
break
85+
86+
return MCPActivityRequest(
87+
user=ai_config.user,
88+
tool=tool,
89+
server=server_name,
90+
agent=self.name,
91+
model="",
92+
cwd=Path(payload.raw.get("cwd", "")),
93+
input=payload.raw.get("tool_input", {}),
94+
)

0 commit comments

Comments
 (0)