mcpforunityserver 8.2.3__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.
Files changed (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. utils/reload_sentinel.py +9 -0
__init__.py ADDED
File without changes
core/__init__.py ADDED
File without changes
core/config.py ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ Configuration settings for the MCP for Unity Server.
3
+ This file contains all configurable parameters for the server.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class ServerConfig:
11
+ """Main configuration class for the MCP server."""
12
+
13
+ # Network settings
14
+ unity_host: str = "localhost"
15
+ unity_port: int = 6400
16
+ mcp_port: int = 6500
17
+
18
+ # Connection settings
19
+ connection_timeout: float = 30.0
20
+ buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
21
+
22
+ # STDIO framing behaviour
23
+ require_framing: bool = True
24
+ handshake_timeout: float = 1.0
25
+ framed_receive_timeout: float = 2.0
26
+ max_heartbeat_frames: int = 16
27
+ heartbeat_timeout: float = 2.0
28
+
29
+ # Logging settings
30
+ log_level: str = "INFO"
31
+ log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
32
+
33
+ # Server settings
34
+ max_retries: int = 5
35
+ retry_delay: float = 0.25
36
+ # Backoff hint returned to clients when Unity is reloading (milliseconds)
37
+ reload_retry_ms: int = 250
38
+ # Number of polite retries when Unity reports reloading
39
+ # 40 × 250ms ≈ 10s default window
40
+ reload_max_retries: int = 40
41
+
42
+ # Port discovery cache
43
+ port_registry_ttl: float = 5.0
44
+
45
+ # Telemetry settings
46
+ telemetry_enabled: bool = True
47
+ # Align with telemetry.py default Cloud Run endpoint
48
+ telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events"
49
+
50
+ def configure_logging(self) -> None:
51
+ level = getattr(logging, self.log_level, logging.INFO)
52
+ logging.basicConfig(level=level, format=self.log_format)
53
+
54
+
55
+ # Create a global config instance
56
+ config = ServerConfig()
@@ -0,0 +1,37 @@
1
+ import functools
2
+ import inspect
3
+ import logging
4
+ from typing import Callable, Any
5
+
6
+ logger = logging.getLogger("mcp-for-unity-server")
7
+
8
+
9
+ def log_execution(name: str, type_label: str):
10
+ """Decorator to log input arguments and return value of a function."""
11
+ def decorator(func: Callable) -> Callable:
12
+ @functools.wraps(func)
13
+ def _sync_wrapper(*args, **kwargs) -> Any:
14
+ logger.info(
15
+ f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
16
+ try:
17
+ result = func(*args, **kwargs)
18
+ logger.info(f"{type_label} '{name}' returned: {result}")
19
+ return result
20
+ except Exception as e:
21
+ logger.info(f"{type_label} '{name}' failed: {e}")
22
+ raise
23
+
24
+ @functools.wraps(func)
25
+ async def _async_wrapper(*args, **kwargs) -> Any:
26
+ logger.info(
27
+ f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
28
+ try:
29
+ result = await func(*args, **kwargs)
30
+ logger.info(f"{type_label} '{name}' returned: {result}")
31
+ return result
32
+ except Exception as e:
33
+ logger.info(f"{type_label} '{name}' failed: {e}")
34
+ raise
35
+
36
+ return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
37
+ return decorator
core/telemetry.py ADDED
@@ -0,0 +1,533 @@
1
+ """
2
+ Privacy-focused, anonymous telemetry system for MCP for Unity
3
+ Inspired by Onyx's telemetry implementation with Unity-specific adaptations
4
+
5
+ Fire-and-forget telemetry sender with a single background worker.
6
+ - No context/thread-local propagation to avoid re-entrancy into tool resolution.
7
+ - Small network timeouts to prevent stalls.
8
+ """
9
+
10
+ import contextlib
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from importlib import import_module, metadata
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ import platform
19
+ import queue
20
+ import sys
21
+ import threading
22
+ import time
23
+ from typing import Any
24
+ from urllib.parse import urlparse
25
+ import uuid
26
+
27
+ import tomli
28
+
29
+ try:
30
+ import httpx
31
+ HAS_HTTPX = True
32
+ except ImportError:
33
+ httpx = None # type: ignore
34
+ HAS_HTTPX = False
35
+
36
+ logger = logging.getLogger("unity-mcp-telemetry")
37
+ PACKAGE_NAME = "mcpforunityserver"
38
+
39
+
40
+ def _version_from_local_pyproject() -> str:
41
+ """Locate the nearest pyproject.toml that matches our package name."""
42
+ current = Path(__file__).resolve()
43
+ for parent in current.parents:
44
+ candidate = parent / "pyproject.toml"
45
+ if not candidate.exists():
46
+ continue
47
+ try:
48
+ with candidate.open("rb") as f:
49
+ data = tomli.load(f)
50
+ except (OSError, tomli.TOMLDecodeError):
51
+ continue
52
+
53
+ project_table = data.get("project") or {}
54
+ poetry_table = data.get("tool", {}).get("poetry", {})
55
+
56
+ project_name = project_table.get("name") or poetry_table.get("name")
57
+ if project_name and project_name.lower() != PACKAGE_NAME.lower():
58
+ continue
59
+
60
+ version = project_table.get("version") or poetry_table.get("version")
61
+ if version:
62
+ return version
63
+ raise FileNotFoundError("pyproject.toml not found for mcpforunityserver")
64
+
65
+
66
+ def get_package_version() -> str:
67
+ """
68
+ Get package version in different ways:
69
+ 1. First we try the installed metadata - this is because uvx is used on the asset store
70
+ 2. If that fails, we try to read from pyproject.toml - this is available for users who download via Git
71
+ Default is "unknown", but that should never happen
72
+ """
73
+ try:
74
+ return metadata.version(PACKAGE_NAME)
75
+ except Exception:
76
+ # Fallback for development: read from pyproject.toml
77
+ try:
78
+ return _version_from_local_pyproject()
79
+ except Exception:
80
+ return "unknown"
81
+
82
+
83
+ MCP_VERSION = get_package_version()
84
+
85
+
86
+ class RecordType(str, Enum):
87
+ """Types of telemetry records we collect"""
88
+ VERSION = "version"
89
+ STARTUP = "startup"
90
+ USAGE = "usage"
91
+ LATENCY = "latency"
92
+ FAILURE = "failure"
93
+ RESOURCE_RETRIEVAL = "resource_retrieval"
94
+ TOOL_EXECUTION = "tool_execution"
95
+ UNITY_CONNECTION = "unity_connection"
96
+ CLIENT_CONNECTION = "client_connection"
97
+
98
+
99
+ class MilestoneType(str, Enum):
100
+ """Major user journey milestones"""
101
+ FIRST_STARTUP = "first_startup"
102
+ FIRST_TOOL_USAGE = "first_tool_usage"
103
+ FIRST_SCRIPT_CREATION = "first_script_creation"
104
+ FIRST_SCENE_MODIFICATION = "first_scene_modification"
105
+ MULTIPLE_SESSIONS = "multiple_sessions"
106
+ DAILY_ACTIVE_USER = "daily_active_user"
107
+ WEEKLY_ACTIVE_USER = "weekly_active_user"
108
+
109
+
110
+ @dataclass
111
+ class TelemetryRecord:
112
+ """Structure for telemetry data"""
113
+ record_type: RecordType
114
+ timestamp: float
115
+ customer_uuid: str
116
+ session_id: str
117
+ data: dict[str, Any]
118
+ milestone: MilestoneType | None = None
119
+
120
+
121
+ class TelemetryConfig:
122
+ """Telemetry configuration"""
123
+
124
+ def __init__(self):
125
+ """
126
+ Prefer config file, then allow env overrides
127
+ """
128
+ server_config = None
129
+ for modname in (
130
+ # Prefer plain module to respect test-time overrides and sys.path injection
131
+ "src.core.config",
132
+ "config",
133
+ "src.config",
134
+ "Server.config",
135
+ ):
136
+ try:
137
+ mod = import_module(modname)
138
+ server_config = getattr(mod, "config", None)
139
+ if server_config is not None:
140
+ break
141
+ except Exception:
142
+ continue
143
+
144
+ # Determine enabled flag: config -> env DISABLE_* opt-out
145
+ cfg_enabled = True if server_config is None else bool(
146
+ getattr(server_config, "telemetry_enabled", True))
147
+ self.enabled = cfg_enabled and not self._is_disabled()
148
+
149
+ # Telemetry endpoint (Cloud Run default; override via env)
150
+ cfg_default = None if server_config is None else getattr(
151
+ server_config, "telemetry_endpoint", None)
152
+ default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
153
+ self.default_endpoint = default_ep
154
+ # Prefer config default; allow explicit env override only when set
155
+ env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT")
156
+ if env_ep is not None and env_ep != "":
157
+ self.endpoint = self._validated_endpoint(env_ep, default_ep)
158
+ else:
159
+ # Validate config-provided default as well to enforce scheme/host rules
160
+ self.endpoint = self._validated_endpoint(default_ep, default_ep)
161
+ try:
162
+ logger.info(
163
+ f"Telemetry configured: endpoint={self.endpoint} (default={default_ep}), timeout_env={os.environ.get('UNITY_MCP_TELEMETRY_TIMEOUT') or '<unset>'}")
164
+ except Exception:
165
+ pass
166
+
167
+ # Local storage for UUID and milestones
168
+ self.data_dir = self._get_data_directory()
169
+ self.uuid_file = self.data_dir / "customer_uuid.txt"
170
+ self.milestones_file = self.data_dir / "milestones.json"
171
+
172
+ # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
173
+ try:
174
+ self.timeout = float(os.environ.get(
175
+ "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
176
+ except Exception:
177
+ self.timeout = 1.5
178
+ try:
179
+ logger.info(f"Telemetry timeout={self.timeout:.2f}s")
180
+ except Exception:
181
+ pass
182
+
183
+ # Session tracking
184
+ self.session_id = str(uuid.uuid4())
185
+
186
+ def _is_disabled(self) -> bool:
187
+ """Check if telemetry is disabled via environment variables"""
188
+ disable_vars = [
189
+ "DISABLE_TELEMETRY",
190
+ "UNITY_MCP_DISABLE_TELEMETRY",
191
+ "MCP_DISABLE_TELEMETRY"
192
+ ]
193
+
194
+ for var in disable_vars:
195
+ if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
196
+ return True
197
+ return False
198
+
199
+ def _get_data_directory(self) -> Path:
200
+ """Get directory for storing telemetry data"""
201
+ if os.name == 'nt': # Windows
202
+ base_dir = Path(os.environ.get(
203
+ 'APPDATA', Path.home() / 'AppData' / 'Roaming'))
204
+ elif os.name == 'posix': # macOS/Linux
205
+ if 'darwin' in os.uname().sysname.lower(): # macOS
206
+ base_dir = Path.home() / 'Library' / 'Application Support'
207
+ else: # Linux
208
+ base_dir = Path(os.environ.get('XDG_DATA_HOME',
209
+ Path.home() / '.local' / 'share'))
210
+ else:
211
+ base_dir = Path.home() / '.unity-mcp'
212
+
213
+ data_dir = base_dir / 'UnityMCP'
214
+ data_dir.mkdir(parents=True, exist_ok=True)
215
+ return data_dir
216
+
217
+ def _validated_endpoint(self, candidate: str, fallback: str) -> str:
218
+ """Validate telemetry endpoint URL scheme; allow only http/https.
219
+ Falls back to the provided default on error.
220
+ """
221
+ try:
222
+ parsed = urlparse(candidate)
223
+ if parsed.scheme not in ("https", "http"):
224
+ raise ValueError(f"Unsupported scheme: {parsed.scheme}")
225
+ # Basic sanity: require network location and path
226
+ if not parsed.netloc:
227
+ raise ValueError("Missing netloc in endpoint")
228
+ # Reject localhost/loopback endpoints in production to avoid accidental local overrides
229
+ host = parsed.hostname or ""
230
+ if host in ("localhost", "127.0.0.1", "::1"):
231
+ raise ValueError(
232
+ "Localhost endpoints are not allowed for telemetry")
233
+ return candidate
234
+ except Exception as e:
235
+ logger.debug(
236
+ f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}",
237
+ exc_info=True,
238
+ )
239
+ return fallback
240
+
241
+
242
+ class TelemetryCollector:
243
+ """Main telemetry collection class"""
244
+
245
+ def __init__(self):
246
+ self.config = TelemetryConfig()
247
+ self._customer_uuid: str | None = None
248
+ self._milestones: dict[str, dict[str, Any]] = {}
249
+ self._lock: threading.Lock = threading.Lock()
250
+ # Bounded queue with single background worker (records only; no context propagation)
251
+ self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
252
+ # Load persistent data before starting worker so first events have UUID
253
+ self._load_persistent_data()
254
+ self._worker: threading.Thread = threading.Thread(
255
+ target=self._worker_loop, daemon=True)
256
+ self._worker.start()
257
+
258
+ def _load_persistent_data(self):
259
+ """Load UUID and milestones from disk"""
260
+ # Load customer UUID
261
+ try:
262
+ if self.config.uuid_file.exists():
263
+ self._customer_uuid = self.config.uuid_file.read_text(
264
+ encoding="utf-8").strip() or str(uuid.uuid4())
265
+ else:
266
+ self._customer_uuid = str(uuid.uuid4())
267
+ try:
268
+ self.config.uuid_file.write_text(
269
+ self._customer_uuid, encoding="utf-8")
270
+ if os.name == "posix":
271
+ os.chmod(self.config.uuid_file, 0o600)
272
+ except OSError as e:
273
+ logger.debug(
274
+ f"Failed to persist customer UUID: {e}", exc_info=True)
275
+ except OSError as e:
276
+ logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
277
+ self._customer_uuid = str(uuid.uuid4())
278
+
279
+ # Load milestones (failure here must not affect UUID)
280
+ try:
281
+ if self.config.milestones_file.exists():
282
+ content = self.config.milestones_file.read_text(
283
+ encoding="utf-8")
284
+ self._milestones = json.loads(content) or {}
285
+ if not isinstance(self._milestones, dict):
286
+ self._milestones = {}
287
+ except (OSError, json.JSONDecodeError, ValueError) as e:
288
+ logger.debug(f"Failed to load milestones: {e}", exc_info=True)
289
+ self._milestones = {}
290
+
291
+ def _save_milestones(self):
292
+ """Save milestones to disk. Caller must hold self._lock."""
293
+ try:
294
+ self.config.milestones_file.write_text(
295
+ json.dumps(self._milestones, indent=2),
296
+ encoding="utf-8",
297
+ )
298
+ except OSError as e:
299
+ logger.warning(f"Failed to save milestones: {e}", exc_info=True)
300
+
301
+ def record_milestone(self, milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
302
+ """Record a milestone event, returns True if this is the first occurrence"""
303
+ if not self.config.enabled:
304
+ return False
305
+ milestone_key = milestone.value
306
+ with self._lock:
307
+ if milestone_key in self._milestones:
308
+ return False # Already recorded
309
+ milestone_data = {
310
+ "timestamp": time.time(),
311
+ "data": data or {},
312
+ }
313
+ self._milestones[milestone_key] = milestone_data
314
+ self._save_milestones()
315
+
316
+ # Also send as telemetry record
317
+ self.record(
318
+ record_type=RecordType.USAGE,
319
+ data={"milestone": milestone_key, **(data or {})},
320
+ milestone=milestone
321
+ )
322
+
323
+ return True
324
+
325
+ def record(self,
326
+ record_type: RecordType,
327
+ data: dict[str, Any],
328
+ milestone: MilestoneType | None = None):
329
+ """Record a telemetry event (async, non-blocking)"""
330
+ if not self.config.enabled:
331
+ return
332
+
333
+ # Allow fallback sender when httpx is unavailable (no early return)
334
+
335
+ record = TelemetryRecord(
336
+ record_type=record_type,
337
+ timestamp=time.time(),
338
+ customer_uuid=self._customer_uuid or "unknown",
339
+ session_id=self.config.session_id,
340
+ data=data,
341
+ milestone=milestone
342
+ )
343
+ # Enqueue for background worker (non-blocking). Drop on backpressure.
344
+ try:
345
+ self._queue.put_nowait(record)
346
+ except queue.Full:
347
+ logger.debug(
348
+ f"Telemetry queue full; dropping {record.record_type}")
349
+
350
+ def _worker_loop(self):
351
+ """Background worker that serializes telemetry sends."""
352
+ while True:
353
+ rec = self._queue.get()
354
+ try:
355
+ # Run sender directly; do not reuse caller context/thread-locals
356
+ self._send_telemetry(rec)
357
+ except Exception:
358
+ logger.debug("Telemetry worker send failed", exc_info=True)
359
+ finally:
360
+ with contextlib.suppress(Exception):
361
+ self._queue.task_done()
362
+
363
+ def _send_telemetry(self, record: TelemetryRecord):
364
+ """Send telemetry data to endpoint"""
365
+ try:
366
+ # System fingerprint (top-level remains concise; details stored in data JSON)
367
+ _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows'
368
+ _source = sys.platform # 'darwin' | 'linux' | 'win32'
369
+ _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})"
370
+ _python_version = platform.python_version()
371
+
372
+ # Enrich data JSON so BigQuery stores detailed fields without schema change
373
+ enriched_data = dict(record.data or {})
374
+ enriched_data.setdefault("platform_detail", _platform_detail)
375
+ enriched_data.setdefault("python_version", _python_version)
376
+
377
+ payload = {
378
+ "record": record.record_type.value,
379
+ "timestamp": record.timestamp,
380
+ "customer_uuid": record.customer_uuid,
381
+ "session_id": record.session_id,
382
+ "data": enriched_data,
383
+ "version": MCP_VERSION,
384
+ "platform": _platform,
385
+ "source": _source,
386
+ }
387
+
388
+ if record.milestone:
389
+ payload["milestone"] = record.milestone.value
390
+
391
+ # Prefer httpx when available; otherwise fall back to urllib
392
+ if httpx:
393
+ with httpx.Client(timeout=self.config.timeout) as client:
394
+ # Re-validate endpoint at send time to handle dynamic changes
395
+ endpoint = self.config._validated_endpoint(
396
+ self.config.endpoint, self.config.default_endpoint)
397
+ response = client.post(endpoint, json=payload)
398
+ if 200 <= response.status_code < 300:
399
+ logger.debug(f"Telemetry sent: {record.record_type}")
400
+ else:
401
+ logger.warning(
402
+ f"Telemetry failed: HTTP {response.status_code}")
403
+ else:
404
+ import urllib.request
405
+ import urllib.error
406
+ data_bytes = json.dumps(payload).encode("utf-8")
407
+ endpoint = self.config._validated_endpoint(
408
+ self.config.endpoint, self.config.default_endpoint)
409
+ req = urllib.request.Request(
410
+ endpoint,
411
+ data=data_bytes,
412
+ headers={"Content-Type": "application/json"},
413
+ method="POST",
414
+ )
415
+ try:
416
+ with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
417
+ if 200 <= resp.getcode() < 300:
418
+ logger.debug(
419
+ f"Telemetry sent (urllib): {record.record_type}")
420
+ else:
421
+ logger.warning(
422
+ f"Telemetry failed (urllib): HTTP {resp.getcode()}")
423
+ except urllib.error.URLError as ue:
424
+ logger.warning(f"Telemetry send failed (urllib): {ue}")
425
+
426
+ except Exception as e:
427
+ # Never let telemetry errors interfere with app functionality
428
+ logger.debug(f"Telemetry send failed: {e}")
429
+
430
+
431
+ # Global telemetry instance
432
+ _telemetry_collector: TelemetryCollector | None = None
433
+
434
+
435
+ def get_telemetry() -> TelemetryCollector:
436
+ """Get the global telemetry collector instance"""
437
+ global _telemetry_collector
438
+ if _telemetry_collector is None:
439
+ _telemetry_collector = TelemetryCollector()
440
+ return _telemetry_collector
441
+
442
+
443
+ def record_telemetry(record_type: RecordType,
444
+ data: dict[str, Any],
445
+ milestone: MilestoneType | None = None):
446
+ """Convenience function to record telemetry"""
447
+ get_telemetry().record(record_type, data, milestone)
448
+
449
+
450
+ def record_milestone(milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
451
+ """Convenience function to record a milestone"""
452
+ return get_telemetry().record_milestone(milestone, data)
453
+
454
+
455
+ def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: str | None = None, sub_action: str | None = None):
456
+ """Record tool usage telemetry
457
+
458
+ Args:
459
+ tool_name: Name of the tool invoked (e.g., 'manage_scene').
460
+ success: Whether the tool completed successfully.
461
+ duration_ms: Execution duration in milliseconds.
462
+ error: Optional error message (truncated if present).
463
+ sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy').
464
+ """
465
+ data = {
466
+ "tool_name": tool_name,
467
+ "success": success,
468
+ "duration_ms": round(duration_ms, 2)
469
+ }
470
+
471
+ if sub_action is not None:
472
+ try:
473
+ data["sub_action"] = str(sub_action)
474
+ except Exception:
475
+ # Ensure telemetry is never disruptive
476
+ data["sub_action"] = "unknown"
477
+
478
+ if error:
479
+ data["error"] = str(error)[:200] # Limit error message length
480
+
481
+ record_telemetry(RecordType.TOOL_EXECUTION, data)
482
+
483
+
484
+ def record_resource_usage(resource_name: str, success: bool, duration_ms: float, error: str | None = None):
485
+ """Record resource usage telemetry
486
+
487
+ Args:
488
+ resource_name: Name of the resource invoked (e.g., 'get_tests').
489
+ success: Whether the resource completed successfully.
490
+ duration_ms: Execution duration in milliseconds.
491
+ error: Optional error message (truncated if present).
492
+ """
493
+ data = {
494
+ "resource_name": resource_name,
495
+ "success": success,
496
+ "duration_ms": round(duration_ms, 2)
497
+ }
498
+
499
+ if error:
500
+ data["error"] = str(error)[:200] # Limit error message length
501
+
502
+ record_telemetry(RecordType.RESOURCE_RETRIEVAL, data)
503
+
504
+
505
+ def record_latency(operation: str, duration_ms: float, metadata: dict[str, Any] | None = None):
506
+ """Record latency telemetry"""
507
+ data = {
508
+ "operation": operation,
509
+ "duration_ms": round(duration_ms, 2)
510
+ }
511
+
512
+ if metadata:
513
+ data.update(metadata)
514
+
515
+ record_telemetry(RecordType.LATENCY, data)
516
+
517
+
518
+ def record_failure(component: str, error: str, metadata: dict[str, Any] | None = None):
519
+ """Record failure telemetry"""
520
+ data = {
521
+ "component": component,
522
+ "error": str(error)[:500] # Limit error message length
523
+ }
524
+
525
+ if metadata:
526
+ data.update(metadata)
527
+
528
+ record_telemetry(RecordType.FAILURE, data)
529
+
530
+
531
+ def is_telemetry_enabled() -> bool:
532
+ """Check if telemetry is enabled"""
533
+ return get_telemetry().config.enabled