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 +69 -82
- windows_mcp/analytics.py +170 -171
- windows_mcp/desktop/config.py +20 -20
- windows_mcp/desktop/service.py +279 -99
- windows_mcp/desktop/views.py +59 -57
- windows_mcp/tree/cache_utils.py +126 -0
- windows_mcp/tree/config.py +75 -50
- windows_mcp/tree/service.py +424 -348
- windows_mcp/tree/utils.py +21 -21
- windows_mcp/tree/views.py +51 -25
- windows_mcp/uia/__init__.py +4 -0
- windows_mcp/uia/controls.py +4790 -0
- windows_mcp/uia/core.py +3278 -0
- windows_mcp/uia/enums.py +1963 -0
- windows_mcp/uia/events.py +83 -0
- windows_mcp/uia/patterns.py +2106 -0
- windows_mcp/vdm/__init__.py +1 -0
- windows_mcp/vdm/core.py +490 -0
- windows_mcp/watchdog/__init__.py +1 -0
- windows_mcp/watchdog/event_handlers.py +55 -0
- windows_mcp/watchdog/service.py +199 -0
- {windows_mcp-0.5.7.dist-info → windows_mcp-0.5.9.dist-info}/METADATA +30 -24
- windows_mcp-0.5.9.dist-info/RECORD +29 -0
- windows_mcp-0.5.7.dist-info/RECORD +0 -17
- {windows_mcp-0.5.7.dist-info → windows_mcp-0.5.9.dist-info}/WHEEL +0 -0
- {windows_mcp-0.5.7.dist-info → windows_mcp-0.5.9.dist-info}/entry_points.txt +0 -0
- {windows_mcp-0.5.7.dist-info → windows_mcp-0.5.9.dist-info}/licenses/LICENSE.md +0 -0
windows_mcp/__main__.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
from windows_mcp.analytics import PostHogAnalytics, with_analytics
|
|
2
|
-
from
|
|
3
|
-
from windows_mcp.
|
|
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=
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
46
|
+
watchdog.start()
|
|
45
47
|
await asyncio.sleep(1) # Simulate startup latency
|
|
46
48
|
yield
|
|
47
49
|
finally:
|
|
48
|
-
|
|
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
|
|
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
|
|
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='
|
|
71
|
-
description='
|
|
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="
|
|
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='
|
|
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="
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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='
|
|
187
|
-
description='
|
|
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
|
|
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(
|
|
217
|
-
if len(
|
|
218
|
-
raise ValueError("
|
|
219
|
-
x,y=
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
258
|
+
if not tree_state.dom_node:
|
|
275
259
|
return f'No DOM information found. Please open {url} in browser first.'
|
|
276
|
-
|
|
277
|
-
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
14
|
-
logger
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
async def
|
|
24
|
-
"""Tracks
|
|
25
|
-
...
|
|
26
|
-
|
|
27
|
-
async def
|
|
28
|
-
"""
|
|
29
|
-
...
|
|
30
|
-
|
|
31
|
-
async def
|
|
32
|
-
"""
|
|
33
|
-
...
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if
|
|
61
|
-
self._user_id
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
client_data["
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|