Skip to content
32 changes: 32 additions & 0 deletions MCPForUnity/Editor/Tools/ExecuteCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,32 @@ public static class ExecuteCode
private static string[] _cachedAssemblyPaths;
private static string[] _cachedCodeDomAssemblyPaths;

// Every compile emits a fresh in-memory "MCPDynamic" assembly, and Mono cannot unload
// one, so recompiling an identical snippet leaks an assembly per call until the next
// domain reload (see issue #1351). Cache the compiled output keyed on the wrapped
// source so repeated calls reuse one assembly.
private const int MaxCompiledCacheEntries = 64;
private static readonly Dictionary<string, CompiledSnippet> _compiledCache =
new Dictionary<string, CompiledSnippet>(StringComparer.Ordinal);

private readonly struct CompiledSnippet
{
public CompiledSnippet(Assembly assembly, string compiler)
{
Assembly = assembly;
Compiler = compiler;
}

public Assembly Assembly { get; }
public string Compiler { get; }
}

[UnityEditor.InitializeOnLoadMethod]
private static void OnDomainReload()
{
_cachedAssemblyPaths = null;
_cachedCodeDomAssemblyPaths = null;
_compiledCache.Clear();
RoslynCompiler.ResetCache();
}

Expand Down Expand Up @@ -181,6 +202,11 @@ private static object HandleReplay(JObject @params)
private static object CompileAndExecute(string code, string compiler)
{
string wrappedSource = WrapUserCode(code);

string cacheKey = compiler + "\n" + wrappedSource;
if (_compiledCache.TryGetValue(cacheKey, out CompiledSnippet cached))
return InvokeCompiled(cached.Assembly, cached.Compiler);
Comment on lines 204 to +208

string[] assemblyPaths = GetAssemblyPaths();

Assembly compiled;
Expand Down Expand Up @@ -222,6 +248,12 @@ private static object CompileAndExecute(string code, string compiler)
break;
}

// Bound the cache so a stream of genuinely distinct snippets cannot itself become
// the leak. Clearing wholesale is enough: the entries are only a compile shortcut.
if (_compiledCache.Count >= MaxCompiledCacheEntries)
_compiledCache.Clear();
_compiledCache[cacheKey] = new CompiledSnippet(compiled, usedCompiler);

return InvokeCompiled(compiled, usedCompiler);
}

Expand Down
82 changes: 62 additions & 20 deletions MCPForUnity/Editor/Tools/ManageScene.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ public static object HandleCommand(JObject @params)
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/"
// Paths are relative to a project root folder — "Assets" by default, or "Packages"
// when the caller addresses a scene shipped inside a package (see issue #1197).
string rootFolder = "Assets";
string relativeDir = path ?? string.Empty;
if (!string.IsNullOrEmpty(relativeDir))
{
Expand All @@ -180,6 +182,11 @@ public static object HandleCommand(JObject @params)
{
Comment on lines 178 to 182
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
else if (relativeDir.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
{
rootFolder = "Packages";
relativeDir = relativeDir.Substring("Packages/".Length).TrimStart('/');
}
// If path ends with .unity, it's a full scene path — extract just the directory
if (relativeDir.EndsWith(".unity", StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -202,15 +209,17 @@ public static object HandleCommand(JObject @params)
}

string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity";
// Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName
string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets)
// Construct full system path correctly: ProjectRoot/<rootFolder>/relativeDir/sceneFileName
string fullPathDir = rootFolder == "Assets"
? Path.Combine(Application.dataPath, relativeDir) // Application.dataPath ends in Assets
: Path.Combine(GetProjectRoot(), rootFolder, relativeDir);
string fullPath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine(fullPathDir, sceneFileName);
// Ensure relativePath always starts with "Assets/" and uses forward slashes
// Ensure relativePath is project-rooted ("Assets/..." or "Packages/...") with forward slashes
string relativePath = string.IsNullOrEmpty(sceneFileName)
? null
: AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName));
: AssetPathUtility.NormalizeSeparators(Path.Combine(rootFolder, relativeDir, sceneFileName));

// Ensure directory exists for 'create'
if (action == "create" && !string.IsNullOrEmpty(fullPathDir))
Expand Down Expand Up @@ -245,8 +254,7 @@ public static object HandleCommand(JObject @params)
string loadPath = relativePath;
if (string.IsNullOrEmpty(loadPath) && !string.IsNullOrEmpty(path))
loadPath = AssetPathUtility.NormalizeSeparators(
path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
? path : "Assets/" + path);
IsProjectRooted(path) ? path : "Assets/" + path);
if (!string.IsNullOrEmpty(loadPath))
{
if (cmd.additive == true)
Expand Down Expand Up @@ -375,17 +383,7 @@ private static object CreateScene(string fullPath, string relativePath)

private static object LoadScene(string relativePath)
{
if (
!File.Exists(
Path.Combine(
Application.dataPath.Substring(
0,
Application.dataPath.Length - "Assets".Length
),
relativePath
)
)
)
if (!SceneAssetExists(relativePath))
{
return new ErrorResponse($"Scene file not found at '{relativePath}'.");
}
Expand Down Expand Up @@ -1597,12 +1595,56 @@ private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath,
}


// ── Path helpers ───────────────────────────────────────────────────

private static string GetProjectRoot()
{
return Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length);
}

/// <summary>
/// True when the path already carries a project root folder that Unity's asset APIs
/// understand, so it must not be re-rooted under Assets/.
/// </summary>
internal static bool IsProjectRooted(string path)
{
if (string.IsNullOrEmpty(path)) return false;
string normalized = AssetPathUtility.NormalizeSeparators(path).TrimStart('/');
return normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Existence check that works for both roots, accepting either answer.
/// The AssetDatabase is the only one that resolves "Packages/..." — embedded packages
/// live in Library/PackageCache, not under the project root, so File.Exists misses them
/// (ManageAsset.cs carries the same note). File.Exists still covers an Assets/ scene
/// written to disk but not yet imported, which the AssetDatabase does not know about
/// until a refresh. This guard only exists to produce a clearer error than
/// EditorSceneManager.OpenScene would, so erring toward accepting is the safe direction.
/// </summary>
internal static bool SceneAssetExists(string projectRelativePath)
{
if (string.IsNullOrEmpty(projectRelativePath)) return false;

if (AssetDatabase.LoadAssetAtPath<SceneAsset>(projectRelativePath) != null) return true;

try
{
return File.Exists(Path.Combine(GetProjectRoot(), projectRelativePath));
}
catch (ArgumentException)
{
// Invalid path characters — treat as not found rather than throwing.
return false;
}
}

// ── Multi-scene editing ────────────────────────────────────────────

private static object LoadSceneAdditive(string scenePath)
{
string projectRoot = Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length);
if (!File.Exists(Path.Combine(projectRoot, scenePath)))
if (!SceneAssetExists(scenePath))
return new ErrorResponse($"Scene not found: '{scenePath}'");

var existing = SceneManager.GetSceneByPath(scenePath);
Expand Down
49 changes: 34 additions & 15 deletions MCPForUnity/Editor/Tools/ReadConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,25 +423,44 @@ bool includeStacktrace

// --- Internal Helpers ---

// Mapping bits from LogEntry.mode. These may vary by Unity version.
// Mapping bits from LogEntry.mode, mirroring UnityEditor.ConsoleWindow.Mode.
// These values are stable from 2021.3 through 6000.x.
private const int ModeBitError = 1 << 0;
private const int ModeBitAssert = 1 << 1;
private const int ModeBitWarning = 1 << 2;
private const int ModeBitLog = 1 << 3;
private const int ModeBitException = 1 << 4; // often combined with Error bits
private const int ModeBitScriptingError = 1 << 9;
private const int ModeBitScriptingWarning = 1 << 10;
private const int ModeBitScriptingLog = 1 << 11;
private const int ModeBitScriptingException = 1 << 18;
private const int ModeBitScriptingAssertion = 1 << 22;

private static LogType GetLogTypeFromMode(int mode)
private const int ModeBitLog = 1 << 2;
private const int ModeBitFatal = 1 << 4;
private const int ModeBitAssetImportError = 1 << 6;
private const int ModeBitAssetImportWarning = 1 << 7;
private const int ModeBitScriptingError = 1 << 8;
private const int ModeBitScriptingWarning = 1 << 9;
private const int ModeBitScriptingLog = 1 << 10;
private const int ModeBitScriptCompileError = 1 << 11;
private const int ModeBitScriptCompileWarning = 1 << 12;
private const int ModeBitStickyError = 1 << 13;
private const int ModeBitScriptingException = 1 << 17;
private const int ModeBitScriptingAssertion = 1 << 21;
private const int ModeBitVisualScriptingError = 1 << 22;

private const int ModeMaskError = ModeBitError
| ModeBitFatal
| ModeBitAssetImportError
| ModeBitScriptingError
| ModeBitScriptCompileError
| ModeBitStickyError
| ModeBitVisualScriptingError;

private const int ModeMaskWarning = ModeBitAssetImportWarning
| ModeBitScriptingWarning
| ModeBitScriptCompileWarning;

internal static LogType GetLogTypeFromMode(int mode)
{
// Preserve Unity's real type (no remapping); bits may vary by version
if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception;
if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error;
// Preserve Unity's real type (no remapping). Order matters: an exception
// also carries the Error bit, and an assertion also carries Assert.
if ((mode & ModeBitScriptingException) != 0) return LogType.Exception;
if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert;
if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning;
if ((mode & ModeMaskError) != 0) return LogType.Error;
if ((mode & ModeMaskWarning) != 0) return LogType.Warning;
return LogType.Log;
}

Expand Down
14 changes: 12 additions & 2 deletions MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEngine.Experimental.Rendering;

namespace MCPForUnity.Runtime.Helpers
//The reason for having another Runtime Utilities in additional to Editor Utilities is to avoid Editor-only dependencies in this runtime code.
Expand Down Expand Up @@ -579,14 +580,23 @@ public static Texture2D DownscaleTexture(Texture2D source, int maxEdge)
int dstW = Mathf.Max(1, Mathf.RoundToInt(srcW * scale));
int dstH = Mathf.Max(1, Mathf.RoundToInt(srcH * scale));

// Match the temporary RT's read/write mode to the source texture rather than the
// project default. In a Linear-colorspace project a Default (sRGB) RT applies an
// sRGB encode on store while the blit samples a linear-flagged capture without a
// matching decode, which washes the image out (see issue #1328).
bool srcIsSrgb = GraphicsFormatUtility.IsSRGBFormat(source.graphicsFormat);
RenderTextureReadWrite readWrite = srcIsSrgb
? RenderTextureReadWrite.sRGB
: RenderTextureReadWrite.Linear;

RenderTexture prevActive = RenderTexture.active;
var rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32);
var rt = RenderTexture.GetTemporary(dstW, dstH, 0, RenderTextureFormat.ARGB32, readWrite);
rt.filterMode = FilterMode.Bilinear;
try
{
Graphics.Blit(source, rt);
RenderTexture.active = rt;
var dst = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false);
var dst = new Texture2D(dstW, dstH, TextureFormat.RGBA32, false, linear: !srcIsSrgb);
dst.ReadPixels(new Rect(0, 0, dstW, dstH), 0, 0);
dst.Apply();
return dst;
Expand Down
1 change: 1 addition & 0 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ def _build_instructions(project_scoped_tools: bool) -> str:
- Resources are addressed by URI, never by name. A resource's name and URI are NOT interchangeable: names use underscores (e.g. editor_state) while URIs use slashes (e.g. mcpforunity://editor/state). Do NOT build a URI by swapping separators in the name — you will 404.
- These instructions always spell resources as full mcpforunity:// URIs — read one exactly as written. If you only have a name (from resources/list or another tool's output), look its URI up in resources/list rather than guessing it.
- Resource payloads are wrapped: the content lives under a top-level `data` object, so field paths are `data.<section>.<field>` (e.g. `data.advice.ready_for_tools`), not bare top-level fields.
- The mcpforunity:// URI names the resource, not the server. Some clients take a separate server key on a resource read — in Codex, tools are exposed as mcp__unityMCP__* but resources/read wants server: "unityMCP". If a read fails with an unknown-server error, list resources first and use the key exactly as returned.

Script Management:
- After creating or modifying scripts (by your own tools or the `manage_script` tool) use `read_console` to check for compilation errors before proceeding
Expand Down
2 changes: 1 addition & 1 deletion Server/src/services/tools/manage_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def manage_scene(
"validate",
], "Perform CRUD operations on Unity scenes and control the Scene View camera."],
name: Annotated[str, "Scene name."] | None = None,
path: Annotated[str, "Scene path."] | None = None,
path: Annotated[str, "Scene path, under Assets/ or Packages/. A bare path is treated as relative to Assets/."] | None = None,
build_index: Annotated[int | str,
"Unity build index (quote as string, e.g., '0')."] | None = None,
# --- scene_view_frame params ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,5 +546,42 @@ private static JObject Execute(string code)
}));
}


// ──────────────────── Compiled-assembly cache (issue #1351) ────────────────────

[Test]
public void Execute_SameSnippetTwice_DoesNotLoadASecondAssembly()
{
// Every compile Assembly.Load()s a fresh "MCPDynamic" image that Mono cannot
// unload, so an uncached recompile leaks one assembly per call.
const string snippet = "return 41 + 1;";

// Warm the cache: this call is expected to add exactly one assembly.
var first = Execute(snippet);
Assert.IsTrue(first.Value<bool>("success"), first.ToString());

int loadedBefore = AppDomain.CurrentDomain.GetAssemblies().Length;

var second = Execute(snippet);
Assert.IsTrue(second.Value<bool>("success"), second.ToString());
Assert.AreEqual(42, second["data"]["result"].Value<int>());

int loadedAfter = AppDomain.CurrentDomain.GetAssemblies().Length;
Assert.AreEqual(loadedBefore, loadedAfter,
"Re-executing an identical snippet loaded another assembly; the compile cache did not hit.");
}

[Test]
public void Execute_DifferentSnippets_StillCompileIndependently()
{
var a = Execute("return 1;");
var b = Execute("return 2;");

Assert.IsTrue(a.Value<bool>("success"), a.ToString());
Assert.IsTrue(b.Value<bool>("success"), b.ToString());
Assert.AreEqual(1, a["data"]["result"].Value<int>());
Assert.AreEqual(2, b["data"]["result"].Value<int>());
}

}
}
Loading
Loading