From c18ee94da8b1e067fc14dd49d83c416d885a999f Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:20:28 +0100 Subject: [PATCH 1/3] feat: add execute_code tool for running arbitrary C# in Unity Editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a built-in `execute_code` tool that compiles and runs C# code inside the Unity Editor via CSharpCodeProvider. No external dependencies (Roslyn not required), no script files created. ## Actions - `execute` — compile and run C# method body, return result - `get_history` — list past executions with previews - `replay` — re-run a history entry with original settings - `clear_history` — clear execution history ## Safety - `safety_checks` (default: true) blocks known dangerous patterns (File.Delete, Process.Start, AssetDatabase.DeleteAsset, infinite loops) - Clearly documented as pattern-based blocklist, NOT a security sandbox - `destructiveHint=True` annotation for MCP clients ## Features - In-memory compilation with all loaded assembly references - User-friendly error line numbers (wrapper offset subtracted) - Execution history (max 50 entries) with code preview truncation - Replay preserves original safety_checks setting - CLI commands: `code execute`, `code history`, `code replay`, `code clear-history` ## Files - C#: `MCPForUnity/Editor/Tools/ExecuteCode.cs` (329 lines) - Python: `Server/src/services/tools/execute_code.py` (85 lines) - CLI: `Server/src/cli/commands/code.py` (+89 lines) - Tests: `Server/tests/test_execute_code.py` (17 tests, all passing) - Manifest: added `execute_code` entry Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ExecuteCode.cs | 337 +++++++++++++++++++ MCPForUnity/Editor/Tools/ExecuteCode.cs.meta | 2 + Server/src/cli/commands/code.py | 91 ++++- Server/src/services/tools/execute_code.py | 92 +++++ Server/tests/test_execute_code.py | 164 +++++++++ manifest.json | 4 + 6 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/ExecuteCode.cs create mode 100644 MCPForUnity/Editor/Tools/ExecuteCode.cs.meta create mode 100644 Server/src/services/tools/execute_code.py create mode 100644 Server/tests/test_execute_code.py diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs b/MCPForUnity/Editor/Tools/ExecuteCode.cs new file mode 100644 index 000000000..5d2dcb9ae --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs @@ -0,0 +1,337 @@ +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using MCPForUnity.Editor.Helpers; +using Microsoft.CSharp; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("execute_code", AutoRegister = false)] + public static class ExecuteCode + { + private const int MaxCodeLength = 50000; + private const int MaxHistoryEntries = 50; + private const int MaxHistoryCodePreview = 500; + private const int WrapperLineOffset = 9; + 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 _history = new List(); + + private static readonly HashSet _blockedPatterns = new HashSet(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() ?? true; + + 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); + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + + AddToHistory(code, result, elapsed, safetyChecks); + return result; + } + catch (Exception e) + { + McpLog.Error($"[ExecuteCode] Execution failed: {e}"); + var errorResult = new ErrorResponse($"Execution failed: {e.Message}"); + AddToHistory(code, errorResult, 0, safetyChecks); + return errorResult; + } + } + + private static object HandleGetHistory(JObject @params) + { + int limit = @params["limit"]?.Value() ?? 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, + }).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(); + 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, + }); + return HandleExecute(replayParams); + } + + private static object CompileAndExecute(string code) + { + string wrappedSource = WrapUserCode(code); + + using (var provider = new CSharpCodeProvider()) + { + var parameters = new CompilerParameters + { + GenerateInMemory = true, + GenerateExecutable = false, + TreatWarningsAsErrors = false, + }; + + AddReferences(parameters); + + var results = provider.CompileAssemblyFromSource(parameters, wrappedSource); + + if (results.Errors.HasErrors) + { + var errors = new List(); + foreach (CompilerError error in results.Errors) + { + if (!error.IsWarning) + { + int userLine = Math.Max(1, error.Line - WrapperLineOffset); + errors.Add($"Line {userLine}: {error.ErrorText}"); + } + } + return new ErrorResponse("Compilation failed", new { errors }); + } + + var assembly = results.CompiledAssembly; + 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 }); + + if (result != null) + return new SuccessResponse("Code executed successfully.", new { result = SerializeResult(result) }); + + return new SuccessResponse("Code executed successfully."); + } + } + + 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 void AddReferences(CompilerParameters parameters) + { + var referencedAssemblies = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + if (assembly.IsDynamic) continue; + var location = assembly.Location; + if (string.IsNullOrEmpty(location)) continue; + if (!System.IO.File.Exists(location)) continue; + if (referencedAssemblies.Add(location)) + parameters.ReferencedAssemblies.Add(location); + } + catch (NotSupportedException) + { + // Some assemblies don't support Location property + } + } + } + + 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 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, + }); + + 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; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs.meta b/MCPForUnity/Editor/Tools/ExecuteCode.cs.meta new file mode 100644 index 000000000..b5d099adc --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ebfb8c53994b4750ad9c4f284e7126e \ No newline at end of file diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index bb9b892d8..5d46d08a3 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -1,4 +1,4 @@ -"""Code CLI commands - read source code. search might be implemented later (but can be totally achievable with AI).""" +"""Code CLI commands - read, search, and execute source code.""" import sys import os @@ -6,16 +6,101 @@ from typing import Optional, Any from cli.utils.config import get_config -from cli.utils.output import format_output, print_error, print_info +from cli.utils.output import format_output, print_error, print_info, print_success from cli.utils.connection import run_command, handle_unity_errors @click.group() def code(): - """Code operations - read source files.""" + """Code operations - read, search, and execute.""" pass +@code.command("execute") +@click.argument("source", required=False) +@click.option("--file", "-f", default=None, type=click.Path(exists=True), help="Read code from a file instead of argument.") +@click.option("--no-safety-checks", is_flag=True, help="Disable blocked-pattern checks (allows File.Delete, Process.Start, etc).") +@handle_unity_errors +def execute(source: Optional[str], file: Optional[str], no_safety_checks: bool): + """Execute C# code in Unity Editor. + + Code runs as a method body with access to UnityEngine and UnityEditor. + Use 'return' to send data back. + + \b + Examples: + unity-mcp code execute "return Application.unityVersion;" + unity-mcp code execute "Debug.Log(Camera.main.name);" + unity-mcp code execute -f my_script.cs + """ + config = get_config() + + if file: + with open(file, "r") as f: + source = f.read() + elif not source: + print_error("Provide code as an argument or use --file.") + sys.exit(1) + + params: dict[str, Any] = { + "action": "execute", + "code": source, + "safety_checks": not no_safety_checks, + } + + result = run_command("execute_code", params, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + data = result.get("data", {}) + if data and data.get("result") is not None: + print_success(f"Result: {data['result']}") + + +@code.command("history") +@click.option("--limit", "-n", default=10, type=int, help="Number of entries to show (default: 10).") +@handle_unity_errors +def history(limit: int): + """Show execution history. + + \b + Examples: + unity-mcp code history + unity-mcp code history --limit 5 + """ + config = get_config() + result = run_command("execute_code", {"action": "get_history", "limit": limit}, config) + click.echo(format_output(result, config.format)) + + +@code.command("replay") +@click.argument("index", type=int) +@handle_unity_errors +def replay(index: int): + """Replay a history entry by index. + + \b + Examples: + unity-mcp code replay 0 + unity-mcp code replay 3 + """ + config = get_config() + result = run_command("execute_code", {"action": "replay", "index": index}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + data = result.get("data", {}) + if data and data.get("result") is not None: + print_success(f"Result: {data['result']}") + + +@code.command("clear-history") +@handle_unity_errors +def clear_history(): + """Clear execution history.""" + config = get_config() + result = run_command("execute_code", {"action": "clear_history"}, config) + click.echo(format_output(result, config.format)) + + @code.command("read") @click.argument("path") @click.option( diff --git a/Server/src/services/tools/execute_code.py b/Server/src/services/tools/execute_code.py new file mode 100644 index 000000000..f51a6784c --- /dev/null +++ b/Server/src/services/tools/execute_code.py @@ -0,0 +1,92 @@ +""" +Execute arbitrary C# code inside the Unity Editor. + +Supports execute, history, replay, and clear actions with basic blocked-pattern +checks. Code is compiled in-memory via CSharpCodeProvider — no script files created. + +WARNING: This tool runs arbitrary code in the Unity Editor process. +Safety checks block known dangerous patterns but are NOT a security sandbox. +""" +from typing import Annotated, Any, Literal + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description=( + "Execute arbitrary C# code inside the Unity Editor. " + "The code runs as a method body with access to UnityEngine and UnityEditor namespaces. " + "Use 'return' to send data back. Compiled in-memory — no script files created. " + "Actions: execute (run code), get_history (list past executions), " + "replay (re-run a history entry), clear_history. " + "NOTE: safety_checks blocks known dangerous patterns but is not a full sandbox." + ), + annotations=ToolAnnotations( + title="Execute Code", + destructiveHint=True, + ), +) +async def execute_code( + ctx: Context, + action: Annotated[ + Literal["execute", "get_history", "replay", "clear_history"], + "Action to perform.", + ], + code: Annotated[ + str, + "C# code to execute (for 'execute' action). Must be a valid method body. " + "Access UnityEngine and UnityEditor namespaces. Use 'return' to send data back.", + ] | None = None, + safety_checks: Annotated[ + bool, + "Enable basic blocked-pattern checks (File.Delete, Process.Start, infinite loops, etc). " + "Not a full sandbox — advanced bypass is possible. Default: true.", + ] = True, + index: Annotated[ + int, + "History entry index to replay (for 'replay' action).", + ] | None = None, + limit: Annotated[ + int, + "Number of history entries to return (for 'get_history' action, 1-50). Default: 10.", + ] = 10, +) -> dict[str, Any]: + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict: dict[str, Any] = {"action": action} + + if action == "execute": + if code is None: + return {"success": False, "message": "Parameter 'code' is required for 'execute' action."} + params_dict["code"] = code + params_dict["safety_checks"] = safety_checks + elif action == "replay": + if index is None: + return {"success": False, "message": "Parameter 'index' is required for 'replay' action."} + params_dict["index"] = index + elif action == "get_history": + params_dict["limit"] = max(1, min(limit, 50)) + + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + response = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "execute_code", + params_dict, + ) + + if not isinstance(response, dict): + return {"success": False, "message": str(response)} + + return { + "success": response.get("success", False), + "message": response.get("message", response.get("error", "")), + "data": response.get("data"), + } diff --git a/Server/tests/test_execute_code.py b/Server/tests/test_execute_code.py new file mode 100644 index 000000000..1faa0da12 --- /dev/null +++ b/Server/tests/test_execute_code.py @@ -0,0 +1,164 @@ +"""Tests for execute_code tool.""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.execute_code import execute_code + + +@pytest.fixture +def mock_unity(monkeypatch): + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "Code executed successfully.", "data": {"result": 42}} + + monkeypatch.setattr( + "services.tools.execute_code.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.execute_code.send_with_unity_instance", + fake_send, + ) + return captured + + +@pytest.fixture +def mock_unity_error(monkeypatch): + async def fake_send(send_fn, unity_instance, tool_name, params): + return {"success": False, "error": "Compilation failed"} + + monkeypatch.setattr( + "services.tools.execute_code.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.execute_code.send_with_unity_instance", + fake_send, + ) + + +# --- execute action --- + +def test_execute_forwards_code_to_unity(mock_unity): + result = asyncio.run(execute_code(SimpleNamespace(), action="execute", code="return 42;")) + assert result["success"] is True + assert mock_unity["tool_name"] == "execute_code" + assert mock_unity["params"]["code"] == "return 42;" + assert mock_unity["params"]["action"] == "execute" + + +def test_execute_sends_safety_checks_true_by_default(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="execute", code="return 1;")) + assert mock_unity["params"]["safety_checks"] is True + + +def test_execute_sends_safety_checks_false(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="execute", code="x();", safety_checks=False)) + assert mock_unity["params"]["safety_checks"] is False + + +def test_execute_returns_data(mock_unity): + result = asyncio.run(execute_code(SimpleNamespace(), action="execute", code="return 42;")) + assert result["data"]["result"] == 42 + + +def test_execute_requires_code(): + result = asyncio.run(execute_code(SimpleNamespace(), action="execute", code=None)) + assert result["success"] is False + assert "code" in result["message"].lower() + + +# --- get_history action --- + +def test_get_history_forwards_to_unity(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="get_history", limit=5)) + assert mock_unity["params"]["action"] == "get_history" + assert mock_unity["params"]["limit"] == 5 + + +def test_get_history_default_limit(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="get_history")) + assert mock_unity["params"]["limit"] == 10 + + +def test_get_history_clamps_limit(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="get_history", limit=999)) + assert mock_unity["params"]["limit"] == 50 + + +def test_get_history_clamps_negative_limit(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="get_history", limit=-5)) + assert mock_unity["params"]["limit"] == 1 + + +# --- replay action --- + +def test_replay_forwards_index(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="replay", index=3)) + assert mock_unity["params"]["action"] == "replay" + assert mock_unity["params"]["index"] == 3 + + +def test_replay_requires_index(): + result = asyncio.run(execute_code(SimpleNamespace(), action="replay", index=None)) + assert result["success"] is False + assert "index" in result["message"].lower() + + +# --- clear_history action --- + +def test_clear_history_forwards(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="clear_history")) + assert mock_unity["params"]["action"] == "clear_history" + + +# --- error handling --- + +def test_error_response_normalized(mock_unity_error): + result = asyncio.run(execute_code(SimpleNamespace(), action="execute", code="bad")) + assert result["success"] is False + assert "Compilation failed" in result["message"] + + +def test_non_dict_response_handled(monkeypatch): + async def fake_send(send_fn, unity_instance, tool_name, params): + return "unexpected" + + monkeypatch.setattr( + "services.tools.execute_code.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.execute_code.send_with_unity_instance", + fake_send, + ) + result = asyncio.run(execute_code(SimpleNamespace(), action="execute", code="return 1;")) + assert result["success"] is False + + +# --- param isolation --- + +def test_execute_omits_irrelevant_params(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="execute", code="return 1;")) + assert "index" not in mock_unity["params"] + assert "limit" not in mock_unity["params"] + + +def test_history_omits_irrelevant_params(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="get_history")) + assert "code" not in mock_unity["params"] + assert "index" not in mock_unity["params"] + assert "safety_checks" not in mock_unity["params"] + + +def test_replay_omits_irrelevant_params(mock_unity): + asyncio.run(execute_code(SimpleNamespace(), action="replay", index=0)) + assert "code" not in mock_unity["params"] + assert "limit" not in mock_unity["params"] + assert "safety_checks" not in mock_unity["params"] diff --git a/manifest.json b/manifest.json index 7ea0fbe72..ce6f2c34b 100644 --- a/manifest.json +++ b/manifest.json @@ -53,6 +53,10 @@ "name": "execute_custom_tool", "description": "Execute custom Unity Editor tools registered by the project" }, + { + "name": "execute_code", + "description": "Execute arbitrary C# code inside the Unity Editor with access to all Unity APIs" + }, { "name": "execute_menu_item", "description": "Execute Unity Editor menu items" From d2847a960becc625df7b483d79888c6c4fc59aba Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:11:54 +0100 Subject: [PATCH 2/3] fix: address review feedback from Sourcery and CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix off-by-one in WrapperLineOffset (9 → 10) - Cache resolved assembly paths in static field for performance - Add encoding="utf-8" to CLI file read - Add group="scripting_ext" to Python tool decorator Co-Authored-By: Claude Opus 4.6 (1M context) --- MCPForUnity/Editor/Tools/ExecuteCode.cs | 21 +++++++++++++++++---- Server/src/cli/commands/code.py | 2 +- Server/src/services/tools/execute_code.py | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ExecuteCode.cs b/MCPForUnity/Editor/Tools/ExecuteCode.cs index 5d2dcb9ae..55a49b69b 100644 --- a/MCPForUnity/Editor/Tools/ExecuteCode.cs +++ b/MCPForUnity/Editor/Tools/ExecuteCode.cs @@ -17,7 +17,7 @@ public static class ExecuteCode private const int MaxCodeLength = 50000; private const int MaxHistoryEntries = 50; private const int MaxHistoryCodePreview = 500; - private const int WrapperLineOffset = 9; + private const int WrapperLineOffset = 10; private const string WrapperClassName = "MCPDynamicCode"; private const string WrapperMethodName = "Execute"; @@ -27,6 +27,7 @@ public static class ExecuteCode private const string ActionReplay = "replay"; private static readonly List _history = new List(); + private static string[] _cachedAssemblyPaths; private static readonly HashSet _blockedPatterns = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -249,7 +250,16 @@ private static string WrapUserCode(string code) private static void AddReferences(CompilerParameters parameters) { - var referencedAssemblies = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_cachedAssemblyPaths == null) + _cachedAssemblyPaths = ResolveAssemblyPaths(); + + foreach (var path in _cachedAssemblyPaths) + parameters.ReferencedAssemblies.Add(path); + } + + private static string[] ResolveAssemblyPaths() + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { @@ -259,14 +269,17 @@ private static void AddReferences(CompilerParameters parameters) var location = assembly.Location; if (string.IsNullOrEmpty(location)) continue; if (!System.IO.File.Exists(location)) continue; - if (referencedAssemblies.Add(location)) - parameters.ReferencedAssemblies.Add(location); + 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) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index 5d46d08a3..8cfb69af2 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -36,7 +36,7 @@ def execute(source: Optional[str], file: Optional[str], no_safety_checks: bool): config = get_config() if file: - with open(file, "r") as f: + with open(file, "r", encoding="utf-8") as f: source = f.read() elif not source: print_error("Provide code as an argument or use --file.") diff --git a/Server/src/services/tools/execute_code.py b/Server/src/services/tools/execute_code.py index f51a6784c..ae5c56d6a 100644 --- a/Server/src/services/tools/execute_code.py +++ b/Server/src/services/tools/execute_code.py @@ -27,6 +27,7 @@ "replay (re-run a history entry), clear_history. " "NOTE: safety_checks blocks known dangerous patterns but is not a full sandbox." ), + group="scripting_ext", annotations=ToolAnnotations( title="Execute Code", destructiveHint=True, From 752175cfe02a98fe736f45a58080dbe013fb5c9f Mon Sep 17 00:00:00 2001 From: zaferdace <47742545+zaferdace@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:19:54 +0100 Subject: [PATCH 3/3] refactor: extract _print_execution_result helper in CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit nitpick — deduplicate result printing logic between execute and replay commands. Co-Authored-By: Claude Opus 4.6 (1M context) --- Server/src/cli/commands/code.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Server/src/cli/commands/code.py b/Server/src/cli/commands/code.py index 8cfb69af2..1e2763aa3 100644 --- a/Server/src/cli/commands/code.py +++ b/Server/src/cli/commands/code.py @@ -50,10 +50,7 @@ def execute(source: Optional[str], file: Optional[str], no_safety_checks: bool): result = run_command("execute_code", params, config) click.echo(format_output(result, config.format)) - if result.get("success"): - data = result.get("data", {}) - if data and data.get("result") is not None: - print_success(f"Result: {data['result']}") + _print_execution_result(result) @code.command("history") @@ -86,6 +83,10 @@ def replay(index: int): config = get_config() result = run_command("execute_code", {"action": "replay", "index": index}, config) click.echo(format_output(result, config.format)) + _print_execution_result(result) + + +def _print_execution_result(result: dict[str, Any]) -> None: if result.get("success"): data = result.get("data", {}) if data and data.get("result") is not None: