windows-mcp 0.5.7__py3-none-any.whl → 0.5.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
windows_mcp/__main__.py CHANGED
@@ -1,11 +1,10 @@
1
1
  from windows_mcp.analytics import PostHogAnalytics, with_analytics
2
- from live_inspect.watch_cursor import WatchCursor
3
- from windows_mcp.desktop.service import Desktop
2
+ from windows_mcp.desktop.service import Desktop,Size
3
+ from windows_mcp.watchdog.service import WatchDog
4
4
  from contextlib import asynccontextmanager
5
5
  from fastmcp.utilities.types import Image
6
6
  from mcp.types import ToolAnnotations
7
7
  from typing import Literal, Optional
8
- from humancursor import SystemCursor
9
8
  from fastmcp import FastMCP, Context
10
9
  from dotenv import load_dotenv
11
10
  from textwrap import dedent
@@ -16,46 +15,50 @@ import os
16
15
 
17
16
  load_dotenv()
18
17
 
18
+ MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT = 1920, 1080
19
19
  pg.FAILSAFE=False
20
20
  pg.PAUSE=1.0
21
21
 
22
- desktop=Desktop()
23
- cursor=SystemCursor()
24
- watch_cursor=WatchCursor()
25
- windows_version=desktop.get_windows_version()
26
- default_language=desktop.get_default_language()
27
- screen_width,screen_height=desktop.get_resolution()
22
+ desktop: Optional[Desktop] = None
23
+ watchdog: Optional[WatchDog] = None
24
+ analytics: Optional[PostHogAnalytics] = None
25
+ screen_size:Optional[Size]=None
28
26
 
29
27
  instructions=dedent(f'''
30
- Windows MCP server provides tools to interact directly with the {windows_version} desktop,
28
+ Windows MCP server provides tools to interact directly with the Windows desktop,
31
29
  thus enabling to operate the desktop on the user's behalf.
32
30
  ''')
33
31
 
34
- # Initialize analytics at module level to be used in decorators
35
- if os.getenv("ANONYMIZED_TELEMETRY", "true").lower() == "false":
36
- analytics = None
37
- else:
38
- analytics = PostHogAnalytics()
39
-
40
32
  @asynccontextmanager
41
33
  async def lifespan(app: FastMCP):
42
34
  """Runs initialization code before the server starts and cleanup code after it shuts down."""
35
+ global desktop, watchdog, analytics,screen_size
36
+
37
+ # Initialize components here instead of at module level
38
+ if os.getenv("ANONYMIZED_TELEMETRY", "true").lower() != "false":
39
+ analytics = PostHogAnalytics()
40
+ desktop = Desktop()
41
+ watchdog = WatchDog()
42
+ screen_size=desktop.get_screen_size()
43
+ watchdog.set_focus_callback(desktop.tree._on_focus_change)
44
+
43
45
  try:
44
- watch_cursor.start()
46
+ watchdog.start()
45
47
  await asyncio.sleep(1) # Simulate startup latency
46
48
  yield
47
49
  finally:
48
- watch_cursor.stop()
50
+ if watchdog:
51
+ watchdog.stop()
49
52
  if analytics:
50
53
  await analytics.close()
51
54
 
52
55
  mcp=FastMCP(name='windows-mcp',instructions=instructions,lifespan=lifespan)
53
56
 
54
57
  @mcp.tool(
55
- name="App-Tool",
58
+ name="App",
56
59
  description="Manages Windows applications with three modes: 'launch' (start app by name), 'resize' (set window position/size using window_loc=[x,y] and window_size=[width,height]), 'switch' (activate app by name). Essential for application lifecycle management.",
57
60
  annotations=ToolAnnotations(
58
- title="App Tool",
61
+ title="App",
59
62
  readOnlyHint=False,
60
63
  destructiveHint=True,
61
64
  idempotentHint=False,
@@ -67,10 +70,10 @@ def app_tool(mode:Literal['launch','resize','switch'],name:str|None=None,window_
67
70
  return desktop.app(mode,name,window_loc,window_size)
68
71
 
69
72
  @mcp.tool(
70
- name='Powershell-Tool',
71
- description='Execute PowerShell commands directly on the Windows system and return output with status code. Supports all PowerShell cmdlets, scripts, and system commands. Use for file operations, system queries, and administrative tasks.',
73
+ name='Shell',
74
+ description='A comprehensive system tool for executing any PowerShell commands. Use it to navigate the file system, manage files and processes, and execute system-level operations. Capable of accessing web content (e.g., via Invoke-WebRequest), interacting with network resources, and performing complex administrative tasks. This tool provides full access to the underlying operating system capabilities, making it the primary interface for system automation, scripting, and deep system interaction.',
72
75
  annotations=ToolAnnotations(
73
- title="Powershell Tool",
76
+ title="Shell",
74
77
  readOnlyHint=False,
75
78
  destructiveHint=True,
76
79
  idempotentHint=False,
@@ -78,15 +81,15 @@ def app_tool(mode:Literal['launch','resize','switch'],name:str|None=None,window_
78
81
  )
79
82
  )
80
83
  @with_analytics(analytics, "Powershell-Tool")
81
- def powershell_tool(command: str, ctx: Context = None) -> str:
82
- response,status_code=desktop.execute_command(command)
84
+ def powershell_tool(command: str,timeout:int=10, ctx: Context = None) -> str:
85
+ response,status_code=desktop.execute_command(command,timeout)
83
86
  return f'Response: {response}\nStatus Code: {status_code}'
84
87
 
85
88
  @mcp.tool(
86
- name='State-Tool',
89
+ name='Snapshot',
87
90
  description='Captures complete desktop state including: system language, focused/opened apps, interactive elements (buttons, text fields, links, menus with coordinates), and scrollable areas. Set use_vision=True to include screenshot. Set use_dom=True for browser content to get web page elements instead of browser UI. Always call this first to understand the current desktop state before taking actions.',
88
91
  annotations=ToolAnnotations(
89
- title="State Tool",
92
+ title="Snapshot",
90
93
  readOnlyHint=True,
91
94
  destructiveHint=False,
92
95
  idempotentHint=True,
@@ -96,9 +99,8 @@ def powershell_tool(command: str, ctx: Context = None) -> str:
96
99
  @with_analytics(analytics, "State-Tool")
97
100
  def state_tool(use_vision:bool=False,use_dom:bool=False, ctx: Context = None):
98
101
  # Calculate scale factor to cap resolution at 1080p (1920x1080)
99
- max_width, max_height = 1920, 1080
100
- scale_width = max_width / screen_width if screen_width > max_width else 1.0
101
- scale_height = max_height / screen_height if screen_height > max_height else 1.0
102
+ scale_width = MAX_IMAGE_WIDTH / screen_size.width if screen_size.width > MAX_IMAGE_WIDTH else 1.0
103
+ scale_height = MAX_IMAGE_HEIGHT / screen_size.height if screen_size.height > MAX_IMAGE_HEIGHT else 1.0
102
104
  scale = min(scale_width, scale_height) # Use the smaller scale to ensure both dimensions fit
103
105
 
104
106
  desktop_state=desktop.get_state(use_vision=use_vision,use_dom=use_dom,as_bytes=True,scale=scale)
@@ -106,10 +108,7 @@ def state_tool(use_vision:bool=False,use_dom:bool=False, ctx: Context = None):
106
108
  scrollable_elements=desktop_state.tree_state.scrollable_elements_to_string()
107
109
  apps=desktop_state.apps_to_string()
108
110
  active_app=desktop_state.active_app_to_string()
109
- return [dedent(f'''
110
- Default Language of User:
111
- {default_language} with encoding: {desktop.encoding}
112
-
111
+ return [dedent(f'''
113
112
  Focused App:
114
113
  {active_app}
115
114
 
@@ -124,10 +123,10 @@ def state_tool(use_vision:bool=False,use_dom:bool=False, ctx: Context = None):
124
123
  ''')]+([Image(data=desktop_state.screenshot,format='png')] if use_vision else [])
125
124
 
126
125
  @mcp.tool(
127
- name='Click-Tool',
126
+ name='Click',
128
127
  description='Performs mouse clicks at specified coordinates [x, y]. Supports button types: left (default), right (context menu), middle. Supports clicks: 1 (single), 2 (double), 3 (triple). Always use coordinates from State-Tool output to ensure accuracy.',
129
128
  annotations=ToolAnnotations(
130
- title="Click Tool",
129
+ title="Click",
131
130
  readOnlyHint=False,
132
131
  destructiveHint=True,
133
132
  idempotentHint=False,
@@ -144,10 +143,10 @@ def click_tool(loc:list[int],button:Literal['left','right','middle']='left',clic
144
143
  return f'{num_clicks.get(clicks)} {button} clicked at ({x},{y}).'
145
144
 
146
145
  @mcp.tool(
147
- name='Type-Tool',
146
+ name='Type',
148
147
  description='Types text at specified coordinates [x, y]. Set clear=True to clear existing text first (Ctrl+A then type), clear=False to append. Set press_enter=True to submit after typing. Always click on the target input field first to ensure focus.',
149
148
  annotations=ToolAnnotations(
150
- title="Type Tool",
149
+ title="Type",
151
150
  readOnlyHint=False,
152
151
  destructiveHint=True,
153
152
  idempotentHint=False,
@@ -163,10 +162,10 @@ def type_tool(loc:list[int],text:str,clear:bool=False,press_enter:bool=False, ct
163
162
  return f'Typed {text} at ({x},{y}).'
164
163
 
165
164
  @mcp.tool(
166
- name='Scroll-Tool',
165
+ name='Scroll',
167
166
  description='Scrolls at coordinates [x, y] or current mouse position if loc=None. Type: vertical (default) or horizontal. Direction: up/down for vertical, left/right for horizontal. wheel_times controls amount (1 wheel ≈ 3-5 lines). Use for navigating long content, lists, and web pages.',
168
167
  annotations=ToolAnnotations(
169
- title="Scroll Tool",
168
+ title="Scroll",
170
169
  readOnlyHint=False,
171
170
  destructiveHint=False,
172
171
  idempotentHint=True,
@@ -183,29 +182,10 @@ def scroll_tool(loc:list[int]=None,type:Literal['horizontal','vertical']='vertic
183
182
  return f'Scrolled {type} {direction} by {wheel_times} wheel times'+f' at ({loc[0]},{loc[1]}).' if loc else ''
184
183
 
185
184
  @mcp.tool(
186
- name='Drag-Tool',
187
- description='Performs drag-and-drop from current mouse position to destination coordinates [x, y]. Click or move to source position first, then call this tool with target coordinates. Use for moving files, reordering items, resizing windows, or any drag-drop UI interactions.',
188
- annotations=ToolAnnotations(
189
- title="Drag Tool",
190
- readOnlyHint=False,
191
- destructiveHint=True,
192
- idempotentHint=False,
193
- openWorldHint=False
194
- )
195
- )
196
- @with_analytics(analytics, "Drag-Tool")
197
- def drag_tool(to_loc:list[int], ctx: Context = None)->str:
198
- if len(to_loc) != 2:
199
- raise ValueError("to_loc must be a list of exactly 2 integers [x, y]")
200
- desktop.drag(to_loc)
201
- x2,y2=to_loc[0],to_loc[1]
202
- return f'Dragged the element to ({x2},{y2}).'
203
-
204
- @mcp.tool(
205
- name='Move-Tool',
206
- description='Moves mouse cursor to coordinates [x, y] without clicking. Use for hovering to reveal tooltips/menus, positioning cursor before drag operations, or triggering hover-based UI changes. Does not interact with elements.',
185
+ name='Move',
186
+ description='Moves mouse cursor to coordinates [x, y]. Set drag=True to perform a drag-and-drop operation from the current mouse position to the target coordinates. Default (drag=False) is a simple cursor move (hover).',
207
187
  annotations=ToolAnnotations(
208
- title="Move Tool",
188
+ title="Move",
209
189
  readOnlyHint=False,
210
190
  destructiveHint=False,
211
191
  idempotentHint=True,
@@ -213,18 +193,22 @@ def drag_tool(to_loc:list[int], ctx: Context = None)->str:
213
193
  )
214
194
  )
215
195
  @with_analytics(analytics, "Move-Tool")
216
- def move_tool(to_loc:list[int], ctx: Context = None)->str:
217
- if len(to_loc) != 2:
218
- raise ValueError("to_loc must be a list of exactly 2 integers [x, y]")
219
- x,y=to_loc[0],to_loc[1]
220
- desktop.move(to_loc)
221
- return f'Moved the mouse pointer to ({x},{y}).'
196
+ def move_tool(loc:list[int], drag:bool=False, ctx: Context = None)->str:
197
+ if len(loc) != 2:
198
+ raise ValueError("loc must be a list of exactly 2 integers [x, y]")
199
+ x,y=loc[0],loc[1]
200
+ if drag:
201
+ desktop.drag(loc)
202
+ return f'Dragged to ({x},{y}).'
203
+ else:
204
+ desktop.move(loc)
205
+ return f'Moved the mouse pointer to ({x},{y}).'
222
206
 
223
207
  @mcp.tool(
224
- name='Shortcut-Tool',
208
+ name='Shortcut',
225
209
  description='Executes keyboard shortcuts using key combinations separated by +. Examples: "ctrl+c" (copy), "ctrl+v" (paste), "alt+tab" (switch apps), "win+r" (Run dialog), "win" (Start menu), "ctrl+shift+esc" (Task Manager). Use for quick actions and system commands.',
226
210
  annotations=ToolAnnotations(
227
- title="Shortcut Tool",
211
+ title="Shortcut",
228
212
  readOnlyHint=False,
229
213
  destructiveHint=True,
230
214
  idempotentHint=False,
@@ -237,10 +221,10 @@ def shortcut_tool(shortcut:str, ctx: Context = None):
237
221
  return f"Pressed {shortcut}."
238
222
 
239
223
  @mcp.tool(
240
- name='Wait-Tool',
224
+ name='Wait',
241
225
  description='Pauses execution for specified duration in seconds. Use when waiting for: applications to launch/load, UI animations to complete, page content to render, dialogs to appear, or between rapid actions. Helps ensure UI is ready before next interaction.',
242
226
  annotations=ToolAnnotations(
243
- title="Wait Tool",
227
+ title="Wait",
244
228
  readOnlyHint=True,
245
229
  destructiveHint=False,
246
230
  idempotentHint=True,
@@ -253,10 +237,10 @@ def wait_tool(duration:int, ctx: Context = None)->str:
253
237
  return f'Waited for {duration} seconds.'
254
238
 
255
239
  @mcp.tool(
256
- name='Scrape-Tool',
257
- description='Fetch content from a URL or the active browser tab. By default (use_dom=False), performs a lightweight HTTP request to the URL and returns markdown content of complete webpage. Note: Some websites may block automated HTTP requests. If this fails, open the page in a browser and retry with use_dom=True to extract visible text from the active tab\'s DOM within the viewport.',
240
+ name='Scrape',
241
+ description='Fetch content from a URL or the active browser tab. By default (use_dom=False), performs a lightweight HTTP request to the URL and returns markdown content of complete webpage. Note: Some websites may block automated HTTP requests. If this fails, open the page in a browser and retry with use_dom=True to extract visible text from the active tab\'s DOM within the viewport using the accessibility tree data.',
258
242
  annotations=ToolAnnotations(
259
- title="Scrape Tool",
243
+ title="Scrape",
260
244
  readOnlyHint=True,
261
245
  destructiveHint=False,
262
246
  idempotentHint=True,
@@ -271,10 +255,10 @@ def scrape_tool(url:str,use_dom:bool=False, ctx: Context = None)->str:
271
255
 
272
256
  desktop_state=desktop.get_state(use_vision=False,use_dom=use_dom)
273
257
  tree_state=desktop_state.tree_state
274
- if not tree_state.dom_info:
258
+ if not tree_state.dom_node:
275
259
  return f'No DOM information found. Please open {url} in browser first.'
276
- dom_info=tree_state.dom_info
277
- vertical_scroll_percent=dom_info.vertical_scroll_percent
260
+ dom_node=tree_state.dom_node
261
+ vertical_scroll_percent=dom_node.vertical_scroll_percent
278
262
  content='\n'.join([node.text for node in tree_state.dom_informative_nodes])
279
263
  header_status = "Reached top" if vertical_scroll_percent <= 0 else "Scroll up to see more"
280
264
  footer_status = "Reached bottom" if vertical_scroll_percent >= 100 else "Scroll down to see more"
@@ -303,10 +287,13 @@ def scrape_tool(url:str,use_dom:bool=False, ctx: Context = None)->str:
303
287
  show_default=True
304
288
  )
305
289
  def main(transport, host, port):
306
- if transport=='stdio':
307
- mcp.run()
308
- else:
309
- mcp.run(transport=transport,host=host,port=port)
290
+ match transport:
291
+ case 'stdio':
292
+ mcp.run(transport=transport,show_banner=False)
293
+ case 'sse'|'streamable-http':
294
+ mcp.run(transport=transport,host=host,port=port,show_banner=False)
295
+ case _:
296
+ raise ValueError(f"Invalid transport: {transport}")
310
297
 
311
298
  if __name__ == "__main__":
312
299
  main()
windows_mcp/analytics.py CHANGED
@@ -1,171 +1,170 @@
1
- from typing import Optional, Dict, Any, TypeVar, Callable, Protocol, Awaitable
2
- from tempfile import TemporaryDirectory
3
- from uuid_extensions import uuid7str
4
- from fastmcp import Context
5
- from functools import wraps
6
- from pathlib import Path
7
- import posthog
8
- import asyncio
9
- import logging
10
- import time
11
- import os
12
-
13
- logging.basicConfig(level=logging.DEBUG)
14
- logger = logging.getLogger(__name__)
15
-
16
- T = TypeVar("T")
17
-
18
- class Analytics(Protocol):
19
- async def track_tool(self, tool_name: str, result: Dict[str, Any]) -> None:
20
- """Tracks the execution of a tool."""
21
- ...
22
-
23
- async def track_error(self, error: Exception, context: Dict[str, Any]) -> None:
24
- """Tracks an error that occurred during the execution of a tool."""
25
- ...
26
-
27
- async def is_feature_enabled(self, feature: str) -> bool:
28
- """Checks if a feature flag is enabled."""
29
- ...
30
-
31
- async def close(self) -> None:
32
- """Closes the analytics client."""
33
- ...
34
-
35
- class PostHogAnalytics:
36
- TEMP_FOLDER = Path(TemporaryDirectory().name).parent
37
- API_KEY = 'phc_uxdCItyVTjXNU0sMPr97dq3tcz39scQNt3qjTYw5vLV'
38
- HOST = 'https://us.i.posthog.com'
39
-
40
- def __init__(self):
41
- self.client = posthog.Posthog(
42
- self.API_KEY,
43
- host=self.HOST,
44
- disable_geoip=False,
45
- enable_exception_autocapture=True,
46
- debug=True
47
- )
48
- self._user_id = None
49
- self.mcp_interaction_id = f"mcp_{int(time.time()*1000)}_{os.getpid()}"
50
-
51
- if self.client:
52
- logger.debug(f"Initialized with user ID: {self.user_id} and session ID: {self.mcp_interaction_id}")
53
-
54
- @property
55
- def user_id(self) -> str:
56
- if self._user_id:
57
- return self._user_id
58
-
59
- user_id_file = self.TEMP_FOLDER / '.windows-mcp-user-id'
60
- if user_id_file.exists():
61
- self._user_id = user_id_file.read_text(encoding='utf-8').strip()
62
- else:
63
- self._user_id = uuid7str()
64
- try:
65
- user_id_file.write_text(self._user_id, encoding='utf-8')
66
- except Exception as e:
67
- logger.warning(f"Could not persist user ID: {e}")
68
-
69
- return self._user_id
70
-
71
- async def track_tool(self, tool_name: str, result: Dict[str, Any]) -> None:
72
- if self.client:
73
- self.client.capture(
74
- distinct_id=self.user_id,
75
- event="tool_executed",
76
- properties={
77
- "tool_name": tool_name,
78
- "session_id": self.mcp_interaction_id,
79
- "process_person_profile": True,
80
- **result
81
- }
82
- )
83
-
84
- duration = result.get("duration_ms", 0)
85
- success_mark = "SUCCESS" if result.get("success") else "FAILED"
86
- # Using print for immediate visibility in console during debugging
87
- print(f"[Analytics] {tool_name}: {success_mark} ({duration}ms)")
88
- logger.info(f"{tool_name}: {success_mark} ({duration}ms)")
89
- if self.client:
90
- self.client.flush()
91
-
92
- async def track_error(self, error: Exception, context: Dict[str, Any]) -> None:
93
- if self.client:
94
- self.client.capture(
95
- distinct_id=self.user_id,
96
- event="exception",
97
- properties={
98
- "exception": str(error),
99
- "traceback": str(error) if not hasattr(error, '__traceback__') else str(error),
100
- "session_id": self.mcp_interaction_id,
101
- "process_person_profile": True,
102
- **context
103
- }
104
- )
105
-
106
- if self.client:
107
- self.client.flush()
108
-
109
- logger.error(f"ERROR in {context.get('tool_name')}: {error}")
110
-
111
- async def is_feature_enabled(self, feature: str) -> bool:
112
- if not self.client:
113
- return False
114
- return self.client.is_feature_enabled(feature, self.user_id)
115
-
116
- async def close(self) -> None:
117
- if self.client:
118
- self.client.shutdown()
119
- logger.debug("Closed analytics")
120
-
121
- def with_analytics(analytics_instance: Optional[Analytics], tool_name: str):
122
- """
123
- Decorator to wrap tool functions with analytics tracking.
124
- """
125
- def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
126
- @wraps(func)
127
- async def wrapper(*args, **kwargs) -> T:
128
- start = time.time()
129
-
130
- # Capture client info from Context passed as argument
131
- client_data = {}
132
- try:
133
- ctx = next((arg for arg in args if isinstance(arg, Context)), None)
134
- if not ctx:
135
- ctx = next((val for val in kwargs.values() if isinstance(val, Context)), None)
136
-
137
- if ctx and ctx.session and ctx.session.client_params and ctx.session.client_params.clientInfo:
138
- info = ctx.session.client_params.clientInfo
139
- client_data["client_name"] = info.name
140
- client_data["client_version"] = info.version
141
- except Exception:
142
- pass
143
-
144
- try:
145
- if asyncio.iscoroutinefunction(func):
146
- result = await func(*args, **kwargs)
147
- else:
148
- # Run sync function in thread to avoid blocking loop
149
- result = await asyncio.to_thread(func, *args, **kwargs)
150
-
151
- duration_ms = int((time.time() - start) * 1000)
152
-
153
- if analytics_instance:
154
- await analytics_instance.track_tool(tool_name, {
155
- "duration_ms": duration_ms,
156
- "success": True,
157
- **client_data
158
- })
159
-
160
- return result
161
- except Exception as error:
162
- duration_ms = int((time.time() - start) * 1000)
163
- if analytics_instance:
164
- await analytics_instance.track_error(error, {
165
- "tool_name": tool_name,
166
- "duration_ms": duration_ms,
167
- **client_data
168
- })
169
- raise error
170
- return wrapper
171
- return decorator
1
+ from typing import Optional, Dict, Any, TypeVar, Callable, Protocol, Awaitable
2
+ from tempfile import TemporaryDirectory
3
+ from uuid_extensions import uuid7str
4
+ from fastmcp import Context
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ import posthog
8
+ import asyncio
9
+ import logging
10
+ import time
11
+ import os
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logger.setLevel(logging.INFO)
15
+ handler = logging.StreamHandler()
16
+ formatter = logging.Formatter('[%(levelname)s] %(message)s')
17
+ handler.setFormatter(formatter)
18
+ logger.addHandler(handler)
19
+
20
+ T = TypeVar("T")
21
+
22
+ class Analytics(Protocol):
23
+ async def track_tool(self, tool_name: str, result: Dict[str, Any]) -> None:
24
+ """Tracks the execution of a tool."""
25
+ ...
26
+
27
+ async def track_error(self, error: Exception, context: Dict[str, Any]) -> None:
28
+ """Tracks an error that occurred during the execution of a tool."""
29
+ ...
30
+
31
+ async def is_feature_enabled(self, feature: str) -> bool:
32
+ """Checks if a feature flag is enabled."""
33
+ ...
34
+
35
+ async def close(self) -> None:
36
+ """Closes the analytics client."""
37
+ ...
38
+
39
+ class PostHogAnalytics:
40
+ TEMP_FOLDER = Path(TemporaryDirectory().name).parent
41
+ API_KEY = 'phc_uxdCItyVTjXNU0sMPr97dq3tcz39scQNt3qjTYw5vLV'
42
+ HOST = 'https://us.i.posthog.com'
43
+
44
+ def __init__(self):
45
+ self.client = posthog.Posthog(
46
+ self.API_KEY,
47
+ host=self.HOST,
48
+ disable_geoip=False,
49
+ enable_exception_autocapture=True,
50
+ debug=False
51
+ )
52
+ self._user_id = None
53
+ self.mcp_interaction_id = f"mcp_{int(time.time()*1000)}_{os.getpid()}"
54
+
55
+ if self.client:
56
+ logger.debug(f"Initialized with user ID: {self.user_id} and session ID: {self.mcp_interaction_id}")
57
+
58
+ @property
59
+ def user_id(self) -> str:
60
+ if self._user_id:
61
+ return self._user_id
62
+
63
+ user_id_file = self.TEMP_FOLDER / '.windows-mcp-user-id'
64
+ if user_id_file.exists():
65
+ self._user_id = user_id_file.read_text(encoding='utf-8').strip()
66
+ else:
67
+ self._user_id = uuid7str()
68
+ try:
69
+ user_id_file.write_text(self._user_id, encoding='utf-8')
70
+ except Exception as e:
71
+ logger.warning(f"Could not persist user ID: {e}")
72
+
73
+ return self._user_id
74
+
75
+ async def track_tool(self, tool_name: str, result: Dict[str, Any]) -> None:
76
+ if self.client:
77
+ self.client.capture(
78
+ distinct_id=self.user_id,
79
+ event="tool_executed",
80
+ properties={
81
+ "tool_name": tool_name,
82
+ "session_id": self.mcp_interaction_id,
83
+ "process_person_profile": True,
84
+ **result
85
+ }
86
+ )
87
+
88
+ duration = result.get("duration_ms", 0)
89
+ success_mark = "SUCCESS" if result.get("success") else "FAILED"
90
+ # Using print for immediate visibility in console during debugging
91
+ print(f"[Analytics] {tool_name}: {success_mark} ({duration}ms)")
92
+ logger.info(f"{tool_name}: {success_mark} ({duration}ms)")
93
+
94
+ async def track_error(self, error: Exception, context: Dict[str, Any]) -> None:
95
+ if self.client:
96
+ self.client.capture(
97
+ distinct_id=self.user_id,
98
+ event="exception",
99
+ properties={
100
+ "exception": str(error),
101
+ "traceback": str(error) if not hasattr(error, '__traceback__') else str(error),
102
+ "session_id": self.mcp_interaction_id,
103
+ "process_person_profile": True,
104
+ **context
105
+ }
106
+ )
107
+
108
+ logger.error(f"ERROR in {context.get('tool_name')}: {error}")
109
+
110
+ async def is_feature_enabled(self, feature: str) -> bool:
111
+ if not self.client:
112
+ return False
113
+ return self.client.is_feature_enabled(feature, self.user_id)
114
+
115
+ async def close(self) -> None:
116
+ if self.client:
117
+ self.client.shutdown()
118
+ logger.debug("Closed analytics")
119
+
120
+ def with_analytics(analytics_instance: Optional[Analytics], tool_name: str):
121
+ """
122
+ Decorator to wrap tool functions with analytics tracking.
123
+ """
124
+ def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
125
+ @wraps(func)
126
+ async def wrapper(*args, **kwargs) -> T:
127
+ start = time.time()
128
+
129
+ # Capture client info from Context passed as argument
130
+ client_data = {}
131
+ try:
132
+ ctx = next((arg for arg in args if isinstance(arg, Context)), None)
133
+ if not ctx:
134
+ ctx = next((val for val in kwargs.values() if isinstance(val, Context)), None)
135
+
136
+ if ctx and ctx.session and ctx.session.client_params and ctx.session.client_params.clientInfo:
137
+ info = ctx.session.client_params.clientInfo
138
+ client_data["client_name"] = info.name
139
+ client_data["client_version"] = info.version
140
+ except Exception:
141
+ pass
142
+
143
+ try:
144
+ if asyncio.iscoroutinefunction(func):
145
+ result = await func(*args, **kwargs)
146
+ else:
147
+ # Run sync function in thread to avoid blocking loop
148
+ result = await asyncio.to_thread(func, *args, **kwargs)
149
+
150
+ duration_ms = int((time.time() - start) * 1000)
151
+
152
+ if analytics_instance:
153
+ await analytics_instance.track_tool(tool_name, {
154
+ "duration_ms": duration_ms,
155
+ "success": True,
156
+ **client_data
157
+ })
158
+
159
+ return result
160
+ except Exception as error:
161
+ duration_ms = int((time.time() - start) * 1000)
162
+ if analytics_instance:
163
+ await analytics_instance.track_error(error, {
164
+ "tool_name": tool_name,
165
+ "duration_ms": duration_ms,
166
+ **client_data
167
+ })
168
+ raise error
169
+ return wrapper
170
+ return decorator