11import json
2+ import logging
23import re
4+ from datetime import datetime , timezone
35from pathlib import Path
4- from typing import Any , Dict , Iterator , Literal , Tuple
6+ from typing import Any , Dict , Iterator , Literal , Optional , Tuple
57
68import click
79from pygitguardian .models import AIDiscovery , MCPActivityRequest
1113from ..models import Agent , EventType , HookPayload , HookResult
1214
1315
16+ logger = logging .getLogger (__name__ )
17+
18+
1419class 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
122246MANGLING_PATTERN = re .compile (r"[^A-Za-z0-9-]+" )
123247
0 commit comments