Skip to content

Commit 9889bca

Browse files
committed
feat: mcp activity history for VSCode chat
1 parent 50b89c2 commit 9889bca

3 files changed

Lines changed: 409 additions & 15 deletions

File tree

ggshield/verticals/ai/agents/copilot.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
import logging
12
import re
23
from pathlib import Path
3-
from typing import Dict, Iterator, Tuple
4+
from typing import Dict, Iterator, Optional, Tuple
45

56
from ggshield.core.dirs import get_user_home_dir
67
from ggshield.verticals.ai.models import (
78
AIDiscovery,
89
EventType,
910
HookPayload,
11+
MCPActivityRequest,
1012
MCPConfiguration,
1113
Scope,
1214
Tool,
@@ -15,6 +17,8 @@
1517

1618
from .vscode import VSCode
1719

20+
logger = logging.getLogger(__name__)
21+
1822

1923
class Copilot(VSCode):
2024
"""Behavior specific to Copilot CLI.
@@ -87,7 +91,7 @@ def post_process_payload(self, payload: HookPayload):
8791
payload.tool = Tool.MCP
8892

8993
def _lookup_server_name(
90-
self, raw_tool_name: str, ai_config: AIDiscovery
94+
self, raw_tool_name: str, ai_config: Optional[AIDiscovery]
9195
) -> Tuple[str, str]:
9296
# Copilot's hook tool name is "{server}-{tool}"
9397
# which is unfortunate because server names can contain "-" in their name.
@@ -97,12 +101,16 @@ def _lookup_server_name(
97101
# We look for the longest chain of parts separated by "-" that is a valid server configuration name.
98102

99103
# Build a map of mangled server configuration names to server names.
100-
mangled_to_server: Dict[str, str] = {
101-
_mangle_name(configuration.name): server.name
102-
for server in ai_config.servers
103-
for configuration in server.configurations
104-
if configuration.agent == self.name
105-
}
104+
mangled_to_server: Dict[str, str] = (
105+
{
106+
_mangle_name(configuration.name): server.name
107+
for server in ai_config.servers
108+
for configuration in server.configurations
109+
if configuration.agent == self.name
110+
}
111+
if ai_config is not None
112+
else {}
113+
)
106114

107115
parts = raw_tool_name.split("-")
108116

@@ -116,6 +124,11 @@ def _lookup_server_name(
116124
# If no match is found, fallback to use the last part as the tool name.
117125
return "-".join(parts[:-1]), parts[-1]
118126

127+
def iter_history_events(
128+
self, ai_config: Optional[AIDiscovery]
129+
) -> Iterator[MCPActivityRequest]:
130+
return iter(())
131+
119132

120133
def _mangle_name(name: str) -> str:
121134
"""Mangle a name in the same way Copilot does."""

ggshield/verticals/ai/agents/vscode.py

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
2+
import logging
23
import re
4+
from datetime import datetime, timezone
35
from pathlib import Path
4-
from typing import Any, Dict, Iterator, Literal, Tuple
6+
from typing import Any, Dict, Iterator, Literal, Optional, Tuple
57

68
import click
79
from pygitguardian.models import AIDiscovery, MCPActivityRequest
@@ -11,6 +13,9 @@
1113
from ..models import Agent, EventType, HookPayload, HookResult
1214

1315

16+
logger = logging.getLogger(__name__)
17+
18+
1419
class VSCode(Agent):
1520
"""Behavior specific to VSCode."""
1621

@@ -89,7 +94,7 @@ def parse_mcp_activity(
8994
)
9095

9196
def _lookup_server_name(
92-
self, raw_tool_name: str, ai_config: AIDiscovery
97+
self, raw_tool_name: str, ai_config: Optional[AIDiscovery]
9398
) -> Tuple[str, str]:
9499
# VSCode's hook tool name is "mcp_{server}_{tool}"
95100
# which is unfortunate because a lot of tools have a "_" in their name.
@@ -100,11 +105,15 @@ def _lookup_server_name(
100105
# we look for the longest chain of parts separated by "_" that is a valid server configuration name.
101106

102107
# Build a map of mangled server configuration names to server names.
103-
mangled_to_server: Dict[str, str] = {
104-
_mangle_name(configuration.name): server.name
105-
for server in ai_config.servers
106-
for configuration in server.configurations
107-
}
108+
mangled_to_server: Dict[str, str] = (
109+
{
110+
_mangle_name(configuration.name): server.name
111+
for server in ai_config.servers
112+
for configuration in server.configurations
113+
}
114+
if ai_config is not None
115+
else {}
116+
)
108117

109118
# This get rid of the "mcp_" prefix.
110119
_, *parts = raw_tool_name.split("_")
@@ -118,6 +127,121 @@ def _lookup_server_name(
118127
# If no match is found, fallback to use the first part as the server name.
119128
return parts[0], "_".join(parts[1:])
120129

130+
def iter_history_events(
131+
self, ai_config: Optional[AIDiscovery]
132+
) -> Iterator[MCPActivityRequest]:
133+
"""Walk every Copilot Chat session and yield MCP tool calls.
134+
135+
Sessions live under
136+
``/workspaceStorage/<hash>/chatSessions/<id>.jsonl``.
137+
Iterating per-workspace lets us read each ``workspace.json`` once.
138+
"""
139+
for workspace_dir in sorted(self.config_folder.glob("workspaceStorage/*")):
140+
cwd = self._workspace_cwd(workspace_dir)
141+
for session in sorted(workspace_dir.glob("chatSessions/*.jsonl")):
142+
try:
143+
yield from self._parse_session_file(session, cwd, ai_config)
144+
except OSError as exc:
145+
logger.warning("VSCode: skipping %s: %s", session, exc)
146+
147+
def _workspace_cwd(self, workspace_dir: Path) -> str:
148+
"""Return the project folder backing a ``workspaceStorage/<hash>`` directory."""
149+
data = self._load_json_file(workspace_dir / "workspace.json")
150+
folder = (data or {}).get("folder", "") if data else ""
151+
return folder.removeprefix("file://") if isinstance(folder, str) else ""
152+
153+
def _parse_session_file(
154+
self, path: Path, cwd: str, ai_config: Optional[AIDiscovery]
155+
) -> Iterator[MCPActivityRequest]:
156+
"""Yield deduped MCP events from a single Copilot Chat session file."""
157+
seen: set = set()
158+
last_ts: Optional[datetime] = None
159+
with path.open("r", encoding="utf-8", errors="ignore") as history_file:
160+
for raw in history_file:
161+
try:
162+
line = json.loads(raw)
163+
except (json.JSONDecodeError, ValueError):
164+
continue
165+
content = line.get("v") if isinstance(line, dict) else None
166+
# A request snapshot carries timestamp (unix ms) at the top of v[].
167+
# Bare-delta lines (just a toolInvocation) don't — fall back to
168+
# the last seen request timestamp.
169+
if isinstance(content, list):
170+
for item in content:
171+
if isinstance(item, dict) and isinstance(
172+
item.get("timestamp"), (int, float)
173+
):
174+
try:
175+
last_ts = datetime.fromtimestamp(
176+
item["timestamp"] / 1000, tz=timezone.utc
177+
)
178+
except (ValueError, OSError, OverflowError):
179+
pass
180+
for inv in _find_mcp_invocations(content):
181+
tool_call_id = inv.get("toolCallId")
182+
if not tool_call_id or tool_call_id in seen:
183+
continue
184+
event = self._build_activity(inv, cwd, last_ts, ai_config)
185+
if event is None:
186+
continue
187+
seen.add(tool_call_id)
188+
yield event
189+
190+
def _build_activity(
191+
self,
192+
invocation: Dict[str, Any],
193+
cwd: str,
194+
timestamp: Optional[datetime],
195+
ai_config: Optional[AIDiscovery],
196+
) -> Optional[MCPActivityRequest]:
197+
if timestamp is None:
198+
return None
199+
source = invocation.get("source") or {}
200+
server_cfg_name = source.get("label") or ""
201+
tool_id = invocation.get("toolId") or ""
202+
if not server_cfg_name or not tool_id:
203+
return None
204+
tool_input = (invocation.get("toolSpecificData") or {}).get("rawInput") or {}
205+
if not isinstance(tool_input, dict):
206+
tool_input = {}
207+
_, tool_name = self._lookup_server_name(tool_id, ai_config)
208+
return MCPActivityRequest(
209+
user=self._user_or_default(ai_config),
210+
tool=tool_name,
211+
server=self._resolve_server_name(server_cfg_name, ai_config),
212+
agent=self.name,
213+
model="",
214+
cwd=cwd,
215+
input=tool_input,
216+
timestamp=timestamp,
217+
)
218+
219+
def _resolve_server_name(
220+
self, cfg_name: str, ai_config: Optional[AIDiscovery]
221+
) -> str:
222+
"""Resolve a bubble's ``source.label`` to the canonical server name."""
223+
if ai_config is None or not cfg_name:
224+
return cfg_name
225+
for server in ai_config.servers:
226+
for configuration in server.configurations:
227+
if configuration.name == cfg_name:
228+
return server.name
229+
return cfg_name
230+
231+
232+
def _find_mcp_invocations(obj: Any) -> Iterator[Dict[str, Any]]:
233+
"""Yield every ``toolInvocationSerialized`` dict with ``source.type == "mcp"``."""
234+
if isinstance(obj, dict):
235+
if obj.get("kind") == "toolInvocationSerialized":
236+
source = obj.get("source") or {}
237+
if isinstance(source, dict) and source.get("type") == "mcp":
238+
yield obj
239+
for value in obj.values():
240+
yield from _find_mcp_invocations(value)
241+
elif isinstance(obj, list):
242+
for value in obj:
243+
yield from _find_mcp_invocations(value)
244+
121245

122246
MANGLING_PATTERN = re.compile(r"[^A-Za-z0-9-]+")
123247

0 commit comments

Comments
 (0)