chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Current client context for JSON-RPC processing
|
||||
/// </summary>
|
||||
public class ClientExecutionContext
|
||||
{
|
||||
public string Endpoint { get; }
|
||||
|
||||
public ClientExecutionContext(string endpoint)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class specialized in handling JSON-RPC 2.0 processing
|
||||
///
|
||||
/// Design document reference: Packages/src/Editor/ARCHITECTURE.md
|
||||
///
|
||||
/// Related classes:
|
||||
/// - UnityApiHandler: Executes Unity commands based on JSON-RPC requests
|
||||
/// - McpBridgeServer: TCP server that receives JSON-RPC messages from TypeScript
|
||||
/// - MainThreadSwitcher: Ensures Unity API calls run on the main thread
|
||||
/// - JsonRpcRequest: Request model for JSON-RPC 2.0 protocol
|
||||
/// - ClientExecutionContext: Thread-local context for tracking current client
|
||||
///
|
||||
/// Processing flow:
|
||||
/// 1. Receives JSON message from McpBridgeServer
|
||||
/// 2. Parses and validates JSON-RPC 2.0 format
|
||||
/// 3. Sets client context for the current thread
|
||||
/// 4. Delegates to UnityApiHandler for command execution
|
||||
/// 5. Formats response according to JSON-RPC 2.0 specification
|
||||
/// 6. Returns JSON response to be sent back to client
|
||||
/// </summary>
|
||||
public static class JsonRpcProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Current client context for async operations
|
||||
/// </summary>
|
||||
private static readonly AsyncLocal<ClientExecutionContext> _currentClientContext = new();
|
||||
|
||||
/// <summary>
|
||||
/// Get current client context (ProcessID and Endpoint)
|
||||
/// </summary>
|
||||
public static ClientExecutionContext CurrentClientContext => _currentClientContext.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Process JSON-RPC request and generate response with client context
|
||||
/// </summary>
|
||||
public static async Task<string> ProcessRequest(string jsonRequest, string clientEndpoint)
|
||||
{
|
||||
var context = new ClientExecutionContext(clientEndpoint);
|
||||
_currentClientContext.Value = context;
|
||||
|
||||
try
|
||||
{
|
||||
return await ProcessRequest(jsonRequest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_currentClientContext.Value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process JSON-RPC request and generate response
|
||||
/// </summary>
|
||||
public static async Task<string> ProcessRequest(string jsonRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonRpcRequest request = ParseRequest(jsonRequest);
|
||||
|
||||
if (request.IsNotification)
|
||||
{
|
||||
ProcessNotification(request);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ProcessRpcRequest(request, jsonRequest);
|
||||
}
|
||||
catch (JsonReaderException ex)
|
||||
{
|
||||
return CreateErrorResponse(null, ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateErrorResponse(null, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse JSON-RPC request string into structured object
|
||||
/// </summary>
|
||||
private static JsonRpcRequest ParseRequest(string jsonRequest)
|
||||
{
|
||||
JObject request = JObject.Parse(jsonRequest);
|
||||
return new JsonRpcRequest
|
||||
{
|
||||
Method = request["method"]?.ToString(),
|
||||
Params = request["params"],
|
||||
Id = request["id"]?.ToObject<object>(),
|
||||
UloopMetadata = ParseUloopMetadata(request["x-uloop"])
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonRpcRequestUloopMetadata ParseUloopMetadata(JToken metadataToken)
|
||||
{
|
||||
if (metadataToken == null || metadataToken.Type == JTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new JsonRpcRequestUloopMetadata
|
||||
{
|
||||
ExpectedProjectRoot = metadataToken["expectedProjectRoot"]?.ToString(),
|
||||
ExpectedServerSessionId = metadataToken["expectedServerSessionId"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process notification (fire-and-forget)
|
||||
///
|
||||
/// Note: Notifications are one-way messages without response.
|
||||
/// Currently handles:
|
||||
/// - focus-window: Brings Unity Editor window to foreground (used after request timeout)
|
||||
/// </summary>
|
||||
private static void ProcessNotification(JsonRpcRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Method))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (request.Method)
|
||||
{
|
||||
case "focus-window":
|
||||
HandleFocusWindowNotification();
|
||||
break;
|
||||
default:
|
||||
// Unknown notification - ignore silently
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle focus-window notification.
|
||||
/// Note: focus-window is now handled at OS level by TypeScript MCP server using launch-unity package.
|
||||
/// This notification handler remains for protocol compatibility but does nothing.
|
||||
/// </summary>
|
||||
private static void HandleFocusWindowNotification()
|
||||
{
|
||||
// Intentionally empty - focus-window is handled by TypeScript MCP server at OS level
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process RPC request and return response JSON
|
||||
/// </summary>
|
||||
private static async Task<string> ProcessRpcRequest(JsonRpcRequest request, string originalJson)
|
||||
{
|
||||
Stopwatch requestStopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
ValidateClientIdentityIfNeeded(request);
|
||||
|
||||
Stopwatch mainThreadWaitStopwatch = Stopwatch.StartNew();
|
||||
await MainThreadSwitcher.SwitchToMainThread();
|
||||
mainThreadWaitStopwatch.Stop();
|
||||
|
||||
Stopwatch toolStopwatch = Stopwatch.StartNew();
|
||||
BaseToolResponse result = await ExecuteMethod(request.Method, request.Params);
|
||||
toolStopwatch.Stop();
|
||||
|
||||
AppendExecuteDynamicCodeTimingsIfSupported(
|
||||
result,
|
||||
mainThreadWaitStopwatch.Elapsed.TotalMilliseconds,
|
||||
toolStopwatch.Elapsed.TotalMilliseconds,
|
||||
requestStopwatch.Elapsed.TotalMilliseconds);
|
||||
string response = CreateSuccessResponse(request.Id, result);
|
||||
return response;
|
||||
}
|
||||
catch (JsonSerializationException ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"[JsonRpcProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}");
|
||||
return CreateErrorResponse(request.Id, ex);
|
||||
}
|
||||
catch (ParameterValidationException ex)
|
||||
{
|
||||
LogParameterValidationException(ex);
|
||||
return CreateErrorResponse(request.Id, ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"[JsonRpcProcessor] Error: {ex.Message}\nStack trace: {ex.StackTrace}");
|
||||
return CreateErrorResponse(request.Id, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateClientIdentityIfNeeded(JsonRpcRequest request)
|
||||
{
|
||||
JsonRpcRequestIdentityValidator.Validate(
|
||||
request?.UloopMetadata,
|
||||
McpEditorSettings.GetProjectRootPath(),
|
||||
McpEditorSettings.GetServerSessionId());
|
||||
}
|
||||
|
||||
private static void LogParameterValidationException(ParameterValidationException exception)
|
||||
{
|
||||
if (JsonRpcRequestIdentityValidator.IsExpectedRetryableFailure(exception))
|
||||
{
|
||||
#if ULOOPMCP_DEBUG
|
||||
UnityEngine.Debug.LogWarning(
|
||||
$"[JsonRpcProcessor] Expected retryable parameter validation event: {exception.Message}");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
UnityEngine.Debug.LogError(
|
||||
$"[JsonRpcProcessor] Parameter validation error: {exception.Message}\nStack trace: {exception.StackTrace}");
|
||||
}
|
||||
|
||||
private static void AppendExecuteDynamicCodeTimingsIfSupported(
|
||||
BaseToolResponse result,
|
||||
double mainThreadWaitMilliseconds,
|
||||
double toolTotalMilliseconds,
|
||||
double requestTotalMilliseconds)
|
||||
{
|
||||
if (result is not ExecuteDynamicCodeResponse executeDynamicCodeResponse)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteDynamicCodeResponseTimingAugmenter.AppendTimingEntries(
|
||||
executeDynamicCodeResponse,
|
||||
mainThreadWaitMilliseconds,
|
||||
toolTotalMilliseconds,
|
||||
requestTotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create JSON-RPC success response
|
||||
/// </summary>
|
||||
/// <param name="id">Request ID - must be same type as received (string/number/null per JSON-RPC spec)</param>
|
||||
/// <param name="result">Command execution result</param>
|
||||
private static string CreateSuccessResponse(object id, BaseToolResponse result)
|
||||
{
|
||||
JsonSerializerSettings settings = new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
MaxDepth = McpServerConfig.DEFAULT_JSON_MAX_DEPTH
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
JsonRpcSuccessResponse response = new JsonRpcSuccessResponse(
|
||||
McpServerConfig.JSONRPC_VERSION,
|
||||
id,
|
||||
result
|
||||
);
|
||||
return JsonConvert.SerializeObject(response, Formatting.None, settings);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Return safe fallback response for any serialization errors
|
||||
object fallbackResult = new
|
||||
{
|
||||
error = "Serialization failed - returning safe fallback",
|
||||
commandType = result != null ? result.GetType().Name : "unknown"
|
||||
};
|
||||
|
||||
JsonRpcSuccessResponse fallbackResponse = new JsonRpcSuccessResponse(
|
||||
McpServerConfig.JSONRPC_VERSION,
|
||||
id,
|
||||
fallbackResult
|
||||
);
|
||||
return JsonConvert.SerializeObject(fallbackResponse, Formatting.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create JSON-RPC error response
|
||||
/// </summary>
|
||||
/// <param name="id">Request ID - must be same type as received (string/number/null per JSON-RPC spec)</param>
|
||||
/// <param name="ex">Exception to convert to error response</param>
|
||||
private static string CreateErrorResponse(object id, Exception ex)
|
||||
{
|
||||
// Centralize exception -> user-facing message via UserFriendlyErrorConverter
|
||||
UserFriendlyErrorConverter handler = new UserFriendlyErrorConverter();
|
||||
UserFriendlyErrorDto exceptionResponse = handler.ProcessException(ex);
|
||||
|
||||
// Map UserFriendlyErrorDto to JsonRpcError
|
||||
string errorMessage = exceptionResponse.FriendlyMessage;
|
||||
|
||||
JsonRpcErrorData errorData;
|
||||
if (ex is McpSecurityException secEx)
|
||||
{
|
||||
errorData = new SecurityBlockedErrorData(secEx.ToolName, secEx.SecurityReason, exceptionResponse.Explanation ?? ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message);
|
||||
}
|
||||
|
||||
JsonRpcErrorResponse errorResponse = new JsonRpcErrorResponse(
|
||||
McpServerConfig.JSONRPC_VERSION,
|
||||
id,
|
||||
new JsonRpcError(McpServerConfig.INTERNAL_ERROR_CODE, errorMessage, errorData)
|
||||
);
|
||||
|
||||
JsonSerializerSettings settings = new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
MaxDepth = McpServerConfig.DEFAULT_JSON_MAX_DEPTH
|
||||
};
|
||||
|
||||
return JsonConvert.SerializeObject(errorResponse, Formatting.None, settings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute appropriate handler according to method name
|
||||
/// Use new command-based structure
|
||||
/// </summary>
|
||||
private static async Task<BaseToolResponse> ExecuteMethod(string method, JToken paramsToken)
|
||||
{
|
||||
return await UnityApiHandler.ExecuteCommandAsync(method, paramsToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for JSON-RPC error types
|
||||
/// </summary>
|
||||
public static class JsonRpcErrorTypes
|
||||
{
|
||||
public const string SecurityBlocked = "security_blocked";
|
||||
public const string InternalError = "internal_error";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base class for JSON-RPC error data
|
||||
/// </summary>
|
||||
public abstract class JsonRpcErrorData
|
||||
{
|
||||
public abstract string type { get; }
|
||||
|
||||
public string message { get; protected set; }
|
||||
|
||||
protected JsonRpcErrorData(string message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error data for security blocked commands
|
||||
/// </summary>
|
||||
public class SecurityBlockedErrorData : JsonRpcErrorData
|
||||
{
|
||||
public override string type => JsonRpcErrorTypes.SecurityBlocked;
|
||||
|
||||
public string command { get; }
|
||||
|
||||
public string reason { get; }
|
||||
|
||||
public SecurityBlockedErrorData(string command, string reason, string message) : base(message)
|
||||
{
|
||||
this.command = command;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error data for internal errors
|
||||
/// </summary>
|
||||
public class InternalErrorData : JsonRpcErrorData
|
||||
{
|
||||
public override string type => JsonRpcErrorTypes.InternalError;
|
||||
|
||||
public InternalErrorData(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON-RPC error object
|
||||
/// </summary>
|
||||
public class JsonRpcError
|
||||
{
|
||||
public int code { get; }
|
||||
|
||||
public string message { get; }
|
||||
|
||||
public JsonRpcErrorData data { get; }
|
||||
|
||||
public JsonRpcError(int code, string message, JsonRpcErrorData data)
|
||||
{
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON-RPC success response
|
||||
/// </summary>
|
||||
public class JsonRpcSuccessResponse
|
||||
{
|
||||
public string jsonrpc { get; }
|
||||
|
||||
public object id { get; }
|
||||
|
||||
public object result { get; }
|
||||
|
||||
public JsonRpcSuccessResponse(string jsonRpc, object id, object result)
|
||||
{
|
||||
this.jsonrpc = jsonRpc;
|
||||
this.id = id;
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON-RPC error response
|
||||
/// </summary>
|
||||
public class JsonRpcErrorResponse
|
||||
{
|
||||
public string jsonrpc { get; }
|
||||
|
||||
public object id { get; }
|
||||
|
||||
public JsonRpcError error { get; }
|
||||
|
||||
public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error)
|
||||
{
|
||||
this.jsonrpc = jsonRpc;
|
||||
this.id = id;
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user