Files
2026-07-13 12:49:17 +08:00

710 lines
31 KiB
C#

using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Runtime.Helpers;
using Microsoft.CSharp;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
[McpForUnityTool("execute_code", AutoRegister = false, Group = "scripting_ext")]
public static class ExecuteCode
{
private const int MaxCodeLength = 50000;
private const int MaxHistoryEntries = 50;
private const int MaxHistoryCodePreview = 500;
internal const int WrapperLineOffset = 10;
private const string WrapperClassName = "MCPDynamicCode";
private const string WrapperMethodName = "Execute";
private const string ActionExecute = "execute";
private const string ActionGetHistory = "get_history";
private const string ActionClearHistory = "clear_history";
private const string ActionReplay = "replay";
private static readonly List<HistoryEntry> _history = new List<HistoryEntry>();
private static string[] _cachedAssemblyPaths;
[UnityEditor.InitializeOnLoadMethod]
private static void OnDomainReload()
{
_cachedAssemblyPaths = null;
RoslynCompiler.ResetCache();
}
private static readonly HashSet<string> _blockedPatterns = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"System.IO.File.Delete",
"System.IO.Directory.Delete",
"FileUtil.DeleteFileOrDirectory",
"AssetDatabase.DeleteAsset",
"AssetDatabase.MoveAssetToTrash",
"EditorApplication.Exit",
"Process.Start",
"Process.Kill",
"while(true)",
"while (true)",
"for(;;)",
"for (;;)",
};
public static object HandleCommand(JObject @params)
{
if (@params == null)
return new ErrorResponse("Parameters cannot be null.");
var p = new ToolParams(@params);
var actionResult = p.GetRequired("action");
if (!actionResult.IsSuccess)
return new ErrorResponse(actionResult.ErrorMessage);
string action = actionResult.Value.ToLowerInvariant();
switch (action)
{
case ActionExecute:
return HandleExecute(@params);
case ActionGetHistory:
return HandleGetHistory(@params);
case ActionClearHistory:
return HandleClearHistory();
case ActionReplay:
return HandleReplay(@params);
default:
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions: {ActionExecute}, {ActionGetHistory}, {ActionClearHistory}, {ActionReplay}");
}
}
private static object HandleExecute(JObject @params)
{
string code = @params["code"]?.ToString();
if (string.IsNullOrWhiteSpace(code))
return new ErrorResponse("Required parameter 'code' is missing or empty.");
if (code.Length > MaxCodeLength)
return new ErrorResponse($"Code exceeds maximum length of {MaxCodeLength} characters.");
bool safetyChecks = @params["safety_checks"]?.Value<bool>() ?? true;
string compiler = @params["compiler"]?.ToString()?.ToLowerInvariant() ?? "auto";
if (safetyChecks)
{
var violation = CheckBlockedPatterns(code);
if (violation != null)
return new ErrorResponse($"Blocked pattern detected: {violation}");
}
try
{
var startTime = DateTime.UtcNow;
var result = CompileAndExecute(code, compiler);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
AddToHistory(code, result, elapsed, safetyChecks, compiler);
return result;
}
catch (Exception e)
{
McpLog.Error($"[ExecuteCode] Execution failed: {e}");
var errorResult = new ErrorResponse($"Execution failed: {e.Message}");
AddToHistory(code, errorResult, 0, safetyChecks, compiler);
return errorResult;
}
}
private static object HandleGetHistory(JObject @params)
{
int limit = @params["limit"]?.Value<int>() ?? 10;
limit = Math.Clamp(limit, 1, MaxHistoryEntries);
if (_history.Count == 0)
return new SuccessResponse("No execution history.", new { total = 0, entries = new object[0] });
var entries = _history.Skip(Math.Max(0, _history.Count - limit)).ToList();
return new SuccessResponse($"Returning {entries.Count} of {_history.Count} history entries.", new
{
total = _history.Count,
entries = entries.Select((e, i) => new
{
index = _history.Count - entries.Count + i,
codePreview = e.code.Length > MaxHistoryCodePreview
? e.code.Substring(0, MaxHistoryCodePreview) + "..."
: e.code,
e.success,
e.resultPreview,
e.elapsedMs,
e.timestamp,
e.safetyChecksEnabled,
e.compiler,
}).ToList(),
});
}
private static object HandleClearHistory()
{
int count = _history.Count;
_history.Clear();
return new SuccessResponse($"Cleared {count} history entries.");
}
private static object HandleReplay(JObject @params)
{
if (_history.Count == 0)
return new ErrorResponse("No execution history to replay.");
int? index = @params["index"]?.Value<int>();
if (index == null || index < 0 || index >= _history.Count)
return new ErrorResponse($"Invalid history index. Valid range: 0-{_history.Count - 1}");
var entry = _history[index.Value];
var replayParams = JObject.FromObject(new
{
action = ActionExecute,
code = entry.code,
safety_checks = entry.safetyChecksEnabled,
compiler = entry.compiler ?? "auto",
});
return HandleExecute(replayParams);
}
// ──────────────────── Compilation ────────────────────
private static object CompileAndExecute(string code, string compiler)
{
string wrappedSource = WrapUserCode(code);
string[] assemblyPaths = GetAssemblyPaths();
Assembly compiled;
string usedCompiler;
switch (compiler)
{
case "roslyn":
if (!RoslynCompiler.IsAvailable)
return new ErrorResponse("Roslyn (Microsoft.CodeAnalysis) is not available. Install it via NuGet or use compiler='codedom'.");
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var roslynErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(roslynErrors), compiler = "roslyn" });
usedCompiler = "roslyn";
break;
case "codedom":
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var codedomErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(codedomErrors), compiler = "codedom" });
usedCompiler = "codedom";
break;
default: // "auto"
if (RoslynCompiler.IsAvailable)
{
compiled = RoslynCompiler.Compile(wrappedSource, assemblyPaths, out var autoErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoErrors), compiler = "roslyn" });
usedCompiler = "roslyn";
}
else
{
compiled = CodeDomCompile(wrappedSource, assemblyPaths, out var autoFallbackErrors);
if (compiled == null)
return new ErrorResponse("Compilation failed", new { errors = OffsetErrors(autoFallbackErrors), compiler = "codedom" });
usedCompiler = "codedom";
}
break;
}
return InvokeCompiled(compiled, usedCompiler);
}
private static object InvokeCompiled(Assembly assembly, string compilerUsed)
{
var type = assembly.GetType(WrapperClassName);
if (type == null)
return new ErrorResponse("Internal error: failed to find compiled type.");
var method = type.GetMethod(WrapperMethodName, BindingFlags.Public | BindingFlags.Static);
if (method == null)
return new ErrorResponse("Internal error: failed to find Execute method.");
object result = null;
Exception executionError = null;
try
{
result = method.Invoke(null, null);
}
catch (TargetInvocationException tie)
{
executionError = tie.InnerException ?? tie;
}
catch (Exception e)
{
executionError = e;
}
if (executionError != null)
return new ErrorResponse($"Runtime error: {executionError.Message}",
new { exceptionType = executionError.GetType().Name, stackTrace = executionError.StackTrace, compiler = compilerUsed });
if (result != null)
return new SuccessResponse("Code executed successfully.",
new { result = SerializeResult(result), compiler = compilerUsed });
return new SuccessResponse("Code executed successfully.", new { compiler = compilerUsed });
}
private static List<string> OffsetErrors(List<string> errors)
{
// Errors already have line numbers adjusted by the compiler-specific code
return errors;
}
// ──────────────────── CodeDom compiler ────────────────────
private static Assembly CodeDomCompile(string source, string[] assemblyPaths, out List<string> errors)
{
errors = new List<string>();
// CodeDom needs the netstandard-aware filtered paths
var filtered = FilterAssemblyPathsForCodeDom(assemblyPaths);
// CSharpCodeProvider turns every ReferencedAssemblies entry into a literal /r:"..." flag
// on the csc command line. Projects with ~100+ asmdefs blow past Windows' 32 KB
// CreateProcess argument limit and fail with "The filename or extension is too long."
// Route references through a response file (@responsefile is supported by both mcs and
// Roslyn csc) so we pass exactly one short argument regardless of reference count.
string responseFilePath = Path.Combine(Path.GetTempPath(), $"mcp-codedom-{Guid.NewGuid():N}.rsp");
try
{
using (var writer = new StreamWriter(responseFilePath, append: false, Encoding.UTF8))
{
foreach (var path in filtered)
{
writer.Write("/r:\"");
writer.Write(path);
writer.WriteLine("\"");
}
}
using (var provider = new CSharpCodeProvider())
{
var parameters = new CompilerParameters
{
GenerateInMemory = true,
GenerateExecutable = false,
TreatWarningsAsErrors = false,
CompilerOptions = "@\"" + responseFilePath + "\"",
};
var results = provider.CompileAssemblyFromSource(parameters, source);
if (results.Errors.HasErrors)
{
foreach (CompilerError error in results.Errors)
{
if (!error.IsWarning)
{
int userLine = Math.Max(1, error.Line - WrapperLineOffset);
errors.Add($"Line {userLine}: {error.ErrorText}");
}
}
return null;
}
return results.CompiledAssembly;
}
}
finally
{
try { if (File.Exists(responseFilePath)) File.Delete(responseFilePath); }
catch { /* best effort */ }
}
}
// CSharpCodeProvider can't resolve type-forwarding, so when netstandard.dll is loaded
// alongside mscorlib/System.Runtime/System.Collections, types like List<T> appear in
// multiple assemblies causing "type defined multiple times" errors.
private static readonly HashSet<string> _codedomDuplicateAssemblies = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"mscorlib",
"System.Runtime",
"System.Private.CoreLib",
"System.Collections",
};
private static string[] FilterAssemblyPathsForCodeDom(string[] allPaths)
{
bool hasNetstandard = allPaths.Any(p =>
string.Equals(Path.GetFileNameWithoutExtension(p), "netstandard", StringComparison.OrdinalIgnoreCase));
if (!hasNetstandard)
return allPaths;
return allPaths.Where(p =>
!_codedomDuplicateAssemblies.Contains(Path.GetFileNameWithoutExtension(p))).ToArray();
}
// ──────────────────── Shared helpers ────────────────────
private static string WrapUserCode(string code)
{
var sb = new StringBuilder();
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using System.Reflection;");
sb.AppendLine("using UnityEngine;");
sb.AppendLine("using UnityEditor;");
sb.AppendLine($"public static class {WrapperClassName}");
sb.AppendLine("{");
sb.AppendLine($" public static object {WrapperMethodName}()");
sb.AppendLine(" {");
sb.AppendLine(code);
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string[] GetAssemblyPaths()
{
if (_cachedAssemblyPaths == null)
_cachedAssemblyPaths = ResolveAssemblyPaths();
return _cachedAssemblyPaths;
}
private static string[] ResolveAssemblyPaths()
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in UnityAssembliesCompat.GetLoadedAssemblies())
{
try
{
if (assembly.IsDynamic) continue;
var location = assembly.Location;
if (string.IsNullOrEmpty(location)) continue;
if (!File.Exists(location)) continue;
paths.Add(location);
}
catch (NotSupportedException)
{
// Some assemblies don't support Location property
}
}
var result = new string[paths.Count];
paths.CopyTo(result);
return result;
}
private static string CheckBlockedPatterns(string code)
{
foreach (var pattern in _blockedPatterns)
{
if (code.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
return $"Code contains blocked pattern: '{pattern}'. Disable safety checks with safety_checks=false if this is intentional.";
}
return null;
}
private static void AddToHistory(string code, object result, double elapsedMs, bool safetyChecks, string compiler = "auto")
{
string preview;
if (result is SuccessResponse sr)
preview = sr.Data?.ToString() ?? sr.Message;
else if (result is ErrorResponse er)
preview = er.Error;
else
preview = result?.ToString() ?? "null";
if (preview != null && preview.Length > 200)
preview = preview.Substring(0, 200) + "...";
_history.Add(new HistoryEntry
{
code = code,
success = result is SuccessResponse,
resultPreview = preview,
elapsedMs = Math.Round(elapsedMs, 1),
timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
safetyChecksEnabled = safetyChecks,
compiler = compiler,
});
while (_history.Count > MaxHistoryEntries)
_history.RemoveAt(0);
}
private static object SerializeResult(object result)
{
if (result == null) return null;
var type = result.GetType();
if (type.IsPrimitive || result is string || result is decimal)
return result;
try
{
return JToken.FromObject(result);
}
catch
{
return result.ToString();
}
}
private class HistoryEntry
{
public string code;
public bool success;
public string resultPreview;
public double elapsedMs;
public string timestamp;
public bool safetyChecksEnabled;
public string compiler;
}
}
/// <summary>
/// Roslyn compiler backend accessed entirely via reflection.
/// No compile-time dependency on Microsoft.CodeAnalysis — works only if the package is installed.
/// </summary>
internal static class RoslynCompiler
{
private static bool? _isAvailable;
private static Type _syntaxTreeType;
private static Type _compilationType;
private static Type _compilationOptionsType;
private static Type _parseOptionsType;
private static Type _metadataReferenceType;
private static Type _outputKindEnum;
private static Type _languageVersionEnum;
private static MethodInfo _parseText;
private static MethodInfo _createCompilation;
private static MethodInfo _createFromFile;
private static MethodInfo _emit;
private static object _parseOptions;
private static object _compilationOptions;
public static bool IsAvailable
{
get
{
if (_isAvailable == null)
_isAvailable = Initialize();
return _isAvailable.Value;
}
}
public static void ResetCache()
{
_isAvailable = null;
}
private static bool Initialize()
{
try
{
_syntaxTreeType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree, Microsoft.CodeAnalysis.CSharp");
_compilationType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilation, Microsoft.CodeAnalysis.CSharp");
_compilationOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions, Microsoft.CodeAnalysis.CSharp");
_parseOptionsType = Type.GetType("Microsoft.CodeAnalysis.CSharp.CSharpParseOptions, Microsoft.CodeAnalysis.CSharp");
_metadataReferenceType = Type.GetType("Microsoft.CodeAnalysis.MetadataReference, Microsoft.CodeAnalysis");
_outputKindEnum = Type.GetType("Microsoft.CodeAnalysis.OutputKind, Microsoft.CodeAnalysis");
_languageVersionEnum = Type.GetType("Microsoft.CodeAnalysis.CSharp.LanguageVersion, Microsoft.CodeAnalysis.CSharp");
if (_syntaxTreeType == null || _compilationType == null || _compilationOptionsType == null ||
_parseOptionsType == null || _metadataReferenceType == null || _outputKindEnum == null ||
_languageVersionEnum == null)
return false;
// CSharpSyntaxTree.ParseText(string, CSharpParseOptions, string, Encoding, CancellationToken)
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
_parseText = _syntaxTreeType.GetMethod("ParseText", new[] { typeof(string), _parseOptionsType, typeof(string), typeof(Encoding), typeof(System.Threading.CancellationToken) });
if (_parseText == null)
return false;
// CSharpCompilation.Create(string, IEnumerable<SyntaxTree>, IEnumerable<MetadataReference>, CSharpCompilationOptions)
var metadataRefBase = _metadataReferenceType;
var syntaxTreeEnumerable = typeof(IEnumerable<>).MakeGenericType(syntaxTreeBase);
var metadataRefEnumerable = typeof(IEnumerable<>).MakeGenericType(metadataRefBase);
_createCompilation = _compilationType.GetMethod("Create", new[] { typeof(string), syntaxTreeEnumerable, metadataRefEnumerable, _compilationOptionsType });
if (_createCompilation == null)
return false;
// MetadataReference.CreateFromFile(string, MetadataReferenceProperties, DocumentationProvider)
_createFromFile = _metadataReferenceType.GetMethods(BindingFlags.Public | BindingFlags.Static)
.FirstOrDefault(m => m.Name == "CreateFromFile");
if (_createFromFile == null)
return false;
// Emit has no single-param overload; the simplest is
// Emit(Stream, Stream, Stream, Stream, IEnumerable<ResourceDescription>, EmitOptions, CancellationToken)
var compilationBase = Type.GetType("Microsoft.CodeAnalysis.Compilation, Microsoft.CodeAnalysis");
if (compilationBase == null) return false;
_emit = compilationBase.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(m => m.Name == "Emit")
.OrderBy(m => m.GetParameters().Length)
.FirstOrDefault();
if (_emit == null)
return false;
// Build CSharpParseOptions — constructor has optional params, use reflection
var latestValue = Enum.Parse(_languageVersionEnum, "Latest");
var parseOptionsCtor = _parseOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
var parseCtorParams = parseOptionsCtor.GetParameters();
var parseArgs = new object[parseCtorParams.Length];
for (int i = 0; i < parseCtorParams.Length; i++)
{
if (parseCtorParams[i].Name == "languageVersion")
parseArgs[i] = latestValue;
else if (parseCtorParams[i].HasDefaultValue)
parseArgs[i] = parseCtorParams[i].DefaultValue;
else
parseArgs[i] = null;
}
_parseOptions = parseOptionsCtor.Invoke(parseArgs);
// Build CSharpCompilationOptions — use the first constructor (has most defaults)
var dllKind = Enum.Parse(_outputKindEnum, "DynamicallyLinkedLibrary");
var compOptionsCtor = _compilationOptionsType.GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0];
var compCtorParams = compOptionsCtor.GetParameters();
var compArgs = new object[compCtorParams.Length];
for (int i = 0; i < compCtorParams.Length; i++)
{
if (compCtorParams[i].Name == "outputKind")
compArgs[i] = dllKind;
else if (compCtorParams[i].HasDefaultValue)
compArgs[i] = compCtorParams[i].DefaultValue;
else
compArgs[i] = null;
}
_compilationOptions = compOptionsCtor.Invoke(compArgs);
return true;
}
catch (Exception e)
{
McpLog.Warn($"[ExecuteCode] Roslyn initialization failed: {e.Message}");
return false;
}
}
public static Assembly Compile(string source, string[] assemblyPaths, out List<string> errors)
{
errors = new List<string>();
try
{
// Parse source
var syntaxTree = _parseText.Invoke(null, new object[] { source, _parseOptions, null, null, default(System.Threading.CancellationToken) });
// Build metadata references
var metadataRefBase = _metadataReferenceType;
var listType = typeof(List<>).MakeGenericType(metadataRefBase);
var refs = (System.Collections.IList)Activator.CreateInstance(listType);
foreach (var path in assemblyPaths)
{
try
{
var cfParams = _createFromFile.GetParameters();
var cfArgs = new object[cfParams.Length];
cfArgs[0] = path; // string path
for (int i = 1; i < cfParams.Length; i++)
cfArgs[i] = cfParams[i].HasDefaultValue ? cfParams[i].DefaultValue : null;
var metaRef = _createFromFile.Invoke(null, cfArgs);
refs.Add(metaRef);
}
catch
{
// Skip assemblies that can't be loaded as metadata
}
}
// Build syntax tree array
var syntaxTreeBase = Type.GetType("Microsoft.CodeAnalysis.SyntaxTree, Microsoft.CodeAnalysis");
var treeArray = Array.CreateInstance(syntaxTreeBase, 1);
treeArray.SetValue(syntaxTree, 0);
// Create compilation
var compilation = _createCompilation.Invoke(null, new object[] { "MCPDynamic", treeArray, refs, _compilationOptions });
// Emit to memory
using (var ms = new MemoryStream())
{
// Build args for the Emit overload (fill non-stream params with defaults)
var emitParams = _emit.GetParameters();
var emitArgs = new object[emitParams.Length];
emitArgs[0] = ms; // peStream
for (int i = 1; i < emitParams.Length; i++)
{
if (emitParams[i].HasDefaultValue)
emitArgs[i] = emitParams[i].DefaultValue;
else
emitArgs[i] = null;
}
var emitResult = _emit.Invoke(compilation, emitArgs);
// Check emitResult.Success
var successProp = emitResult.GetType().GetProperty("Success");
bool success = (bool)successProp.GetValue(emitResult);
if (!success)
{
// Read emitResult.Diagnostics
var diagProp = emitResult.GetType().GetProperty("Diagnostics");
var diagnostics = (System.Collections.IEnumerable)diagProp.GetValue(emitResult);
var severityError = Enum.Parse(Type.GetType("Microsoft.CodeAnalysis.DiagnosticSeverity, Microsoft.CodeAnalysis"), "Error");
foreach (var diag in diagnostics)
{
var sevProp = diag.GetType().GetProperty("Severity");
var severity = sevProp.GetValue(diag);
if (!severity.Equals(severityError)) continue;
var locProp = diag.GetType().GetProperty("Location");
var loc = locProp.GetValue(diag);
var spanProp = loc.GetType().GetMethod("GetLineSpan");
var lineSpan = spanProp.Invoke(loc, null);
var startProp = lineSpan.GetType().GetProperty("StartLinePosition");
var startPos = startProp.GetValue(lineSpan);
var lineProp = startPos.GetType().GetProperty("Line");
int line = (int)lineProp.GetValue(startPos);
var msgProp = diag.GetType().GetMethod("GetMessage", new[] { typeof(System.Globalization.CultureInfo) });
string msg = (string)msgProp.Invoke(diag, new object[] { null });
int userLine = Math.Max(1, line + 1 - ExecuteCode.WrapperLineOffset);
errors.Add($"Line {userLine}: {msg}");
}
return null;
}
ms.Seek(0, SeekOrigin.Begin);
return Assembly.Load(ms.ToArray());
}
}
catch (Exception e)
{
// Walk to the deepest cause: TargetInvocationException (and friends) wrap the real
// failure inside .InnerException, and reporting only e.Message hides everything
// useful (e.g. a missing transitive dep manifests as the generic "Exception has been
// thrown by the target of an invocation.").
Exception root = e;
while (root.InnerException != null) root = root.InnerException;
errors.Add($"Roslyn compilation error: {root.GetType().Name}: {root.Message}");
return null;
}
}
}
}