commit
fe962114b9
12
README.md
12
README.md
|
|
@ -292,6 +292,18 @@ Help make MCP for Unity better!
|
|||
|
||||
---
|
||||
|
||||
## 📊 Telemetry & Privacy
|
||||
|
||||
Unity MCP includes **privacy-focused, anonymous telemetry** to help us improve the product. We collect usage analytics and performance data, but **never** your code, project names, or personal information.
|
||||
|
||||
- **🔒 Anonymous**: Random UUIDs only, no personal data
|
||||
- **🚫 Easy opt-out**: Set `DISABLE_TELEMETRY=true` environment variable
|
||||
- **📖 Transparent**: See [TELEMETRY.md](TELEMETRY.md) for full details
|
||||
|
||||
Your privacy matters to us. All telemetry is optional and designed to respect your workflow.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting ❓
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
# Unity MCP Telemetry
|
||||
|
||||
Unity MCP includes privacy-focused, anonymous telemetry to help us improve the product. This document explains what data is collected, how to opt out, and our privacy practices.
|
||||
|
||||
## 🔒 Privacy First
|
||||
|
||||
- **Anonymous**: We use randomly generated UUIDs - no personal information
|
||||
- **Non-blocking**: Telemetry never interferes with your Unity workflow
|
||||
- **Easy opt-out**: Simple environment variable or Unity Editor setting
|
||||
- **Transparent**: All collected data types are documented here
|
||||
|
||||
## 📊 What We Collect
|
||||
|
||||
### Usage Analytics
|
||||
- **Tool Usage**: Which MCP tools you use (manage_script, manage_scene, etc.)
|
||||
- **Performance**: Execution times and success/failure rates
|
||||
- **System Info**: Unity version, platform (Windows/Mac/Linux), MCP version
|
||||
- **Milestones**: First-time usage events (first script creation, first tool use, etc.)
|
||||
|
||||
### Technical Diagnostics
|
||||
- **Connection Events**: Bridge startup/connection success/failures
|
||||
- **Error Reports**: Anonymized error messages (truncated to 200 chars)
|
||||
- **Server Health**: Startup time, connection latency
|
||||
|
||||
### What We **DON'T** Collect
|
||||
- ❌ Your code or script contents
|
||||
- ❌ Project names, file names, or paths
|
||||
- ❌ Personal information or identifiers
|
||||
- ❌ Sensitive project data
|
||||
- ❌ IP addresses (beyond what's needed for HTTP requests)
|
||||
|
||||
## 🚫 How to Opt Out
|
||||
|
||||
### Method 1: Environment Variable (Recommended)
|
||||
Set any of these environment variables to `true`:
|
||||
|
||||
```bash
|
||||
# Disable all telemetry
|
||||
export DISABLE_TELEMETRY=true
|
||||
|
||||
# Unity MCP specific
|
||||
export UNITY_MCP_DISABLE_TELEMETRY=true
|
||||
|
||||
# MCP protocol wide
|
||||
export MCP_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
### Method 2: Unity Editor (Coming Soon)
|
||||
In Unity Editor: `Window > MCP for Unity > Settings > Disable Telemetry`
|
||||
|
||||
### Method 3: Manual Config
|
||||
Add to your MCP client config:
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Technical Implementation
|
||||
|
||||
### Architecture
|
||||
- **Python Server**: Core telemetry collection and transmission
|
||||
- **Unity Bridge**: Local event collection from Unity Editor
|
||||
- **Anonymous UUIDs**: Generated per-installation for aggregate analytics
|
||||
- **Thread-safe**: Non-blocking background transmission
|
||||
- **Fail-safe**: Errors never interrupt your workflow
|
||||
|
||||
### Data Storage
|
||||
Telemetry data is stored locally in:
|
||||
- **Windows**: `%APPDATA%\UnityMCP\`
|
||||
- **macOS**: `~/Library/Application Support/UnityMCP/`
|
||||
- **Linux**: `~/.local/share/UnityMCP/`
|
||||
|
||||
Files created:
|
||||
- `customer_uuid.txt`: Anonymous identifier
|
||||
- `milestones.json`: One-time events tracker
|
||||
|
||||
### Data Transmission
|
||||
- **Endpoint**: `https://api-prod.coplay.dev/telemetry/events`
|
||||
- **Method**: HTTPS POST with JSON payload
|
||||
- **Retry**: Background thread with graceful failure
|
||||
- **Timeout**: 10 second timeout, no retries on failure
|
||||
|
||||
## 📈 How We Use This Data
|
||||
|
||||
### Product Improvement
|
||||
- **Feature Usage**: Understand which tools are most/least used
|
||||
- **Performance**: Identify slow operations to optimize
|
||||
- **Reliability**: Track error rates and connection issues
|
||||
- **Compatibility**: Ensure Unity version compatibility
|
||||
|
||||
### Development Priorities
|
||||
- **Roadmap**: Focus development on most-used features
|
||||
- **Bug Fixes**: Prioritize fixes based on error frequency
|
||||
- **Platform Support**: Allocate resources based on platform usage
|
||||
- **Documentation**: Improve docs for commonly problematic areas
|
||||
|
||||
### What We Don't Do
|
||||
- ❌ Sell data to third parties
|
||||
- ❌ Use data for advertising/marketing
|
||||
- ❌ Track individual developers
|
||||
- ❌ Store sensitive project information
|
||||
|
||||
## 🛠️ For Developers
|
||||
|
||||
### Testing Telemetry
|
||||
```bash
|
||||
cd UnityMcpBridge/UnityMcpServer~/src
|
||||
python test_telemetry.py
|
||||
```
|
||||
|
||||
### Custom Telemetry Events
|
||||
```python
|
||||
from telemetry import record_telemetry, RecordType
|
||||
|
||||
record_telemetry(RecordType.USAGE, {
|
||||
"custom_event": "my_feature_used",
|
||||
"metadata": "optional_data"
|
||||
})
|
||||
```
|
||||
|
||||
### Telemetry Status Check
|
||||
```python
|
||||
from telemetry import is_telemetry_enabled
|
||||
|
||||
if is_telemetry_enabled():
|
||||
print("Telemetry is active")
|
||||
else:
|
||||
print("Telemetry is disabled")
|
||||
```
|
||||
|
||||
## 📋 Data Retention Policy
|
||||
|
||||
- **Aggregated Data**: Retained indefinitely for product insights
|
||||
- **Raw Events**: Automatically purged after 90 days
|
||||
- **Personal Data**: None collected, so none to purge
|
||||
- **Opt-out**: Immediate - no data sent after opting out
|
||||
|
||||
## 🤝 Contact & Transparency
|
||||
|
||||
- **Questions**: [Discord Community](https://discord.gg/y4p8KfzrN4)
|
||||
- **Issues**: [GitHub Issues](https://github.com/CoplayDev/unity-mcp/issues)
|
||||
- **Privacy Concerns**: Create a GitHub issue with "Privacy" label
|
||||
- **Source Code**: All telemetry code is open source in this repository
|
||||
|
||||
## 📊 Example Telemetry Event
|
||||
|
||||
Here's what a typical telemetry event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"record": "tool_execution",
|
||||
"timestamp": 1704067200,
|
||||
"customer_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"session_id": "abc123-def456-ghi789",
|
||||
"version": "3.0.2",
|
||||
"platform": "posix",
|
||||
"data": {
|
||||
"tool_name": "manage_script",
|
||||
"success": true,
|
||||
"duration_ms": 42.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice:
|
||||
- ✅ Anonymous UUID (randomly generated)
|
||||
- ✅ Tool performance metrics
|
||||
- ✅ Success/failure tracking
|
||||
- ❌ No code content
|
||||
- ❌ No project information
|
||||
- ❌ No personal data
|
||||
|
||||
---
|
||||
|
||||
*Unity MCP Telemetry is designed to respect your privacy while helping us build a better tool. Thank you for helping improve Unity MCP!*
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity Bridge telemetry helper for collecting usage analytics
|
||||
/// Following privacy-first approach with easy opt-out mechanisms
|
||||
/// </summary>
|
||||
public static class TelemetryHelper
|
||||
{
|
||||
private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled";
|
||||
private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID";
|
||||
private static Action<Dictionary<string, object>> s_sender;
|
||||
|
||||
/// <summary>
|
||||
/// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs)
|
||||
/// </summary>
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
// Check environment variables first
|
||||
var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY");
|
||||
if (!string.IsNullOrEmpty(envDisable) &&
|
||||
(envDisable.ToLower() == "true" || envDisable == "1"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY");
|
||||
if (!string.IsNullOrEmpty(unityMcpDisable) &&
|
||||
(unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Honor protocol-wide opt-out as well
|
||||
var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY");
|
||||
if (!string.IsNullOrEmpty(mcpDisable) &&
|
||||
(mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check EditorPrefs
|
||||
return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or generate customer UUID for anonymous tracking
|
||||
/// </summary>
|
||||
public static string GetCustomerUUID()
|
||||
{
|
||||
var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, "");
|
||||
if (string.IsNullOrEmpty(uuid))
|
||||
{
|
||||
uuid = System.Guid.NewGuid().ToString();
|
||||
UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid);
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable telemetry (stored in EditorPrefs)
|
||||
/// </summary>
|
||||
public static void DisableTelemetry()
|
||||
{
|
||||
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable telemetry (stored in EditorPrefs)
|
||||
/// </summary>
|
||||
public static void EnableTelemetry()
|
||||
{
|
||||
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send telemetry data to Python server for processing
|
||||
/// This is a lightweight bridge - the actual telemetry logic is in Python
|
||||
/// </summary>
|
||||
public static void RecordEvent(string eventType, Dictionary<string, object> data = null)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var telemetryData = new Dictionary<string, object>
|
||||
{
|
||||
["event_type"] = eventType,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
["customer_uuid"] = GetCustomerUUID(),
|
||||
["unity_version"] = Application.unityVersion,
|
||||
["platform"] = Application.platform.ToString(),
|
||||
["source"] = "unity_bridge"
|
||||
};
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
telemetryData["data"] = data;
|
||||
}
|
||||
|
||||
// Send to Python server via existing bridge communication
|
||||
// The Python server will handle actual telemetry transmission
|
||||
SendTelemetryToPythonServer(telemetryData);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Never let telemetry errors interfere with functionality
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows the bridge to register a concrete sender for telemetry payloads.
|
||||
/// </summary>
|
||||
public static void RegisterTelemetrySender(Action<Dictionary<string, object>> sender)
|
||||
{
|
||||
Interlocked.Exchange(ref s_sender, sender);
|
||||
}
|
||||
|
||||
public static void UnregisterTelemetrySender()
|
||||
{
|
||||
Interlocked.Exchange(ref s_sender, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bridge startup event
|
||||
/// </summary>
|
||||
public static void RecordBridgeStartup()
|
||||
{
|
||||
RecordEvent("bridge_startup", new Dictionary<string, object>
|
||||
{
|
||||
["bridge_version"] = "3.0.2",
|
||||
["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode()
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bridge connection event
|
||||
/// </summary>
|
||||
public static void RecordBridgeConnection(bool success, string error = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["success"] = success
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
data["error"] = error.Substring(0, Math.Min(200, error.Length));
|
||||
}
|
||||
|
||||
RecordEvent("bridge_connection", data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record tool execution from Unity side
|
||||
/// </summary>
|
||||
public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["tool_name"] = toolName,
|
||||
["success"] = success,
|
||||
["duration_ms"] = Math.Round(durationMs, 2)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
data["error"] = error.Substring(0, Math.Min(200, error.Length));
|
||||
}
|
||||
|
||||
RecordEvent("tool_execution_unity", data);
|
||||
}
|
||||
|
||||
private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData)
|
||||
{
|
||||
var sender = Volatile.Read(ref s_sender);
|
||||
if (sender != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
sender(telemetryData);
|
||||
return;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: log when debug is enabled
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDebugEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
|
|
@ -25,6 +26,14 @@ namespace MCPForUnity.Editor
|
|||
private static readonly object startStopLock = new();
|
||||
private static readonly object clientsLock = new();
|
||||
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
|
||||
// Single-writer outbox for framed responses
|
||||
private class Outbound
|
||||
{
|
||||
public byte[] Payload;
|
||||
public string Tag;
|
||||
public int? ReqId;
|
||||
}
|
||||
private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
|
||||
private static CancellationTokenSource cts;
|
||||
private static Task listenerTask;
|
||||
private static int processingCommands = 0;
|
||||
|
|
@ -38,11 +47,16 @@ namespace MCPForUnity.Editor
|
|||
string,
|
||||
(string commandJson, TaskCompletionSource<string> tcs)
|
||||
> commandQueue = new();
|
||||
private static int mainThreadId;
|
||||
private static int currentUnityPort = 6400; // Dynamic port, starts with default
|
||||
private static bool isAutoConnectMode = false;
|
||||
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
|
||||
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
|
||||
|
||||
// IO diagnostics
|
||||
private static long _ioSeq = 0;
|
||||
private static void IoInfo(string s) { McpLog.Info(s, always: false); }
|
||||
|
||||
// Debug helpers
|
||||
private static bool IsDebugEnabled()
|
||||
{
|
||||
|
|
@ -74,10 +88,16 @@ namespace MCPForUnity.Editor
|
|||
currentUnityPort = PortManager.GetPortWithFallback();
|
||||
Start();
|
||||
isAutoConnectMode = true;
|
||||
|
||||
// Record telemetry for bridge startup
|
||||
TelemetryHelper.RecordBridgeStartup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Auto-connect failed: {ex.Message}");
|
||||
|
||||
// Record telemetry for connection failure
|
||||
TelemetryHelper.RecordBridgeConnection(false, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +123,37 @@ namespace MCPForUnity.Editor
|
|||
|
||||
static MCPForUnityBridge()
|
||||
{
|
||||
// Record the main thread ID for safe thread checks
|
||||
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
|
||||
// Start single writer thread for framed responses
|
||||
try
|
||||
{
|
||||
var writerThread = new Thread(() =>
|
||||
{
|
||||
foreach (var item in _outbox.GetConsumingEnumerable())
|
||||
{
|
||||
try
|
||||
{
|
||||
long seq = Interlocked.Increment(ref _ioSeq);
|
||||
IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
// Note: We currently have a per-connection 'stream' in the client handler. For simplicity,
|
||||
// writes are performed inline there. This outbox provides single-writer semantics; if a shared
|
||||
// stream is introduced, redirect here accordingly.
|
||||
// No-op: actual write happens in client loop using WriteFrameAsync
|
||||
sw.Stop();
|
||||
IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}) { IsBackground = true, Name = "MCP-Writer" };
|
||||
writerThread.Start();
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
|
||||
// CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
|
||||
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
|
||||
|
|
@ -533,9 +584,69 @@ namespace MCPForUnity.Editor
|
|||
commandQueue[commandId] = (commandText, tcs);
|
||||
}
|
||||
|
||||
string response = await tcs.Task.ConfigureAwait(false);
|
||||
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
|
||||
await WriteFrameAsync(stream, responseBytes);
|
||||
// Wait for the handler to produce a response, but do not block indefinitely
|
||||
string response;
|
||||
try
|
||||
{
|
||||
using var respCts = new CancellationTokenSource(FrameIOTimeoutMs);
|
||||
var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false);
|
||||
if (completed == tcs.Task)
|
||||
{
|
||||
// Got a result from the handler
|
||||
respCts.Cancel();
|
||||
response = tcs.Task.Result;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Timeout: return a structured error so the client can recover
|
||||
var timeoutResponse = new
|
||||
{
|
||||
status = "error",
|
||||
error = $"Command processing timed out after {FrameIOTimeoutMs} ms",
|
||||
};
|
||||
response = JsonConvert.SerializeObject(timeoutResponse);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorResponse = new
|
||||
{
|
||||
status = "error",
|
||||
error = ex.Message,
|
||||
};
|
||||
response = JsonConvert.SerializeObject(errorResponse);
|
||||
}
|
||||
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
|
||||
}
|
||||
// Crash-proof and self-reporting writer logs (direct write to this client's stream)
|
||||
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
|
||||
byte[] responseBytes;
|
||||
try
|
||||
{
|
||||
responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
|
||||
IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
|
||||
var swDirect = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await WriteFrameAsync(stream, responseBytes);
|
||||
swDirect.Stop();
|
||||
IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -810,6 +921,66 @@ namespace MCPForUnity.Editor
|
|||
}
|
||||
}
|
||||
|
||||
// Invoke the given function on the Unity main thread and wait up to timeoutMs for the result.
|
||||
// Returns null on timeout or error; caller should provide a fallback error response.
|
||||
private static object InvokeOnMainThreadWithTimeout(Func<object> func, int timeoutMs)
|
||||
{
|
||||
if (func == null) return null;
|
||||
try
|
||||
{
|
||||
// If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor.
|
||||
if (mainThreadId == 0)
|
||||
{
|
||||
try { return func(); }
|
||||
catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); }
|
||||
}
|
||||
// If we are already on the main thread, execute directly to avoid deadlocks
|
||||
try
|
||||
{
|
||||
if (Thread.CurrentThread.ManagedThreadId == mainThreadId)
|
||||
{
|
||||
return func();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
object result = null;
|
||||
Exception captured = null;
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
result = func();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
captured = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { tcs.TrySetResult(true); } catch { }
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for completion with timeout (Editor thread will pump delayCall)
|
||||
bool completed = tcs.Task.Wait(timeoutMs);
|
||||
if (!completed)
|
||||
{
|
||||
return null; // timeout
|
||||
}
|
||||
if (captured != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if a string is valid JSON
|
||||
private static bool IsValidJson(string text)
|
||||
{
|
||||
|
|
@ -874,7 +1045,9 @@ namespace MCPForUnity.Editor
|
|||
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
|
||||
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
|
||||
"manage_script" => ManageScript.HandleCommand(paramsObject),
|
||||
"manage_scene" => ManageScene.HandleCommand(paramsObject),
|
||||
// Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag)
|
||||
"manage_scene" => HandleManageScene(paramsObject)
|
||||
?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"),
|
||||
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
|
||||
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
|
||||
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
|
||||
|
|
@ -912,6 +1085,23 @@ namespace MCPForUnity.Editor
|
|||
}
|
||||
}
|
||||
|
||||
private static object HandleManageScene(JObject paramsObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread");
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs);
|
||||
sw.Stop();
|
||||
if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms");
|
||||
return r ?? Response.Error("manage_scene returned null (timeout or error)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Response.Error($"manage_scene dispatch error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to get a summary of parameters for error reporting
|
||||
private static string GetParamsSummary(JObject @params)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -16,15 +16,46 @@ namespace MCPForUnity.Editor.Tools
|
|||
/// </summary>
|
||||
public static class ManageScene
|
||||
{
|
||||
private sealed class SceneCommand
|
||||
{
|
||||
public string action { get; set; } = string.Empty;
|
||||
public string name { get; set; } = string.Empty;
|
||||
public string path { get; set; } = string.Empty;
|
||||
public int? buildIndex { get; set; }
|
||||
}
|
||||
|
||||
private static SceneCommand ToSceneCommand(JObject p)
|
||||
{
|
||||
if (p == null) return new SceneCommand();
|
||||
int? BI(JToken t)
|
||||
{
|
||||
if (t == null || t.Type == JTokenType.Null) return null;
|
||||
var s = t.ToString().Trim();
|
||||
if (s.Length == 0) return null;
|
||||
if (int.TryParse(s, out var i)) return i;
|
||||
if (double.TryParse(s, out var d)) return (int)d;
|
||||
return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null;
|
||||
}
|
||||
return new SceneCommand
|
||||
{
|
||||
action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(),
|
||||
name = p["name"]?.ToString() ?? string.Empty,
|
||||
path = p["path"]?.ToString() ?? string.Empty,
|
||||
buildIndex = BI(p["buildIndex"] ?? p["build_index"])
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main handler for scene management actions.
|
||||
/// </summary>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
string action = @params["action"]?.ToString().ToLower();
|
||||
string name = @params["name"]?.ToString();
|
||||
string path = @params["path"]?.ToString(); // Relative to Assets/
|
||||
int? buildIndex = @params["buildIndex"]?.ToObject<int?>();
|
||||
try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { }
|
||||
var cmd = ToSceneCommand(@params);
|
||||
string action = cmd.action;
|
||||
string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name;
|
||||
string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/
|
||||
int? buildIndex = cmd.buildIndex;
|
||||
// bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension
|
||||
|
||||
// Ensure path is relative to Assets/, removing any leading "Assets/"
|
||||
|
|
@ -76,6 +107,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
|
||||
// Route action
|
||||
try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { }
|
||||
switch (action)
|
||||
{
|
||||
case "create":
|
||||
|
|
@ -98,9 +130,15 @@ namespace MCPForUnity.Editor.Tools
|
|||
// Save current scene, optionally to a new path
|
||||
return SaveScene(fullPath, relativePath);
|
||||
case "get_hierarchy":
|
||||
return GetSceneHierarchy();
|
||||
try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { }
|
||||
var gh = GetSceneHierarchy();
|
||||
try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { }
|
||||
return gh;
|
||||
case "get_active":
|
||||
return GetActiveSceneInfo();
|
||||
try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { }
|
||||
var ga = GetActiveSceneInfo();
|
||||
try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { }
|
||||
return ga;
|
||||
case "get_build_settings":
|
||||
return GetBuildSettingsScenes();
|
||||
// Add cases for modifying build settings, additive loading, unloading etc.
|
||||
|
|
@ -294,7 +332,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
try
|
||||
{
|
||||
try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
|
||||
Scene activeScene = EditorSceneManager.GetActiveScene();
|
||||
try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
|
||||
if (!activeScene.IsValid())
|
||||
{
|
||||
return Response.Error("No active scene found.");
|
||||
|
|
@ -314,6 +354,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { }
|
||||
return Response.Error($"Error getting active scene info: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
|
@ -348,7 +389,9 @@ namespace MCPForUnity.Editor.Tools
|
|||
{
|
||||
try
|
||||
{
|
||||
try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
|
||||
Scene activeScene = EditorSceneManager.GetActiveScene();
|
||||
try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
|
||||
if (!activeScene.IsValid() || !activeScene.isLoaded)
|
||||
{
|
||||
return Response.Error(
|
||||
|
|
@ -356,16 +399,21 @@ namespace MCPForUnity.Editor.Tools
|
|||
);
|
||||
}
|
||||
|
||||
try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { }
|
||||
GameObject[] rootObjects = activeScene.GetRootGameObjects();
|
||||
try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { }
|
||||
var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList();
|
||||
|
||||
return Response.Success(
|
||||
var resp = Response.Success(
|
||||
$"Retrieved hierarchy for scene '{activeScene.name}'.",
|
||||
hierarchy
|
||||
);
|
||||
try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { }
|
||||
return resp;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { }
|
||||
return Response.Error($"Error getting scene hierarchy: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class ServerConfig:
|
|||
mcp_port: int = 6500
|
||||
|
||||
# Connection settings
|
||||
connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts
|
||||
connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts
|
||||
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
|
||||
# Framed receive behavior
|
||||
framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only
|
||||
|
|
@ -33,6 +33,11 @@ class ServerConfig:
|
|||
# Number of polite retries when Unity reports reloading
|
||||
# 40 × 250ms ≈ 10s default window
|
||||
reload_max_retries: int = 40
|
||||
|
||||
# Telemetry settings
|
||||
telemetry_enabled: bool = True
|
||||
# Align with telemetry.py default Cloud Run endpoint
|
||||
telemetry_endpoint: str = "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events"
|
||||
|
||||
# Create a global config instance
|
||||
config = ServerConfig()
|
||||
|
|
@ -0,0 +1 @@
|
|||
3.3.0
|
||||
|
|
@ -1,19 +1,64 @@
|
|||
from mcp.server.fastmcp import FastMCP, Context, Image
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Dict, Any, List
|
||||
from config import config
|
||||
from tools import register_all_tools
|
||||
from unity_connection import get_unity_connection, UnityConnection
|
||||
import time
|
||||
|
||||
# Configure logging using settings from config
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.log_level),
|
||||
format=config.log_format
|
||||
format=config.log_format,
|
||||
stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio
|
||||
force=True # Ensure our handler replaces any prior stdout handlers
|
||||
)
|
||||
logger = logging.getLogger("mcp-for-unity-server")
|
||||
|
||||
# Also write logs to a rotating file so logs are available when launched via stdio
|
||||
try:
|
||||
import os as _os
|
||||
_log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs")
|
||||
_os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_path = _os.path.join(_log_dir, "unity_mcp_server.log")
|
||||
_fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
|
||||
_fh.setFormatter(logging.Formatter(config.log_format))
|
||||
_fh.setLevel(getattr(logging, config.log_level))
|
||||
logger.addHandler(_fh)
|
||||
# Also route telemetry logger to the same rotating file and normal level
|
||||
try:
|
||||
tlog = logging.getLogger("unity-mcp-telemetry")
|
||||
tlog.setLevel(getattr(logging, config.log_level))
|
||||
tlog.addHandler(_fh)
|
||||
except Exception:
|
||||
# Never let logging setup break startup
|
||||
pass
|
||||
except Exception:
|
||||
# Never let logging setup break startup
|
||||
pass
|
||||
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
|
||||
for noisy in ("httpx", "urllib3"):
|
||||
try:
|
||||
logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels
|
||||
# Ensure a slightly higher telemetry timeout unless explicitly overridden by env
|
||||
try:
|
||||
|
||||
|
||||
# Ensure generous timeout unless explicitly overridden by env
|
||||
if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"):
|
||||
os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0"
|
||||
except Exception:
|
||||
pass
|
||||
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
||||
|
||||
# Global connection state
|
||||
_unity_connection: UnityConnection = None
|
||||
|
||||
|
|
@ -22,12 +67,76 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
"""Handle server startup and shutdown."""
|
||||
global _unity_connection
|
||||
logger.info("MCP for Unity Server starting up")
|
||||
|
||||
# Record server startup telemetry
|
||||
start_time = time.time()
|
||||
start_clk = time.perf_counter()
|
||||
try:
|
||||
_unity_connection = get_unity_connection()
|
||||
logger.info("Connected to Unity on startup")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not connect to Unity on startup: {str(e)}")
|
||||
from pathlib import Path
|
||||
ver_path = Path(__file__).parent / "server-version.txt"
|
||||
server_version = ver_path.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
server_version = "unknown"
|
||||
# Defer initial telemetry by 1s to avoid stdio handshake interference
|
||||
import threading
|
||||
def _emit_startup():
|
||||
try:
|
||||
record_telemetry(RecordType.STARTUP, {
|
||||
"server_version": server_version,
|
||||
"startup_time": start_time,
|
||||
})
|
||||
record_milestone(MilestoneType.FIRST_STARTUP)
|
||||
except Exception:
|
||||
logger.debug("Deferred startup telemetry failed", exc_info=True)
|
||||
threading.Timer(1.0, _emit_startup).start()
|
||||
|
||||
try:
|
||||
skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on")
|
||||
if skip_connect:
|
||||
logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
|
||||
else:
|
||||
_unity_connection = get_unity_connection()
|
||||
logger.info("Connected to Unity on startup")
|
||||
|
||||
# Record successful Unity connection (deferred)
|
||||
import threading as _t
|
||||
_t.Timer(1.0, lambda: record_telemetry(
|
||||
RecordType.UNITY_CONNECTION,
|
||||
{
|
||||
"status": "connected",
|
||||
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
|
||||
}
|
||||
)).start()
|
||||
|
||||
except ConnectionError as e:
|
||||
logger.warning("Could not connect to Unity on startup: %s", e)
|
||||
_unity_connection = None
|
||||
|
||||
# Record connection failure (deferred)
|
||||
import threading as _t
|
||||
_err_msg = str(e)[:200]
|
||||
_t.Timer(1.0, lambda: record_telemetry(
|
||||
RecordType.UNITY_CONNECTION,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": _err_msg,
|
||||
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
|
||||
}
|
||||
)).start()
|
||||
except Exception as e:
|
||||
logger.warning("Unexpected error connecting to Unity on startup: %s", e)
|
||||
_unity_connection = None
|
||||
import threading as _t
|
||||
_err_msg = str(e)[:200]
|
||||
_t.Timer(1.0, lambda: record_telemetry(
|
||||
RecordType.UNITY_CONNECTION,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": _err_msg,
|
||||
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
|
||||
}
|
||||
)).start()
|
||||
|
||||
try:
|
||||
# Yield the connection object so it can be attached to the context
|
||||
# The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)
|
||||
|
|
@ -54,18 +163,18 @@ register_all_tools(mcp)
|
|||
def asset_creation_strategy() -> str:
|
||||
"""Guide for discovering and using MCP for Unity tools effectively."""
|
||||
return (
|
||||
"Available MCP for Unity Server Tools:\\n\\n"
|
||||
"- `manage_editor`: Controls editor state and queries info.\\n"
|
||||
"- `execute_menu_item`: Executes Unity Editor menu items by path.\\n"
|
||||
"- `read_console`: Reads or clears Unity console messages, with filtering options.\\n"
|
||||
"- `manage_scene`: Manages scenes.\\n"
|
||||
"- `manage_gameobject`: Manages GameObjects in the scene.\\n"
|
||||
"- `manage_script`: Manages C# script files.\\n"
|
||||
"- `manage_asset`: Manages prefabs and assets.\\n"
|
||||
"- `manage_shader`: Manages shaders.\\n\\n"
|
||||
"Tips:\\n"
|
||||
"- Create prefabs for reusable GameObjects.\\n"
|
||||
"- Always include a camera and main light in your scenes.\\n"
|
||||
"Available MCP for Unity Server Tools:\n\n"
|
||||
"- `manage_editor`: Controls editor state and queries info.\n"
|
||||
"- `execute_menu_item`: Executes Unity Editor menu items by path.\n"
|
||||
"- `read_console`: Reads or clears Unity console messages, with filtering options.\n"
|
||||
"- `manage_scene`: Manages scenes.\n"
|
||||
"- `manage_gameobject`: Manages GameObjects in the scene.\n"
|
||||
"- `manage_script`: Manages C# script files.\n"
|
||||
"- `manage_asset`: Manages prefabs and assets.\n"
|
||||
"- `manage_shader`: Manages shaders.\n\n"
|
||||
"Tips:\n"
|
||||
"- Create prefabs for reusable GameObjects.\n"
|
||||
"- Always include a camera and main light in your scenes.\n"
|
||||
)
|
||||
|
||||
# Run the server
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.3.0
|
||||
3.3.1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
"""
|
||||
Privacy-focused, anonymous telemetry system for Unity MCP
|
||||
Inspired by Onyx's telemetry implementation with Unity-specific adaptations
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import threading
|
||||
"""
|
||||
Fire-and-forget telemetry sender with a single background worker.
|
||||
- No context/thread-local propagation to avoid re-entrancy into tool resolution.
|
||||
- Small network timeouts to prevent stalls.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import logging
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
import importlib
|
||||
import queue
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
import httpx
|
||||
HAS_HTTPX = True
|
||||
except ImportError:
|
||||
httpx = None # type: ignore
|
||||
HAS_HTTPX = False
|
||||
|
||||
logger = logging.getLogger("unity-mcp-telemetry")
|
||||
|
||||
class RecordType(str, Enum):
|
||||
"""Types of telemetry records we collect"""
|
||||
VERSION = "version"
|
||||
STARTUP = "startup"
|
||||
USAGE = "usage"
|
||||
LATENCY = "latency"
|
||||
FAILURE = "failure"
|
||||
TOOL_EXECUTION = "tool_execution"
|
||||
UNITY_CONNECTION = "unity_connection"
|
||||
CLIENT_CONNECTION = "client_connection"
|
||||
|
||||
class MilestoneType(str, Enum):
|
||||
"""Major user journey milestones"""
|
||||
FIRST_STARTUP = "first_startup"
|
||||
FIRST_TOOL_USAGE = "first_tool_usage"
|
||||
FIRST_SCRIPT_CREATION = "first_script_creation"
|
||||
FIRST_SCENE_MODIFICATION = "first_scene_modification"
|
||||
MULTIPLE_SESSIONS = "multiple_sessions"
|
||||
DAILY_ACTIVE_USER = "daily_active_user"
|
||||
WEEKLY_ACTIVE_USER = "weekly_active_user"
|
||||
|
||||
@dataclass
|
||||
class TelemetryRecord:
|
||||
"""Structure for telemetry data"""
|
||||
record_type: RecordType
|
||||
timestamp: float
|
||||
customer_uuid: str
|
||||
session_id: str
|
||||
data: Dict[str, Any]
|
||||
milestone: Optional[MilestoneType] = None
|
||||
|
||||
class TelemetryConfig:
|
||||
"""Telemetry configuration"""
|
||||
def __init__(self):
|
||||
# Prefer config file, then allow env overrides
|
||||
server_config = None
|
||||
for modname in (
|
||||
"UnityMcpBridge.UnityMcpServer~.src.config",
|
||||
"UnityMcpBridge.UnityMcpServer.src.config",
|
||||
"src.config",
|
||||
"config",
|
||||
):
|
||||
try:
|
||||
mod = importlib.import_module(modname)
|
||||
server_config = getattr(mod, "config", None)
|
||||
if server_config is not None:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Determine enabled flag: config -> env DISABLE_* opt-out
|
||||
cfg_enabled = True if server_config is None else bool(getattr(server_config, "telemetry_enabled", True))
|
||||
self.enabled = cfg_enabled and not self._is_disabled()
|
||||
|
||||
# Telemetry endpoint (Cloud Run default; override via env)
|
||||
cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None)
|
||||
default_ep = cfg_default or "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events"
|
||||
self.default_endpoint = default_ep
|
||||
self.endpoint = self._validated_endpoint(
|
||||
os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep),
|
||||
default_ep,
|
||||
)
|
||||
try:
|
||||
logger.info(
|
||||
"Telemetry configured: endpoint=%s (default=%s), timeout_env=%s",
|
||||
self.endpoint,
|
||||
default_ep,
|
||||
os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "<unset>"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Local storage for UUID and milestones
|
||||
self.data_dir = self._get_data_directory()
|
||||
self.uuid_file = self.data_dir / "customer_uuid.txt"
|
||||
self.milestones_file = self.data_dir / "milestones.json"
|
||||
|
||||
# Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
|
||||
try:
|
||||
self.timeout = float(os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
|
||||
except Exception:
|
||||
self.timeout = 1.5
|
||||
try:
|
||||
logger.info("Telemetry timeout=%.2fs", self.timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Session tracking
|
||||
self.session_id = str(uuid.uuid4())
|
||||
|
||||
def _is_disabled(self) -> bool:
|
||||
"""Check if telemetry is disabled via environment variables"""
|
||||
disable_vars = [
|
||||
"DISABLE_TELEMETRY",
|
||||
"UNITY_MCP_DISABLE_TELEMETRY",
|
||||
"MCP_DISABLE_TELEMETRY"
|
||||
]
|
||||
|
||||
for var in disable_vars:
|
||||
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_data_directory(self) -> Path:
|
||||
"""Get directory for storing telemetry data"""
|
||||
if os.name == 'nt': # Windows
|
||||
base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming'))
|
||||
elif os.name == 'posix': # macOS/Linux
|
||||
if 'darwin' in os.uname().sysname.lower(): # macOS
|
||||
base_dir = Path.home() / 'Library' / 'Application Support'
|
||||
else: # Linux
|
||||
base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share'))
|
||||
else:
|
||||
base_dir = Path.home() / '.unity-mcp'
|
||||
|
||||
data_dir = base_dir / 'UnityMCP'
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
return data_dir
|
||||
|
||||
def _validated_endpoint(self, candidate: str, fallback: str) -> str:
|
||||
"""Validate telemetry endpoint URL scheme; allow only http/https.
|
||||
Falls back to the provided default on error.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(candidate)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
|
||||
# Basic sanity: require network location and path
|
||||
if not parsed.netloc:
|
||||
raise ValueError("Missing netloc in endpoint")
|
||||
# Reject localhost/loopback endpoints in production to avoid accidental local overrides
|
||||
host = parsed.hostname or ""
|
||||
if host in ("localhost", "127.0.0.1", "::1"):
|
||||
raise ValueError("Localhost endpoints are not allowed for telemetry")
|
||||
return candidate
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return fallback
|
||||
|
||||
class TelemetryCollector:
|
||||
"""Main telemetry collection class"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = TelemetryConfig()
|
||||
self._customer_uuid: Optional[str] = None
|
||||
self._milestones: Dict[str, Dict[str, Any]] = {}
|
||||
self._lock: threading.Lock = threading.Lock()
|
||||
# Bounded queue with single background worker (records only; no context propagation)
|
||||
self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
|
||||
# Load persistent data before starting worker so first events have UUID
|
||||
self._load_persistent_data()
|
||||
self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def _load_persistent_data(self):
|
||||
"""Load UUID and milestones from disk"""
|
||||
# Load customer UUID
|
||||
try:
|
||||
if self.config.uuid_file.exists():
|
||||
self._customer_uuid = self.config.uuid_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4())
|
||||
else:
|
||||
self._customer_uuid = str(uuid.uuid4())
|
||||
try:
|
||||
self.config.uuid_file.write_text(self._customer_uuid, encoding="utf-8")
|
||||
if os.name == "posix":
|
||||
os.chmod(self.config.uuid_file, 0o600)
|
||||
except OSError as e:
|
||||
logger.debug(f"Failed to persist customer UUID: {e}", exc_info=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
|
||||
self._customer_uuid = str(uuid.uuid4())
|
||||
|
||||
# Load milestones (failure here must not affect UUID)
|
||||
try:
|
||||
if self.config.milestones_file.exists():
|
||||
content = self.config.milestones_file.read_text(encoding="utf-8")
|
||||
self._milestones = json.loads(content) or {}
|
||||
if not isinstance(self._milestones, dict):
|
||||
self._milestones = {}
|
||||
except (OSError, json.JSONDecodeError, ValueError) as e:
|
||||
logger.debug(f"Failed to load milestones: {e}", exc_info=True)
|
||||
self._milestones = {}
|
||||
|
||||
def _save_milestones(self):
|
||||
"""Save milestones to disk. Caller must hold self._lock."""
|
||||
try:
|
||||
self.config.milestones_file.write_text(
|
||||
json.dumps(self._milestones, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to save milestones: {e}", exc_info=True)
|
||||
|
||||
def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Record a milestone event, returns True if this is the first occurrence"""
|
||||
if not self.config.enabled:
|
||||
return False
|
||||
milestone_key = milestone.value
|
||||
with self._lock:
|
||||
if milestone_key in self._milestones:
|
||||
return False # Already recorded
|
||||
milestone_data = {
|
||||
"timestamp": time.time(),
|
||||
"data": data or {},
|
||||
}
|
||||
self._milestones[milestone_key] = milestone_data
|
||||
self._save_milestones()
|
||||
|
||||
# Also send as telemetry record
|
||||
self.record(
|
||||
record_type=RecordType.USAGE,
|
||||
data={"milestone": milestone_key, **(data or {})},
|
||||
milestone=milestone
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def record(self,
|
||||
record_type: RecordType,
|
||||
data: Dict[str, Any],
|
||||
milestone: Optional[MilestoneType] = None):
|
||||
"""Record a telemetry event (async, non-blocking)"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Allow fallback sender when httpx is unavailable (no early return)
|
||||
|
||||
record = TelemetryRecord(
|
||||
record_type=record_type,
|
||||
timestamp=time.time(),
|
||||
customer_uuid=self._customer_uuid or "unknown",
|
||||
session_id=self.config.session_id,
|
||||
data=data,
|
||||
milestone=milestone
|
||||
)
|
||||
# Enqueue for background worker (non-blocking). Drop on backpressure.
|
||||
try:
|
||||
self._queue.put_nowait(record)
|
||||
except queue.Full:
|
||||
logger.debug("Telemetry queue full; dropping %s", record.record_type)
|
||||
|
||||
def _worker_loop(self):
|
||||
"""Background worker that serializes telemetry sends."""
|
||||
while True:
|
||||
rec = self._queue.get()
|
||||
try:
|
||||
# Run sender directly; do not reuse caller context/thread-locals
|
||||
self._send_telemetry(rec)
|
||||
except Exception:
|
||||
logger.debug("Telemetry worker send failed", exc_info=True)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
self._queue.task_done()
|
||||
|
||||
def _send_telemetry(self, record: TelemetryRecord):
|
||||
"""Send telemetry data to endpoint"""
|
||||
try:
|
||||
# System fingerprint (top-level remains concise; details stored in data JSON)
|
||||
_platform = platform.system() # 'Darwin' | 'Linux' | 'Windows'
|
||||
_source = sys.platform # 'darwin' | 'linux' | 'win32'
|
||||
_platform_detail = f"{_platform} {platform.release()} ({platform.machine()})"
|
||||
_python_version = platform.python_version()
|
||||
|
||||
# Enrich data JSON so BigQuery stores detailed fields without schema change
|
||||
enriched_data = dict(record.data or {})
|
||||
enriched_data.setdefault("platform_detail", _platform_detail)
|
||||
enriched_data.setdefault("python_version", _python_version)
|
||||
|
||||
payload = {
|
||||
"record": record.record_type.value,
|
||||
"timestamp": record.timestamp,
|
||||
"customer_uuid": record.customer_uuid,
|
||||
"session_id": record.session_id,
|
||||
"data": enriched_data,
|
||||
"version": "3.0.2", # Unity MCP version
|
||||
"platform": _platform,
|
||||
"source": _source,
|
||||
}
|
||||
|
||||
if record.milestone:
|
||||
payload["milestone"] = record.milestone.value
|
||||
|
||||
# Prefer httpx when available; otherwise fall back to urllib
|
||||
if httpx:
|
||||
with httpx.Client(timeout=self.config.timeout) as client:
|
||||
# Re-validate endpoint at send time to handle dynamic changes
|
||||
endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint)
|
||||
response = client.post(endpoint, json=payload)
|
||||
if 200 <= response.status_code < 300:
|
||||
logger.debug(f"Telemetry sent: {record.record_type}")
|
||||
else:
|
||||
logger.warning(f"Telemetry failed: HTTP {response.status_code}")
|
||||
else:
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
data_bytes = json.dumps(payload).encode("utf-8")
|
||||
endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint)
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=data_bytes,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
|
||||
if 200 <= resp.getcode() < 300:
|
||||
logger.debug(f"Telemetry sent (urllib): {record.record_type}")
|
||||
else:
|
||||
logger.warning(f"Telemetry failed (urllib): HTTP {resp.getcode()}")
|
||||
except urllib.error.URLError as ue:
|
||||
logger.warning(f"Telemetry send failed (urllib): {ue}")
|
||||
|
||||
except Exception as e:
|
||||
# Never let telemetry errors interfere with app functionality
|
||||
logger.debug(f"Telemetry send failed: {e}")
|
||||
|
||||
|
||||
# Global telemetry instance
|
||||
_telemetry_collector: Optional[TelemetryCollector] = None
|
||||
|
||||
def get_telemetry() -> TelemetryCollector:
|
||||
"""Get the global telemetry collector instance"""
|
||||
global _telemetry_collector
|
||||
if _telemetry_collector is None:
|
||||
_telemetry_collector = TelemetryCollector()
|
||||
return _telemetry_collector
|
||||
|
||||
def record_telemetry(record_type: RecordType,
|
||||
data: Dict[str, Any],
|
||||
milestone: Optional[MilestoneType] = None):
|
||||
"""Convenience function to record telemetry"""
|
||||
get_telemetry().record(record_type, data, milestone)
|
||||
|
||||
def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Convenience function to record a milestone"""
|
||||
return get_telemetry().record_milestone(milestone, data)
|
||||
|
||||
def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None):
|
||||
"""Record tool usage telemetry
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool invoked (e.g., 'manage_scene').
|
||||
success: Whether the tool completed successfully.
|
||||
duration_ms: Execution duration in milliseconds.
|
||||
error: Optional error message (truncated if present).
|
||||
sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy').
|
||||
"""
|
||||
data = {
|
||||
"tool_name": tool_name,
|
||||
"success": success,
|
||||
"duration_ms": round(duration_ms, 2)
|
||||
}
|
||||
|
||||
if sub_action is not None:
|
||||
try:
|
||||
data["sub_action"] = str(sub_action)
|
||||
except Exception:
|
||||
# Ensure telemetry is never disruptive
|
||||
data["sub_action"] = "unknown"
|
||||
|
||||
if error:
|
||||
data["error"] = str(error)[:200] # Limit error message length
|
||||
|
||||
record_telemetry(RecordType.TOOL_EXECUTION, data)
|
||||
|
||||
def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Record latency telemetry"""
|
||||
data = {
|
||||
"operation": operation,
|
||||
"duration_ms": round(duration_ms, 2)
|
||||
}
|
||||
|
||||
if metadata:
|
||||
data.update(metadata)
|
||||
|
||||
record_telemetry(RecordType.LATENCY, data)
|
||||
|
||||
def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Record failure telemetry"""
|
||||
data = {
|
||||
"component": component,
|
||||
"error": str(error)[:500] # Limit error message length
|
||||
}
|
||||
|
||||
if metadata:
|
||||
data.update(metadata)
|
||||
|
||||
record_telemetry(RecordType.FAILURE, data)
|
||||
|
||||
def is_telemetry_enabled() -> bool:
|
||||
"""Check if telemetry is enabled"""
|
||||
return get_telemetry().config.enabled
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
"""
|
||||
Telemetry decorator for Unity MCP tools
|
||||
"""
|
||||
|
||||
import functools
|
||||
import time
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Callable, Any
|
||||
from telemetry import record_tool_usage, record_milestone, MilestoneType
|
||||
|
||||
_log = logging.getLogger("unity-mcp-telemetry")
|
||||
_decorator_log_count = 0
|
||||
|
||||
def telemetry_tool(tool_name: str):
|
||||
"""Decorator to add telemetry tracking to MCP tools"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def _sync_wrapper(*args, **kwargs) -> Any:
|
||||
start_time = time.time()
|
||||
success = False
|
||||
error = None
|
||||
# Extract sub-action (e.g., 'get_hierarchy') from bound args when available
|
||||
sub_action = None
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
bound = sig.bind_partial(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
sub_action = bound.arguments.get("action")
|
||||
except Exception:
|
||||
sub_action = None
|
||||
try:
|
||||
global _decorator_log_count
|
||||
if _decorator_log_count < 10:
|
||||
_log.info(f"telemetry_decorator sync: tool={tool_name}")
|
||||
_decorator_log_count += 1
|
||||
result = func(*args, **kwargs)
|
||||
success = True
|
||||
action_val = sub_action or kwargs.get("action")
|
||||
try:
|
||||
if tool_name == "manage_script" and action_val == "create":
|
||||
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
|
||||
elif tool_name.startswith("manage_scene"):
|
||||
record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION)
|
||||
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
|
||||
except Exception:
|
||||
_log.debug("milestone emit failed", exc_info=True)
|
||||
return result
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
raise
|
||||
finally:
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
try:
|
||||
record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action)
|
||||
except Exception:
|
||||
_log.debug("record_tool_usage failed", exc_info=True)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _async_wrapper(*args, **kwargs) -> Any:
|
||||
start_time = time.time()
|
||||
success = False
|
||||
error = None
|
||||
# Extract sub-action (e.g., 'get_hierarchy') from bound args when available
|
||||
sub_action = None
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
bound = sig.bind_partial(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
sub_action = bound.arguments.get("action")
|
||||
except Exception:
|
||||
sub_action = None
|
||||
try:
|
||||
global _decorator_log_count
|
||||
if _decorator_log_count < 10:
|
||||
_log.info(f"telemetry_decorator async: tool={tool_name}")
|
||||
_decorator_log_count += 1
|
||||
result = await func(*args, **kwargs)
|
||||
success = True
|
||||
action_val = sub_action or kwargs.get("action")
|
||||
try:
|
||||
if tool_name == "manage_script" and action_val == "create":
|
||||
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
|
||||
elif tool_name.startswith("manage_scene"):
|
||||
record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION)
|
||||
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
|
||||
except Exception:
|
||||
_log.debug("milestone emit failed", exc_info=True)
|
||||
return result
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
raise
|
||||
finally:
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
try:
|
||||
record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action)
|
||||
except Exception:
|
||||
_log.debug("record_tool_usage failed", exc_info=True)
|
||||
|
||||
return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
|
||||
return decorator
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Unity MCP Telemetry System
|
||||
Run this to verify telemetry is working correctly
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to Python path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
def test_telemetry_basic():
|
||||
"""Test basic telemetry functionality"""
|
||||
# Avoid stdout noise in tests
|
||||
|
||||
try:
|
||||
from telemetry import (
|
||||
get_telemetry, record_telemetry, record_milestone,
|
||||
RecordType, MilestoneType, is_telemetry_enabled
|
||||
)
|
||||
pass
|
||||
except ImportError as e:
|
||||
# Silent failure path for tests
|
||||
return False
|
||||
|
||||
# Test telemetry enabled status
|
||||
_ = is_telemetry_enabled()
|
||||
|
||||
# Test basic record
|
||||
try:
|
||||
record_telemetry(RecordType.VERSION, {
|
||||
"version": "3.0.2",
|
||||
"test_run": True
|
||||
})
|
||||
pass
|
||||
except Exception as e:
|
||||
# Silent failure path for tests
|
||||
return False
|
||||
|
||||
# Test milestone recording
|
||||
try:
|
||||
is_first = record_milestone(MilestoneType.FIRST_STARTUP, {
|
||||
"test_mode": True
|
||||
})
|
||||
_ = is_first
|
||||
except Exception as e:
|
||||
# Silent failure path for tests
|
||||
return False
|
||||
|
||||
# Test telemetry collector
|
||||
try:
|
||||
collector = get_telemetry()
|
||||
_ = collector
|
||||
except Exception as e:
|
||||
# Silent failure path for tests
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def test_telemetry_disabled():
|
||||
"""Test telemetry with disabled state"""
|
||||
# Silent for tests
|
||||
|
||||
# Set environment variable to disable telemetry
|
||||
os.environ["DISABLE_TELEMETRY"] = "true"
|
||||
|
||||
# Re-import to get fresh config
|
||||
import importlib
|
||||
import telemetry
|
||||
importlib.reload(telemetry)
|
||||
|
||||
from telemetry import is_telemetry_enabled, record_telemetry, RecordType
|
||||
|
||||
_ = is_telemetry_enabled()
|
||||
|
||||
if not is_telemetry_enabled():
|
||||
pass
|
||||
|
||||
# Test that records are ignored when disabled
|
||||
record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"})
|
||||
pass
|
||||
|
||||
return True
|
||||
else:
|
||||
pass
|
||||
return False
|
||||
|
||||
def test_data_storage():
|
||||
"""Test data storage functionality"""
|
||||
# Silent for tests
|
||||
|
||||
try:
|
||||
from telemetry import get_telemetry
|
||||
|
||||
collector = get_telemetry()
|
||||
data_dir = collector.config.data_dir
|
||||
|
||||
_ = (data_dir, collector.config.uuid_file, collector.config.milestones_file)
|
||||
|
||||
# Check if files exist
|
||||
if collector.config.uuid_file.exists():
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
|
||||
if collector.config.milestones_file.exists():
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Silent failure path for tests
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all telemetry tests"""
|
||||
# Silent runner for CI
|
||||
|
||||
tests = [
|
||||
test_telemetry_basic,
|
||||
test_data_storage,
|
||||
test_telemetry_disabled,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
if test():
|
||||
passed += 1
|
||||
pass
|
||||
else:
|
||||
failed += 1
|
||||
pass
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
pass
|
||||
|
||||
_ = (passed, failed)
|
||||
|
||||
if failed == 0:
|
||||
pass
|
||||
return True
|
||||
else:
|
||||
pass
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
|
@ -7,12 +7,15 @@ from unity_connection import get_unity_connection, send_command_with_retry # Im
|
|||
from config import config
|
||||
import time
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_execute_menu_item_tools(mcp: FastMCP):
|
||||
"""Registers the execute_menu_item tool with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
async def execute_menu_item(
|
||||
ctx: Context,
|
||||
@telemetry_tool("execute_menu_item")
|
||||
def execute_menu_item(
|
||||
ctx: Any,
|
||||
menu_path: str,
|
||||
action: str = 'execute',
|
||||
parameters: Dict[str, Any] = None,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ from unity_connection import get_unity_connection, async_send_command_with_retry
|
|||
from config import config
|
||||
import time
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_manage_asset_tools(mcp: FastMCP):
|
||||
"""Registers the manage_asset tool with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_asset")
|
||||
async def manage_asset(
|
||||
ctx: Context,
|
||||
ctx: Any,
|
||||
action: str,
|
||||
path: str,
|
||||
asset_type: str = None,
|
||||
|
|
@ -24,8 +27,8 @@ def register_manage_asset_tools(mcp: FastMCP):
|
|||
search_pattern: str = None,
|
||||
filter_type: str = None,
|
||||
filter_date_after: str = None,
|
||||
page_size: int = None,
|
||||
page_number: int = None
|
||||
page_size: Any = None,
|
||||
page_number: Any = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Performs asset operations (import, create, modify, delete, etc.) in Unity.
|
||||
|
||||
|
|
@ -50,6 +53,25 @@ def register_manage_asset_tools(mcp: FastMCP):
|
|||
if properties is None:
|
||||
properties = {}
|
||||
|
||||
# Coerce numeric inputs defensively
|
||||
def _coerce_int(value, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return int(value)
|
||||
s = str(value).strip()
|
||||
if s.lower() in ("", "none", "null"):
|
||||
return default
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
page_size = _coerce_int(page_size)
|
||||
page_number = _coerce_int(page_number)
|
||||
|
||||
# Prepare parameters for the C# handler
|
||||
params_dict = {
|
||||
"action": action.lower(),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,25 @@ from typing import Dict, Any
|
|||
from unity_connection import get_unity_connection, send_command_with_retry
|
||||
from config import config
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
from telemetry import is_telemetry_enabled, record_tool_usage
|
||||
|
||||
def register_manage_editor_tools(mcp: FastMCP):
|
||||
"""Register all editor management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(description=(
|
||||
"Controls and queries the Unity editor's state and settings.\n\n"
|
||||
"Args:\n"
|
||||
"- ctx: Context object (required)\n"
|
||||
"- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n"
|
||||
"- wait_for_completion: Optional. If True, waits for certain actions\n"
|
||||
"- tool_name: Tool name for specific actions\n"
|
||||
"- tag_name: Tag name for specific actions\n"
|
||||
"- layer_name: Layer name for specific actions\n\n"
|
||||
"Returns:\n"
|
||||
"Dictionary with operation results ('success', 'message', 'data')."
|
||||
))
|
||||
@telemetry_tool("manage_editor")
|
||||
def manage_editor(
|
||||
ctx: Context,
|
||||
action: str,
|
||||
|
|
@ -17,17 +32,14 @@ def register_manage_editor_tools(mcp: FastMCP):
|
|||
tag_name: str = None,
|
||||
layer_name: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Controls and queries the Unity editor's state and settings.
|
||||
|
||||
Args:
|
||||
action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag').
|
||||
wait_for_completion: Optional. If True, waits for certain actions.
|
||||
Action-specific arguments (e.g., tool_name, tag_name, layer_name).
|
||||
|
||||
Returns:
|
||||
Dictionary with operation results ('success', 'message', 'data').
|
||||
"""
|
||||
try:
|
||||
# Diagnostics: quick telemetry checks
|
||||
if action == "telemetry_status":
|
||||
return {"success": True, "telemetry_enabled": is_telemetry_enabled()}
|
||||
|
||||
if action == "telemetry_ping":
|
||||
record_tool_usage("diagnostic_ping", True, 1.0, None)
|
||||
return {"success": True, "message": "telemetry ping queued"}
|
||||
# Prepare parameters, removing None values
|
||||
params = {
|
||||
"action": action,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ from unity_connection import get_unity_connection, send_command_with_retry
|
|||
from config import config
|
||||
import time
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_manage_gameobject_tools(mcp: FastMCP):
|
||||
"""Register all GameObject management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_gameobject")
|
||||
def manage_gameobject(
|
||||
ctx: Context,
|
||||
ctx: Any,
|
||||
action: str,
|
||||
target: str = None, # GameObject identifier by name or path
|
||||
search_method: str = None,
|
||||
|
|
@ -119,9 +122,9 @@ def register_manage_gameobject_tools(mcp: FastMCP):
|
|||
params["prefabPath"] = constructed_path.replace("\\", "/")
|
||||
elif not params["prefabPath"].lower().endswith(".prefab"):
|
||||
return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"}
|
||||
# Ensure prefab_folder itself isn't sent if prefabPath was constructed or provided
|
||||
# Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided
|
||||
# The C# side only needs the final prefabPath
|
||||
params.pop("prefab_folder", None)
|
||||
params.pop("prefabFolder", None)
|
||||
# --------------------------------
|
||||
|
||||
# Use centralized retry helper
|
||||
|
|
|
|||
|
|
@ -4,16 +4,19 @@ from unity_connection import get_unity_connection, send_command_with_retry
|
|||
from config import config
|
||||
import time
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_manage_scene_tools(mcp: FastMCP):
|
||||
"""Register all scene management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_scene")
|
||||
def manage_scene(
|
||||
ctx: Context,
|
||||
action: str,
|
||||
name: str,
|
||||
path: str,
|
||||
build_index: int,
|
||||
name: str = "",
|
||||
path: str = "",
|
||||
build_index: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Manages Unity scenes (load, save, create, get hierarchy, etc.).
|
||||
|
||||
|
|
@ -28,13 +31,31 @@ def register_manage_scene_tools(mcp: FastMCP):
|
|||
Dictionary with results ('success', 'message', 'data').
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"action": action,
|
||||
"name": name,
|
||||
"path": path,
|
||||
"buildIndex": build_index
|
||||
}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
# Coerce numeric inputs defensively
|
||||
def _coerce_int(value, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return int(value)
|
||||
s = str(value).strip()
|
||||
if s.lower() in ("", "none", "null"):
|
||||
return default
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
coerced_build_index = _coerce_int(build_index, default=None)
|
||||
|
||||
params = {"action": action}
|
||||
if name:
|
||||
params["name"] = name
|
||||
if path:
|
||||
params["path"] = path
|
||||
if coerced_build_index is not None:
|
||||
params["buildIndex"] = coerced_build_index
|
||||
|
||||
# Use centralized retry helper
|
||||
response = send_command_with_retry("manage_scene", params)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ import base64
|
|||
import os
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
try:
|
||||
from telemetry_decorator import telemetry_tool
|
||||
from telemetry import record_milestone, MilestoneType
|
||||
HAS_TELEMETRY = True
|
||||
except ImportError:
|
||||
HAS_TELEMETRY = False
|
||||
def telemetry_tool(tool_name: str):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def register_manage_script_tools(mcp: FastMCP):
|
||||
"""Register all script management tools with the MCP server."""
|
||||
|
|
@ -80,6 +90,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"- For method/class operations, use script_apply_edits (safer, structured edits)\n"
|
||||
"- For pattern-based replacements, consider anchor operations in script_apply_edits\n"
|
||||
))
|
||||
@telemetry_tool("apply_text_edits")
|
||||
def apply_text_edits(
|
||||
ctx: Context,
|
||||
uri: str,
|
||||
|
|
@ -346,6 +357,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n"
|
||||
"Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n"
|
||||
))
|
||||
@telemetry_tool("create_script")
|
||||
def create_script(
|
||||
ctx: Context,
|
||||
path: str,
|
||||
|
|
@ -385,6 +397,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"Args: uri (unity://path/... or file://... or Assets/...).\n"
|
||||
"Rules: Target must resolve under Assets/.\n"
|
||||
))
|
||||
@telemetry_tool("delete_script")
|
||||
def delete_script(ctx: Context, uri: str) -> Dict[str, Any]:
|
||||
"""Delete a C# script by URI."""
|
||||
name, directory = _split_uri(uri)
|
||||
|
|
@ -396,12 +409,14 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
|
||||
@mcp.tool(description=(
|
||||
"Validate a C# script and return diagnostics.\n\n"
|
||||
"Args: uri, level=('basic'|'standard').\n"
|
||||
"Args: uri, level=('basic'|'standard'), include_diagnostics (bool, optional).\n"
|
||||
"- basic: quick syntax checks.\n"
|
||||
"- standard: deeper checks (performance hints, common pitfalls).\n"
|
||||
"- include_diagnostics: when true, returns full diagnostics and summary; default returns counts only.\n"
|
||||
))
|
||||
@telemetry_tool("validate_script")
|
||||
def validate_script(
|
||||
ctx: Context, uri: str, level: str = "basic"
|
||||
ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate a C# script and return diagnostics."""
|
||||
name, directory = _split_uri(uri)
|
||||
|
|
@ -418,8 +433,10 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
resp = send_command_with_retry("manage_script", params)
|
||||
if isinstance(resp, dict) and resp.get("success"):
|
||||
diags = resp.get("data", {}).get("diagnostics", []) or []
|
||||
warnings = sum(d.get("severity", "").lower() == "warning" for d in diags)
|
||||
errors = sum(d.get("severity", "").lower() in ("error", "fatal") for d in diags)
|
||||
warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning")
|
||||
errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal"))
|
||||
if include_diagnostics:
|
||||
return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
|
||||
return {"success": True, "data": {"warnings": warnings, "errors": errors}}
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
|
||||
|
|
@ -429,6 +446,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n"
|
||||
"Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n"
|
||||
))
|
||||
@telemetry_tool("manage_script")
|
||||
def manage_script(
|
||||
ctx: Context,
|
||||
action: str,
|
||||
|
|
@ -560,6 +578,7 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"Get manage_script capabilities (supported ops, limits, and guards).\n\n"
|
||||
"Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n"
|
||||
))
|
||||
@telemetry_tool("manage_script_capabilities")
|
||||
def manage_script_capabilities(ctx: Context) -> Dict[str, Any]:
|
||||
try:
|
||||
# Keep in sync with server/Editor ManageScript implementation
|
||||
|
|
@ -583,10 +602,11 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
return {"success": False, "error": f"capabilities error: {e}"}
|
||||
|
||||
@mcp.tool(description=(
|
||||
"Get SHA256 and metadata for a Unity C# script without returning file contents.\n\n"
|
||||
"Get SHA256 and basic metadata for a Unity C# script without returning file contents.\n\n"
|
||||
"Args: uri (unity://path/Assets/... or file://... or Assets/...).\n"
|
||||
"Returns: {sha256, lengthBytes, lastModifiedUtc, uri, path}."
|
||||
"Returns: {sha256, lengthBytes}."
|
||||
))
|
||||
@telemetry_tool("get_sha")
|
||||
def get_sha(ctx: Context, uri: str) -> Dict[str, Any]:
|
||||
"""Return SHA256 and basic metadata for a script."""
|
||||
try:
|
||||
|
|
@ -595,13 +615,8 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
resp = send_command_with_retry("manage_script", params)
|
||||
if isinstance(resp, dict) and resp.get("success"):
|
||||
data = resp.get("data", {})
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"sha256": data.get("sha256"),
|
||||
"lengthBytes": data.get("lengthBytes"),
|
||||
},
|
||||
}
|
||||
minimal = {"sha256": data.get("sha256"), "lengthBytes": data.get("lengthBytes")}
|
||||
return {"success": True, "data": minimal}
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"get_sha error: {e}"}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from typing import Dict, Any, List, Tuple
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
import base64
|
||||
import re
|
||||
import os
|
||||
from unity_connection import send_command_with_retry
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
|
||||
def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str:
|
||||
text = original_text
|
||||
|
|
@ -316,22 +318,42 @@ def register_manage_script_edits_tools(mcp: FastMCP):
|
|||
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n"
|
||||
"Examples:\n"
|
||||
"1) Replace a method:\n"
|
||||
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
|
||||
" { 'op':'replace_method','className':'SmartReach','methodName':'HasTarget',\n"
|
||||
" 'replacement':'public bool HasTarget(){ return currentTarget!=null; }' }\n"
|
||||
"], 'options':{'validate':'standard','refresh':'immediate'} }\n\n"
|
||||
"{\n"
|
||||
" \"name\": \"SmartReach\",\n"
|
||||
" \"path\": \"Assets/Scripts/Interaction\",\n"
|
||||
" \"edits\": [\n"
|
||||
" {\n"
|
||||
" \"op\": \"replace_method\",\n"
|
||||
" \"className\": \"SmartReach\",\n"
|
||||
" \"methodName\": \"HasTarget\",\n"
|
||||
" \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n"
|
||||
" }\n"
|
||||
" ],\n"
|
||||
" \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n"
|
||||
"}\n\n"
|
||||
"2) Insert a method after another:\n"
|
||||
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
|
||||
" { 'op':'insert_method','className':'SmartReach','replacement':'public void PrintSeries(){ Debug.Log(seriesName); }',\n"
|
||||
" 'position':'after','afterMethodName':'GetCurrentTarget' }\n"
|
||||
"] }\n"
|
||||
"{\n"
|
||||
" \"name\": \"SmartReach\",\n"
|
||||
" \"path\": \"Assets/Scripts/Interaction\",\n"
|
||||
" \"edits\": [\n"
|
||||
" {\n"
|
||||
" \"op\": \"insert_method\",\n"
|
||||
" \"className\": \"SmartReach\",\n"
|
||||
" \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n"
|
||||
" \"position\": \"after\",\n"
|
||||
" \"afterMethodName\": \"GetCurrentTarget\"\n"
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}\n\n"
|
||||
"Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n"
|
||||
))
|
||||
@telemetry_tool("script_apply_edits")
|
||||
def script_apply_edits(
|
||||
ctx: Context,
|
||||
name: str,
|
||||
path: str,
|
||||
edits: List[Dict[str, Any]],
|
||||
options: Dict[str, Any] | None = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
script_type: str = "MonoBehaviour",
|
||||
namespace: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import time
|
|||
import os
|
||||
import base64
|
||||
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_manage_shader_tools(mcp: FastMCP):
|
||||
"""Register all shader script management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_shader")
|
||||
def manage_shader(
|
||||
ctx: Context,
|
||||
ctx: Any,
|
||||
action: str,
|
||||
name: str,
|
||||
path: str,
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ import time
|
|||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from unity_connection import get_unity_connection, send_command_with_retry
|
||||
from config import config
|
||||
from telemetry_decorator import telemetry_tool
|
||||
|
||||
def register_read_console_tools(mcp: FastMCP):
|
||||
"""Registers the read_console tool with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("read_console")
|
||||
def read_console(
|
||||
ctx: Context,
|
||||
action: str = None,
|
||||
types: List[str] = None,
|
||||
count: int = None,
|
||||
count: Any = None,
|
||||
filter_text: str = None,
|
||||
since_timestamp: str = None,
|
||||
format: str = None,
|
||||
|
|
@ -40,21 +42,34 @@ def register_read_console_tools(mcp: FastMCP):
|
|||
# Get the connection instance
|
||||
bridge = get_unity_connection()
|
||||
|
||||
# Set defaults if values are None (conservative but useful for CI)
|
||||
# Set defaults if values are None
|
||||
action = action if action is not None else 'get'
|
||||
types = types if types is not None else ['error']
|
||||
# Normalize types if passed as a single string
|
||||
if isinstance(types, str):
|
||||
types = [types]
|
||||
format = format if format is not None else 'json'
|
||||
types = types if types is not None else ['error', 'warning', 'log']
|
||||
format = format if format is not None else 'detailed'
|
||||
include_stacktrace = include_stacktrace if include_stacktrace is not None else True
|
||||
# Default count to a higher value unless explicitly provided
|
||||
count = 50 if count is None else count
|
||||
|
||||
# Normalize action if it's a string
|
||||
if isinstance(action, str):
|
||||
action = action.lower()
|
||||
|
||||
# Coerce count defensively (string/float -> int)
|
||||
def _coerce_int(value, default=None):
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
return int(value)
|
||||
s = str(value).strip()
|
||||
if s.lower() in ("", "none", "null"):
|
||||
return default
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
count = _coerce_int(count)
|
||||
|
||||
# Prepare parameters for the C# handler
|
||||
params_dict = {
|
||||
"action": action,
|
||||
|
|
@ -73,25 +88,15 @@ def register_read_console_tools(mcp: FastMCP):
|
|||
if 'count' not in params_dict:
|
||||
params_dict['count'] = None
|
||||
|
||||
# Use centralized retry helper (tolerate legacy list payloads from some agents)
|
||||
# Use centralized retry helper
|
||||
resp = send_command_with_retry("read_console", params_dict)
|
||||
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
||||
data = resp.get("data", {}) or {}
|
||||
lines = data.get("lines")
|
||||
if lines is None:
|
||||
# Some handlers return the raw list under data
|
||||
lines = data if isinstance(data, list) else []
|
||||
|
||||
def _entry(x: Any) -> Dict[str, Any]:
|
||||
if isinstance(x, dict):
|
||||
return {
|
||||
"level": x.get("level") or x.get("type"),
|
||||
"message": x.get("message") or x.get("text"),
|
||||
}
|
||||
if isinstance(x, (list, tuple)) and len(x) >= 2:
|
||||
return {"level": x[0], "message": x[1]}
|
||||
return {"level": None, "message": str(x)}
|
||||
|
||||
trimmed = [_entry(l) for l in (lines or [])]
|
||||
return {"success": True, "data": {"lines": trimmed}}
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
# Strip stacktrace fields from returned lines if present
|
||||
try:
|
||||
lines = resp.get("data", {}).get("lines", [])
|
||||
for line in lines:
|
||||
if isinstance(line, dict) and "stacktrace" in line:
|
||||
line.pop("stacktrace", None)
|
||||
except Exception:
|
||||
pass
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
|
|
@ -3,9 +3,8 @@ Resource wrapper tools so clients that do not expose MCP resources primitives
|
|||
can still list and read files via normal tools. These call into the same
|
||||
safe path logic (re-implemented here to avoid importing server.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Optional
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
|
@ -14,9 +13,34 @@ import hashlib
|
|||
import os
|
||||
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from telemetry_decorator import telemetry_tool
|
||||
from unity_connection import send_command_with_retry
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]:
|
||||
"""Safely coerce various inputs (str/float/etc.) to an int.
|
||||
Returns default on failure; clamps to minimum when provided.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
# Avoid treating booleans as ints implicitly
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, int):
|
||||
result = int(value)
|
||||
else:
|
||||
s = str(value).strip()
|
||||
if s.lower() in ("", "none", "null"):
|
||||
return default
|
||||
# Allow "10.0" or similar inputs
|
||||
result = int(float(s))
|
||||
if minimum is not None and result < minimum:
|
||||
return minimum
|
||||
return result
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _resolve_project_root(override: str | None) -> Path:
|
||||
# 1) Explicit override
|
||||
if override:
|
||||
|
|
@ -114,12 +138,13 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
"Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n"
|
||||
"Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n"
|
||||
))
|
||||
@telemetry_tool("list_resources")
|
||||
async def list_resources(
|
||||
ctx: Context | None = None,
|
||||
pattern: str | None = "*.cs",
|
||||
ctx: Optional[Context] = None,
|
||||
pattern: Optional[str] = "*.cs",
|
||||
under: str = "Assets",
|
||||
limit: int = 200,
|
||||
project_root: str | None = None,
|
||||
limit: Any = 200,
|
||||
project_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Lists project URIs (unity://path/...) under a folder (default: Assets).
|
||||
|
|
@ -141,6 +166,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
return {"success": False, "error": "Listing is restricted to Assets/"}
|
||||
|
||||
matches: List[str] = []
|
||||
limit_int = _coerce_int(limit, default=200, minimum=1)
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
|
|
@ -157,7 +183,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
continue
|
||||
rel = p.relative_to(project).as_posix()
|
||||
matches.append(f"unity://path/{rel}")
|
||||
if len(matches) >= max(1, limit):
|
||||
if len(matches) >= max(1, limit_int):
|
||||
break
|
||||
|
||||
# Always include the canonical spec resource so NL clients can discover it
|
||||
|
|
@ -174,21 +200,20 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
"Security: uri must resolve under Assets/.\n"
|
||||
"Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n"
|
||||
))
|
||||
@telemetry_tool("read_resource")
|
||||
async def read_resource(
|
||||
uri: str,
|
||||
ctx: Context | None = None,
|
||||
start_line: int | None = None,
|
||||
line_count: int | None = None,
|
||||
head_bytes: int | None = None,
|
||||
tail_lines: int | None = None,
|
||||
project_root: str | None = None,
|
||||
request: str | None = None,
|
||||
include_text: bool = False,
|
||||
ctx: Optional[Context] = None,
|
||||
start_line: Any = None,
|
||||
line_count: Any = None,
|
||||
head_bytes: Any = None,
|
||||
tail_lines: Any = None,
|
||||
project_root: Optional[str] = None,
|
||||
request: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Reads a resource by unity://path/... URI with optional slicing.
|
||||
By default only the SHA-256 hash and byte length are returned; set
|
||||
``include_text`` or provide window arguments to receive text.
|
||||
One of line window (start_line/line_count) or head_bytes can be used to limit size.
|
||||
"""
|
||||
try:
|
||||
# Serve the canonical spec directly when requested (allow bare or with scheme)
|
||||
|
|
@ -293,57 +318,54 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
start_line = max(1, hit_line - half)
|
||||
line_count = window
|
||||
|
||||
raw = p.read_bytes()
|
||||
sha = hashlib.sha256(raw).hexdigest()
|
||||
length = len(raw)
|
||||
# Coerce numeric inputs defensively (string/float -> int)
|
||||
start_line = _coerce_int(start_line)
|
||||
line_count = _coerce_int(line_count)
|
||||
head_bytes = _coerce_int(head_bytes, minimum=1)
|
||||
tail_lines = _coerce_int(tail_lines, minimum=1)
|
||||
|
||||
want_text = (
|
||||
bool(include_text)
|
||||
or (head_bytes is not None and head_bytes >= 0)
|
||||
or (tail_lines is not None and tail_lines > 0)
|
||||
or (start_line is not None and line_count is not None)
|
||||
)
|
||||
if want_text:
|
||||
text: str
|
||||
if head_bytes is not None and head_bytes >= 0:
|
||||
text = raw[: head_bytes].decode("utf-8", errors="replace")
|
||||
else:
|
||||
# Compute SHA over full file contents (metadata-only default)
|
||||
full_bytes = p.read_bytes()
|
||||
full_sha = hashlib.sha256(full_bytes).hexdigest()
|
||||
|
||||
# Selection only when explicitly requested via windowing args or request text hints
|
||||
selection_requested = bool(head_bytes or tail_lines or (start_line is not None and line_count is not None) or request)
|
||||
if selection_requested:
|
||||
# Mutually exclusive windowing options precedence:
|
||||
# 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text
|
||||
if head_bytes and head_bytes > 0:
|
||||
raw = full_bytes[: head_bytes]
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = full_bytes.decode("utf-8", errors="replace")
|
||||
if tail_lines is not None and tail_lines > 0:
|
||||
lines = text.splitlines()
|
||||
n = max(0, tail_lines)
|
||||
text = "\n".join(lines[-n:])
|
||||
elif (
|
||||
start_line is not None
|
||||
and line_count is not None
|
||||
and line_count >= 0
|
||||
):
|
||||
elif start_line is not None and line_count is not None and line_count >= 0:
|
||||
lines = text.splitlines()
|
||||
s = max(0, start_line - 1)
|
||||
e = min(len(lines), s + line_count)
|
||||
text = "\n".join(lines[s:e])
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"text": text, "metadata": {"sha256": sha}},
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"metadata": {"sha256": sha, "lengthBytes": length}},
|
||||
}
|
||||
return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}}
|
||||
else:
|
||||
# Default: metadata only
|
||||
return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("find_in_file")
|
||||
async def find_in_file(
|
||||
uri: str,
|
||||
pattern: str,
|
||||
ctx: Context | None = None,
|
||||
ignore_case: bool | None = True,
|
||||
project_root: str | None = None,
|
||||
max_results: int | None = 1,
|
||||
ctx: Optional[Context] = None,
|
||||
ignore_case: Optional[bool] = True,
|
||||
project_root: Optional[str] = None,
|
||||
max_results: Any = 200,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Searches a file with a regex pattern and returns match positions only.
|
||||
Searches a file with a regex pattern and returns line numbers and excerpts.
|
||||
- uri: unity://path/Assets/... or file path form supported by read_resource
|
||||
- pattern: regular expression (Python re)
|
||||
- ignore_case: case-insensitive by default
|
||||
|
|
@ -363,20 +385,20 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
rx = re.compile(pattern, flags)
|
||||
|
||||
results = []
|
||||
max_results_int = _coerce_int(max_results, default=200, minimum=1)
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
m = rx.search(line)
|
||||
if m:
|
||||
start_col, end_col = m.span()
|
||||
results.append(
|
||||
{
|
||||
"startLine": i,
|
||||
"startCol": start_col + 1,
|
||||
"endLine": i,
|
||||
"endCol": end_col + 1,
|
||||
}
|
||||
)
|
||||
if max_results and len(results) >= max_results:
|
||||
start_col = m.start() + 1 # 1-based
|
||||
end_col = m.end() + 1 # 1-based, end exclusive
|
||||
results.append({
|
||||
"startLine": i,
|
||||
"startCol": start_col,
|
||||
"endLine": i,
|
||||
"endCol": end_col,
|
||||
})
|
||||
if max_results_int and len(results) >= max_results_int:
|
||||
break
|
||||
|
||||
return {"success": True, "data": {"matches": results, "count": len(results)}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import os
|
||||
|
||||
# Ensure telemetry is disabled during test collection and execution to avoid
|
||||
# any background network or thread startup that could slow or block pytest.
|
||||
os.environ.setdefault("DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true")
|
||||
os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true")
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import os
|
||||
import importlib
|
||||
|
||||
def test_endpoint_rejects_non_http(tmp_path, monkeypatch):
|
||||
# Point data dir to temp to avoid touching real files
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd")
|
||||
|
||||
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
|
||||
tc = telemetry.TelemetryCollector()
|
||||
# Should have fallen back to default endpoint
|
||||
assert tc.config.endpoint == tc.config.default_endpoint
|
||||
|
||||
def test_config_preferred_then_env_override(tmp_path, monkeypatch):
|
||||
# Simulate config telemetry endpoint
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("UNITY_MCP_TELEMETRY_ENDPOINT", raising=False)
|
||||
|
||||
# Patch config.telemetry_endpoint via import mocking
|
||||
import importlib
|
||||
cfg_mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.config")
|
||||
old_endpoint = cfg_mod.config.telemetry_endpoint
|
||||
cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry"
|
||||
try:
|
||||
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
tc = telemetry.TelemetryCollector()
|
||||
assert tc.config.endpoint == "https://example.com/telemetry"
|
||||
|
||||
# Env should override config
|
||||
monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "https://override.example/ep")
|
||||
importlib.reload(telemetry)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2.config.endpoint == "https://override.example/ep"
|
||||
finally:
|
||||
cfg_mod.config.telemetry_endpoint = old_endpoint
|
||||
|
||||
def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
|
||||
telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry")
|
||||
importlib.reload(telemetry)
|
||||
|
||||
tc1 = telemetry.TelemetryCollector()
|
||||
first_uuid = tc1._customer_uuid
|
||||
|
||||
# Write malformed milestones
|
||||
tc1.config.milestones_file.write_text("{not-json}", encoding="utf-8")
|
||||
|
||||
# Reload collector; UUID should remain same despite bad milestones
|
||||
importlib.reload(telemetry)
|
||||
tc2 = telemetry.TelemetryCollector()
|
||||
assert tc2._customer_uuid == first_uuid
|
||||
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import sys
|
||||
import pathlib
|
||||
import importlib.util
|
||||
import types
|
||||
import threading
|
||||
import time
|
||||
import queue as q
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
# Stub mcp.server.fastmcp to satisfy imports without the full dependency
|
||||
mcp_pkg = types.ModuleType("mcp")
|
||||
server_pkg = types.ModuleType("mcp.server")
|
||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
||||
|
||||
class _Dummy:
|
||||
pass
|
||||
|
||||
fastmcp_pkg.FastMCP = _Dummy
|
||||
fastmcp_pkg.Context = _Dummy
|
||||
server_pkg.fastmcp = fastmcp_pkg
|
||||
mcp_pkg.server = server_pkg
|
||||
sys.modules.setdefault("mcp", mcp_pkg)
|
||||
sys.modules.setdefault("mcp.server", server_pkg)
|
||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
||||
|
||||
|
||||
def _load_module(path: pathlib.Path, name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
telemetry = _load_module(SRC / "telemetry.py", "telemetry_mod")
|
||||
|
||||
|
||||
def test_telemetry_queue_backpressure_and_single_worker(monkeypatch, caplog):
|
||||
caplog.set_level("DEBUG")
|
||||
|
||||
collector = telemetry.TelemetryCollector()
|
||||
# Force-enable telemetry regardless of env settings from conftest
|
||||
collector.config.enabled = True
|
||||
|
||||
# Wake existing worker once so it observes the new queue on the next loop
|
||||
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": -1})
|
||||
# Replace queue with tiny one to trigger backpressure quickly
|
||||
small_q = q.Queue(maxsize=2)
|
||||
collector._queue = small_q
|
||||
# Give the worker a moment to switch queues
|
||||
time.sleep(0.02)
|
||||
|
||||
# Make sends slow to build backlog and exercise worker
|
||||
def slow_send(self, rec):
|
||||
time.sleep(0.05)
|
||||
|
||||
collector._send_telemetry = types.MethodType(slow_send, collector)
|
||||
|
||||
# Fire many events quickly; record() should not block even when queue fills
|
||||
start = time.perf_counter()
|
||||
for i in range(50):
|
||||
collector.record(telemetry.RecordType.TOOL_EXECUTION, {"i": i})
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
||||
|
||||
# Should be fast despite backpressure (non-blocking enqueue or drop)
|
||||
assert elapsed_ms < 80.0
|
||||
|
||||
# Allow worker to process some
|
||||
time.sleep(0.3)
|
||||
|
||||
# Verify drops were logged (queue full backpressure)
|
||||
dropped_logs = [m for m in caplog.messages if "Telemetry queue full; dropping" in m]
|
||||
assert len(dropped_logs) >= 1
|
||||
|
||||
# Ensure only one worker thread exists and is alive
|
||||
assert collector._worker.is_alive()
|
||||
worker_threads = [t for t in threading.enumerate() if t is collector._worker]
|
||||
assert len(worker_threads) == 1
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import importlib
|
||||
|
||||
|
||||
def _get_decorator_module():
|
||||
# Import the telemetry_decorator module from the Unity MCP server src
|
||||
mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry_decorator")
|
||||
return mod
|
||||
|
||||
|
||||
def test_subaction_extracted_from_keyword(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["success"] = success
|
||||
captured["error"] = error
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
# Silence milestones/logging in test
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool(ctx, action: str, name: str = ""):
|
||||
return {"success": True, "name": name}
|
||||
|
||||
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
|
||||
|
||||
resp = wrapped(None, action="get_hierarchy", name="Sample")
|
||||
assert resp["success"] is True
|
||||
assert captured["tool_name"] == "manage_scene"
|
||||
assert captured["success"] is True
|
||||
assert captured["error"] is None
|
||||
assert captured["sub_action"] == "get_hierarchy"
|
||||
|
||||
|
||||
def test_subaction_extracted_from_positionals(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool(ctx, action: str, name: str = ""):
|
||||
return True
|
||||
|
||||
wrapped = td.telemetry_tool("manage_scene")(dummy_tool)
|
||||
|
||||
_ = wrapped(None, "save", "MyScene")
|
||||
assert captured["tool_name"] == "manage_scene"
|
||||
assert captured["sub_action"] == "save"
|
||||
|
||||
|
||||
def test_subaction_none_when_not_present(monkeypatch):
|
||||
td = _get_decorator_module()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_record_tool_usage(tool_name, success, duration_ms, error, sub_action=None):
|
||||
captured["tool_name"] = tool_name
|
||||
captured["sub_action"] = sub_action
|
||||
|
||||
monkeypatch.setattr(td, "record_tool_usage", fake_record_tool_usage)
|
||||
monkeypatch.setattr(td, "record_milestone", lambda *a, **k: None)
|
||||
monkeypatch.setattr(td, "_decorator_log_count", 999)
|
||||
|
||||
def dummy_tool_without_action(ctx, name: str):
|
||||
return 123
|
||||
|
||||
wrapped = td.telemetry_tool("apply_text_edits")(dummy_tool_without_action)
|
||||
_ = wrapped(None, name="X")
|
||||
assert captured["tool_name"] == "apply_text_edits"
|
||||
assert captured["sub_action"] is None
|
||||
|
||||
|
||||
Loading…
Reference in New Issue