diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8d8ae0169 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repo has two primary codebases: +- `Server/`: Python MCP server (`src/` runtime code, `tests/` Python tests, `pyproject.toml`). +- `MCPForUnity/`: Unity package (mainly `Editor/` tools/services and `Runtime/` code). + +Supporting areas: +- `TestProjects/UnityMCPTests/`: Unity test project and C# test suites. +- `docs/`: user, migration, and development documentation. +- `tools/` and `scripts/`: release/dev utilities. +- `mcp_source.py`: switch Unity package source for local/upstream workflows. + +## Build, Test, and Development Commands +- `cd Server && uv run src/main.py --transport stdio`: run local server over stdio. +- `cd Server && uv run src/main.py --transport http --http-url http://127.0.0.1:8080`: run local HTTP server. +- `cd Server && uv run pytest tests/ -v`: run Python server tests. +- `cd Server && uv run pytest tests/ --cov --cov-report=html`: run coverage and generate `htmlcov/`. +- `cd Server && uv run python -m cli.main editor tests --mode PlayMode`: run Unity tests via MCP bridge (Unity must be running). +- `python mcp_source.py`: point Unity project to upstream/main/beta/local package sources. + +## Coding Style & Naming Conventions +- Python: 4-space indentation, snake_case modules/functions, keep async command handlers focused by domain under `Server/src/cli/commands/`. +- C#: PascalCase for types/methods, `ManageXxx` naming for tool classes, keep one tool responsibility per class. +- Prefer small, explicit code over one-off abstractions. +- Type checking uses `Server/pyrightconfig.json` (`typeCheckingMode: basic`). + +## Testing Guidelines +- Add tests for all feature work in both touched layers when applicable (Python + Unity). +- Python test files follow `test_*.py` under `Server/tests/`. +- Validate behavior changes with targeted tests first, then broader suites before PR. + +## Commit & Pull Request Guidelines +- Branch from `beta`; do not target `main` for feature development. +- Follow existing commit style: `feat(scope): ...`, `fix: ...`, `docs: ...`, `chore: ...`. +- Keep commits focused and reference issue/PR IDs when relevant (e.g., `(#859)`). +- PRs should include: clear summary, linked issue/discussion, test evidence, and screenshots/GIFs for Unity UI/tooling changes. + +## Security & Configuration Tips +- Default local HTTP endpoint is `127.0.0.1`; avoid exposing `0.0.0.0` unless explicitly needed. +- For local server iteration in Unity, use **Server Source Override** to your `Server/` path and enable **Dev Mode**. diff --git a/MCPForUnity/Editor/Helpers/ComponentOps.cs b/MCPForUnity/Editor/Helpers/ComponentOps.cs index e4e456ac4..a3397070f 100644 --- a/MCPForUnity/Editor/Helpers/ComponentOps.cs +++ b/MCPForUnity/Editor/Helpers/ComponentOps.cs @@ -197,11 +197,21 @@ private static bool TrySetViaReflection(object component, Type type, string prop { error = null; + // Skip reflection for UnityEngine.Object types with JObject values + // so SerializedProperty can resolve guid/spriteName/fileID forms. + bool isJObjectValue = value != null && value.Type == JTokenType.Object; + // Try property first PropertyInfo propInfo = type.GetProperty(propertyName, flags) ?? type.GetProperty(normalizedName, flags); if (propInfo != null && propInfo.CanWrite) { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(propInfo.PropertyType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + try { object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType); @@ -225,6 +235,12 @@ private static bool TrySetViaReflection(object component, Type type, string prop ?? type.GetField(normalizedName, flags); if (fieldInfo != null && !fieldInfo.IsInitOnly) { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + try { object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); @@ -248,6 +264,12 @@ private static bool TrySetViaReflection(object component, Type type, string prop ?? FindSerializedFieldInHierarchy(type, normalizedName); if (fieldInfo != null) { + if (isJObjectValue && typeof(UnityEngine.Object).IsAssignableFrom(fieldInfo.FieldType)) + { + // Let SerializedProperty path handle complex object references. + return false; + } + try { object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); @@ -599,6 +621,47 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou error = $"No asset found for GUID '{guidToken}'."; return false; } + + var spriteNameToken = jObj["spriteName"]; + if (spriteNameToken != null) + { + string spriteName = spriteNameToken.ToString(); + var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + foreach (var asset in allAssets) + { + if (asset is Sprite sprite && sprite.name == spriteName) + { + prop.objectReferenceValue = sprite; + return true; + } + } + + error = $"Sprite '{spriteName}' not found in atlas '{path}'."; + return false; + } + + var fileIdToken = jObj["fileID"]; + if (fileIdToken != null) + { + long targetFileId = ParamCoercion.CoerceLong(fileIdToken, 0); + if (targetFileId != 0) + { + var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + foreach (var asset in allAssets) + { + if (asset is Sprite sprite) + { + long spriteFileId = GetSpriteFileId(sprite); + if (spriteFileId == targetFileId) + { + prop.objectReferenceValue = sprite; + return true; + } + } + } + } + } + prop.objectReferenceValue = AssetDatabase.LoadAssetAtPath(path); return true; } @@ -767,6 +830,23 @@ private static bool SetEnum(SerializedProperty prop, JToken value, out string er error = $"Unknown enum name '{s}'."; return false; } + + + private static long GetSpriteFileId(Sprite sprite) + { + if (sprite == null) + return 0; + + try + { + var globalId = GlobalObjectId.GetGlobalObjectIdSlow(sprite); + return (long)globalId.targetObjectId; + } + catch + { + return 0; + } + } } } diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs new file mode 100644 index 000000000..da4c75953 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -0,0 +1,346 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using MCPForUnity.Runtime.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Captures the pixels currently displayed in an editor window viewport. + /// Uses the editor view's own pixel grab path instead of re-rendering through a Camera, + /// which is required for SceneView overlays, prefab stages, and UI editing views. + /// + internal static class EditorWindowScreenshotUtility + { + private const string ScreenshotsFolderName = "Screenshots"; + private const int RepaintSettlingDelayMs = 75; + + public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( + SceneView sceneView, + string fileName, + int superSize, + bool ensureUniqueFileName, + bool includeImage, + int maxResolution, + out int viewportWidth, + out int viewportHeight) + { + if (sceneView == null) + throw new ArgumentNullException(nameof(sceneView)); + + FocusAndRepaint(sceneView); + + Rect viewportRectPixels = GetSceneViewViewportPixelRect(sceneView); + viewportWidth = Mathf.RoundToInt(viewportRectPixels.width); + viewportHeight = Mathf.RoundToInt(viewportRectPixels.height); + + if (viewportWidth <= 0 || viewportHeight <= 0) + throw new InvalidOperationException("Captured Scene view viewport is empty."); + + Texture2D captured = null; + Texture2D downscaled = null; + try + { + captured = CaptureViewRect(sceneView, viewportRectPixels); + + var result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName); + byte[] png = captured.EncodeToPNG(); + File.WriteAllBytes(result.FullPath, png); + + if (includeImage) + { + int targetMax = maxResolution > 0 ? maxResolution : 640; + string imageBase64; + int imageWidth; + int imageHeight; + + if (captured.width > targetMax || captured.height > targetMax) + { + downscaled = ScreenshotUtility.DownscaleTexture(captured, targetMax); + imageBase64 = Convert.ToBase64String(downscaled.EncodeToPNG()); + imageWidth = downscaled.width; + imageHeight = downscaled.height; + } + else + { + imageBase64 = Convert.ToBase64String(png); + imageWidth = captured.width; + imageHeight = captured.height; + } + + return new ScreenshotCaptureResult( + result.FullPath, + result.AssetsRelativePath, + result.SuperSize, + isAsync: false, + imageBase64, + imageWidth, + imageHeight); + } + + return result; + } + finally + { + DestroyTexture(captured); + DestroyTexture(downscaled); + } + } + + private static void FocusAndRepaint(SceneView sceneView) + { + try + { + sceneView.Focus(); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView focus failed: {ex.Message}"); + } + + try + { + sceneView.Repaint(); + InvokeMethodIfExists(sceneView, "RepaintImmediately"); + SceneView.RepaintAll(); + UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); + EditorApplication.QueuePlayerLoopUpdate(); + Thread.Sleep(RepaintSettlingDelayMs); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView repaint failed: {ex.Message}"); + } + } + + private static Rect GetSceneViewViewportPixelRect(SceneView sceneView) + { + float pixelsPerPoint = EditorGUIUtility.pixelsPerPoint; + Rect viewportLocalPoints = GetViewportLocalRectPoints(sceneView, pixelsPerPoint); + if (viewportLocalPoints.width <= 0f || viewportLocalPoints.height <= 0f) + throw new InvalidOperationException("Failed to resolve Scene view viewport rect."); + + return new Rect( + Mathf.Round(viewportLocalPoints.x * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.y * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.width * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.height * pixelsPerPoint)); + } + + private static Rect GetViewportLocalRectPoints(SceneView sceneView, float pixelsPerPoint) + { + Rect? cameraViewport = GetRectProperty(sceneView, "cameraViewport"); + if (cameraViewport.HasValue && cameraViewport.Value.width > 0f && cameraViewport.Value.height > 0f) + { + return cameraViewport.Value; + } + + Camera camera = sceneView.camera; + if (camera == null) + throw new InvalidOperationException("Active Scene View has no camera to derive viewport size from."); + + float viewportWidth = camera.pixelWidth / Mathf.Max(0.0001f, pixelsPerPoint); + float viewportHeight = camera.pixelHeight / Mathf.Max(0.0001f, pixelsPerPoint); + Rect windowRect = sceneView.position; + + return new Rect( + 0f, + Mathf.Max(0f, windowRect.height - viewportHeight), + Mathf.Min(windowRect.width, viewportWidth), + Mathf.Min(windowRect.height, viewportHeight)); + } + + private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectPixels) + { + object hostView = GetHostView(sceneView); + if (hostView == null) + throw new InvalidOperationException("Failed to resolve Scene view host view."); + + MethodInfo grabPixels = hostView.GetType().GetMethod( + "GrabPixels", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] { typeof(RenderTexture), typeof(Rect) }, + null); + + if (grabPixels == null) + throw new MissingMethodException($"{hostView.GetType().FullName}.GrabPixels(RenderTexture, Rect)"); + + int viewportWidth = Mathf.RoundToInt(viewportRectPixels.width); + int viewportHeight = Mathf.RoundToInt(viewportRectPixels.height); + + RenderTexture rt = null; + RenderTexture previousActive = RenderTexture.active; + try + { + rt = new RenderTexture(viewportWidth, viewportHeight, 0, RenderTextureFormat.ARGB32) + { + antiAliasing = 1, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + rt.Create(); + + grabPixels.Invoke(hostView, new object[] { rt, viewportRectPixels }); + + RenderTexture.active = rt; + var texture = new Texture2D(viewportWidth, viewportHeight, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, viewportWidth, viewportHeight), 0, 0); + texture.Apply(); + FlipTextureVertically(texture); + return texture; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException ?? ex; + } + finally + { + RenderTexture.active = previousActive; + if (rt != null) + { + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + } + } + } + + private static object GetHostView(EditorWindow window) + { + if (window == null) + return null; + + Type windowType = typeof(EditorWindow); + FieldInfo parentField = windowType.GetField("m_Parent", BindingFlags.Instance | BindingFlags.NonPublic); + if (parentField != null) + { + object parent = parentField.GetValue(window); + if (parent != null) + return parent; + } + + PropertyInfo hostViewProperty = windowType.GetProperty("hostView", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return hostViewProperty?.GetValue(window, null); + } + + private static Rect? GetRectProperty(object instance, string propertyName) + { + if (instance == null) + return null; + + Type type = instance.GetType(); + PropertyInfo property = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property == null || property.PropertyType != typeof(Rect)) + return null; + + try + { + return (Rect)property.GetValue(instance, null); + } + catch + { + return null; + } + } + + private static void InvokeMethodIfExists(object instance, string methodName) + { + if (instance == null) + return; + + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (method == null || method.GetParameters().Length != 0) + return; + + try + { + method.Invoke(instance, null); + } + catch + { + // Best-effort only. + } + } + + private static void FlipTextureVertically(Texture2D texture) + { + if (texture == null) + return; + + int width = texture.width; + int height = texture.height; + Color32[] source = texture.GetPixels32(); + Color32[] flipped = new Color32[source.Length]; + + for (int y = 0; y < height; y++) + { + int srcRow = y * width; + int dstRow = (height - 1 - y) * width; + Array.Copy(source, srcRow, flipped, dstRow, width); + } + + texture.SetPixels32(flipped); + texture.Apply(); + } + + private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName) + { + int size = Mathf.Max(1, superSize); + string resolvedName = BuildFileName(fileName); + string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); + Directory.CreateDirectory(folder); + + string fullPath = Path.Combine(folder, resolvedName); + if (ensureUniqueFileName) + { + fullPath = EnsureUnique(fullPath); + } + + string normalizedFullPath = fullPath.Replace('\\', '/'); + string assetsRelativePath = "Assets/" + normalizedFullPath.Substring(Application.dataPath.Length).TrimStart('/'); + return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, isAsync: false); + } + + private static string BuildFileName(string fileName) + { + string baseName = string.IsNullOrWhiteSpace(fileName) + ? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png" + : fileName.Trim(); + + if (!baseName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + baseName += ".png"; + + return baseName; + } + + private static string EnsureUnique(string fullPath) + { + if (!File.Exists(fullPath)) + return fullPath; + + string directory = Path.GetDirectoryName(fullPath) ?? string.Empty; + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullPath); + string extension = Path.GetExtension(fullPath); + + for (int i = 1; i < 10000; i++) + { + string candidate = Path.Combine(directory, $"{fileNameWithoutExtension}-{i}{extension}"); + if (!File.Exists(candidate)) + return candidate; + } + + throw new IOException($"Could not generate a unique screenshot filename for '{fullPath}'."); + } + + private static void DestroyTexture(Texture2D texture) + { + if (texture == null) + return; + + UnityEngine.Object.DestroyImmediate(texture); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta new file mode 100644 index 000000000..d120907fe --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b73350febfd6534436726d19b4d270fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/MCPForUnity/Editor/Helpers/ParamCoercion.cs index d19d7bf46..dfca71643 100644 --- a/MCPForUnity/Editor/Helpers/ParamCoercion.cs +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -78,6 +78,75 @@ public static int CoerceInt(JToken token, int defaultValue) return null; } + + /// + /// Coerces a JToken to a long value, handling strings and floats. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced long value or default + public static long CoerceLong(JToken token, long defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + return l; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (long)d; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable long value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced long value or null + public static long? CoerceLongNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + return l; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (long)d; + } + catch + { + // Swallow and return null + } + + return null; + } + /// /// Coerces a JToken to a boolean value, handling strings like "true", "1", etc. /// diff --git a/MCPForUnity/Editor/Setup/RoslynInstaller.cs b/MCPForUnity/Editor/Setup/RoslynInstaller.cs index 8110fb0e0..c59710c08 100644 --- a/MCPForUnity/Editor/Setup/RoslynInstaller.cs +++ b/MCPForUnity/Editor/Setup/RoslynInstaller.cs @@ -17,6 +17,8 @@ private static readonly (string packageId, string version, string dllPath, strin ("microsoft.codeanalysis.csharp", "4.12.0", "lib/netstandard2.0/Microsoft.CodeAnalysis.CSharp.dll","Microsoft.CodeAnalysis.CSharp.dll"), ("system.collections.immutable", "8.0.0", "lib/netstandard2.0/System.Collections.Immutable.dll", "System.Collections.Immutable.dll"), ("system.reflection.metadata", "8.0.0", "lib/netstandard2.0/System.Reflection.Metadata.dll", "System.Reflection.Metadata.dll"), + // Required by System.Memory (transitive dependency for System.Reflection.Metadata on netstandard2.0) + ("system.runtime.compilerservices.unsafe", "4.5.3", "lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll", "System.Runtime.CompilerServices.Unsafe.dll"), }; [MenuItem("Window/MCP For Unity/Install Roslyn DLLs", priority = 20)] diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index ec59533ae..a369176b9 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -29,6 +29,7 @@ private sealed class SceneCommand // screenshot: camera selection, inline image, batch, view positioning public string camera { get; set; } + public string captureSource { get; set; } // "game_view" (default) or "scene_view" public bool? includeImage { get; set; } public int? maxResolution { get; set; } public string batch { get; set; } // "surround" or "orbit" for multi-angle batch capture @@ -95,6 +96,7 @@ private static SceneCommand ToSceneCommand(JObject p) // screenshot: camera selection, inline image, batch, view positioning camera = (p["camera"])?.ToString(), + captureSource = (p["captureSource"] ?? p["capture_source"])?.ToString(), includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]), maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]), batch = (p["batch"])?.ToString(), @@ -433,6 +435,46 @@ private static object CaptureScreenshot(SceneCommand cmd) { try { + string fileName = cmd.fileName; + int resolvedSuperSize = (cmd.superSize.HasValue && cmd.superSize.Value > 0) ? cmd.superSize.Value : 1; + bool includeImage = cmd.includeImage ?? false; + int maxResolution = cmd.maxResolution ?? 0; // 0 = let ScreenshotUtility default to 640 + string cameraRef = cmd.camera; + string captureSource = string.IsNullOrWhiteSpace(cmd.captureSource) + ? "game_view" + : cmd.captureSource.Trim().ToLowerInvariant(); + + if (captureSource != "game_view" && captureSource != "scene_view") + { + return new ErrorResponse( + $"Invalid capture_source '{cmd.captureSource}'. Valid values: 'game_view', 'scene_view'."); + } + + if (captureSource == "scene_view") + { + if (resolvedSuperSize > 1) + { + return new ErrorResponse( + "capture_source='scene_view' does not support super_size above 1. Remove 'super_size' or use capture_source='game_view'."); + } + if (!string.IsNullOrEmpty(cmd.batch)) + { + return new ErrorResponse( + "capture_source='scene_view' does not support batch modes. Use capture_source='game_view' for batch capture."); + } + if ((cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) || cmd.viewPosition.HasValue || cmd.viewRotation.HasValue) + { + return new ErrorResponse( + "capture_source='scene_view' does not support look_at/view_position/view_rotation. Use scene_view_target to frame a Scene View object."); + } + if (!string.IsNullOrEmpty(cameraRef)) + { + return new ErrorResponse( + "capture_source='scene_view' does not support camera selection. Remove 'camera' or use capture_source='game_view'."); + } + return CaptureSceneViewScreenshot(cmd, fileName, resolvedSuperSize, includeImage, maxResolution); + } + // Batch capture (e.g., "surround" for 6 angles around the scene) if (!string.IsNullOrEmpty(cmd.batch)) { @@ -449,12 +491,6 @@ private static object CaptureScreenshot(SceneCommand cmd) return CapturePositionedScreenshot(cmd); } - string fileName = cmd.fileName; - int resolvedSuperSize = (cmd.superSize.HasValue && cmd.superSize.Value > 0) ? cmd.superSize.Value : 1; - bool includeImage = cmd.includeImage ?? false; - int maxResolution = cmd.maxResolution ?? 0; // 0 = let ScreenshotUtility default to 640 - string cameraRef = cmd.camera; - // Batch mode warning if (Application.isBatchMode) { @@ -506,6 +542,7 @@ private static object CaptureScreenshot(SceneCommand cmd) { "superSize", result.SuperSize }, { "isAsync", false }, { "camera", targetCamera.name }, + { "captureSource", "game_view" }, }; if (includeImage && result.ImageBase64 != null) { @@ -560,6 +597,7 @@ private static object CaptureScreenshot(SceneCommand cmd) fullPath = defaultResult.FullPath, superSize = defaultResult.SuperSize, isAsync = defaultResult.IsAsync, + captureSource = "game_view", } ); } @@ -569,6 +607,86 @@ private static object CaptureScreenshot(SceneCommand cmd) } } + private static object CaptureSceneViewScreenshot( + SceneCommand cmd, + string fileName, + int resolvedSuperSize, + bool includeImage, + int maxResolution) + { + if (Application.isBatchMode) + { + return new ErrorResponse("capture_source='scene_view' is not supported in batch mode."); + } + + var sceneView = SceneView.lastActiveSceneView; + if (sceneView == null) + { + return new ErrorResponse( + "No active Scene View found. Open a Scene View window first, then retry screenshot with capture_source='scene_view'."); + } + + // Optional pre-frame so capture matches the intended editing target. + if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + { + var frameResult = FrameSceneView(new SceneCommand { sceneViewTarget = cmd.sceneViewTarget }); + if (frameResult is ErrorResponse) + { + return frameResult; + } + } + + try + { + ScreenshotCaptureResult result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToAssets( + sceneView, + fileName, + resolvedSuperSize, + ensureUniqueFileName: true, + includeImage: includeImage, + maxResolution: maxResolution, + out int viewportWidth, + out int viewportHeight); + + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string sceneViewName = sceneView.titleContent?.text ?? "Scene"; + + var data = new Dictionary + { + { "path", result.AssetsRelativePath }, + { "fullPath", result.FullPath }, + { "superSize", result.SuperSize }, + { "isAsync", false }, + { "camera", sceneView.camera != null ? sceneView.camera.name : "SceneCamera" }, + { "captureSource", "scene_view" }, + { "captureMode", "scene_view_viewport" }, + { "sceneViewName", sceneViewName }, + { "viewportWidth", viewportWidth }, + { "viewportHeight", viewportHeight }, + }; + + if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + { + data["sceneViewTarget"] = cmd.sceneViewTarget; + } + + if (includeImage && result.ImageBase64 != null) + { + data["imageBase64"] = result.ImageBase64; + data["imageWidth"] = result.ImageWidth; + data["imageHeight"] = result.ImageHeight; + } + + return new SuccessResponse( + $"Scene View screenshot captured to '{result.AssetsRelativePath}' (scene view: {sceneViewName}).", + data); + } + catch (Exception e) + { + return new ErrorResponse($"Error capturing Scene View screenshot: {e.Message}"); + } + } + /// /// Captures screenshots from 6 angles around scene bounds (or a look_at target) for AI scene understanding. /// Does NOT save to disk — returns all images as inline base64 PNGs. Always uses camera-based capture. @@ -1012,30 +1130,7 @@ private static object FrameSceneView(SceneCommand cmd) return new ErrorResponse($"Target GameObject '{cmd.sceneViewTarget}' not found for scene_view_frame."); } - // Calculate bounds from renderers, colliders, or transform - Bounds bounds = new Bounds(target.transform.position, Vector3.zero); - var renderers = target.GetComponentsInChildren(); - if (renderers.Length > 0) - { - bounds = renderers[0].bounds; - for (int i = 1; i < renderers.Length; i++) - bounds.Encapsulate(renderers[i].bounds); - } - else - { - var colliders = target.GetComponentsInChildren(); - if (colliders.Length > 0) - { - bounds = colliders[0].bounds; - for (int i = 1; i < colliders.Length; i++) - bounds.Encapsulate(colliders[i].bounds); - } - else - { - bounds = new Bounds(target.transform.position, Vector3.one); - } - } - + Bounds bounds = CalculateFrameBounds(target); sceneView.Frame(bounds, false); return new SuccessResponse($"Scene View framed on '{target.name}'.", new { target = target.name }); } @@ -1061,6 +1156,104 @@ private static object FrameSceneView(SceneCommand cmd) } } + private static Bounds CalculateFrameBounds(GameObject target) + { + if (target == null) + return new Bounds(Vector3.zero, Vector3.one); + + if (TryGetRectTransformBounds(target, out Bounds rectBounds)) + return rectBounds; + + if (TryGetRendererBounds(target, out Bounds rendererBounds)) + return rendererBounds; + + if (TryGetColliderBounds(target, out Bounds colliderBounds)) + return colliderBounds; + + return new Bounds(target.transform.position, Vector3.one); + } + + private static bool TryGetRectTransformBounds(GameObject target, out Bounds bounds) + { + bounds = default; + var rectTransforms = target.GetComponentsInChildren(true); + bool hasBounds = false; + var corners = new Vector3[4]; + + foreach (var rectTransform in rectTransforms) + { + if (rectTransform == null) + continue; + if (!rectTransform.gameObject.activeInHierarchy) + continue; + + rectTransform.GetWorldCorners(corners); + for (int i = 0; i < corners.Length; i++) + { + if (!hasBounds) + { + bounds = new Bounds(corners[i], Vector3.zero); + hasBounds = true; + } + else + { + bounds.Encapsulate(corners[i]); + } + } + } + + if (!hasBounds) + return false; + + if (bounds.size.sqrMagnitude < 0.0001f) + bounds.Expand(1f); + return true; + } + + private static bool TryGetRendererBounds(GameObject target, out Bounds bounds) + { + bounds = default; + var renderers = target.GetComponentsInChildren(true); + bool hasBounds = false; + foreach (var renderer in renderers) + { + if (renderer == null || !renderer.gameObject.activeInHierarchy) + continue; + if (!hasBounds) + { + bounds = renderer.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + return hasBounds; + } + + private static bool TryGetColliderBounds(GameObject target, out Bounds bounds) + { + bounds = default; + var colliders = target.GetComponentsInChildren(true); + bool hasBounds = false; + foreach (var collider in colliders) + { + if (collider == null || !collider.gameObject.activeInHierarchy) + continue; + if (!hasBounds) + { + bounds = collider.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(collider.bounds); + } + } + return hasBounds; + } + private static void EnsureGameView() { try diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 576196477..74c6800df 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -102,6 +102,9 @@ unity-mcp gameobject modify "MyCube" --position 0 2 0 # Take a screenshot unity-mcp scene screenshot +unity-mcp scene screenshot --capture-source scene_view --include-image + +# Note: `--capture-source scene_view` only supports `--supersize 1` # Enter play mode unity-mcp editor play @@ -370,6 +373,9 @@ unity-mcp scene save # Take screenshot unity-mcp scene screenshot unity-mcp scene screenshot --filename "my_screenshot" --supersize 2 +unity-mcp scene screenshot --capture-source scene_view --scene-view-target "Canvas" --include-image + +# Note: `--capture-source scene_view` captures the current Scene View viewport and does not support `--supersize` values above 1. ``` ### GameObject Commands diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py index 6543879f0..208703268 100644 --- a/Server/src/cli/commands/scene.py +++ b/Server/src/cli/commands/scene.py @@ -226,6 +226,13 @@ def build_settings(): type=int, help="Max resolution (longest edge) for inline image. Default 640." ) +@click.option( + "--capture-source", + default="game_view", + type=click.Choice(["game_view", "scene_view"], case_sensitive=False), + show_default=True, + help="Screenshot source: game_view (default) or scene_view." +) @click.option( "--batch", "-b", default=None, @@ -274,14 +281,20 @@ def build_settings(): default=None, help="Directory to save batch screenshots to (default: Unity project's Assets/Screenshots)." ) +@click.option( + "--scene-view-target", + default=None, + help="When --capture-source scene_view, frame Scene View on this target (name/path/instance ID) before capture." +) @handle_unity_errors def screenshot(filename: Optional[str], supersize: int, camera: Optional[str], include_image: bool, max_resolution: Optional[int], + capture_source: str, batch: Optional[str], look_at: Optional[str], view_position: Optional[str], view_rotation: Optional[str], orbit_angles: Optional[int], orbit_elevations: Optional[str], orbit_distance: Optional[float], orbit_fov: Optional[float], - output_dir: Optional[str]): + output_dir: Optional[str], scene_view_target: Optional[str]): """Capture a screenshot of the scene. \b @@ -291,6 +304,8 @@ def screenshot(filename: Optional[str], supersize: int, camera: Optional[str], unity-mcp scene screenshot --supersize 2 unity-mcp scene screenshot --camera "SecondCamera" --include-image unity-mcp scene screenshot --include-image --max-resolution 512 + unity-mcp scene screenshot --capture-source scene_view --include-image + unity-mcp scene screenshot --capture-source scene_view --scene-view-target "Canvas" unity-mcp scene screenshot --batch surround --max-resolution 256 unity-mcp scene screenshot --look-at "Player" --max-resolution 512 unity-mcp scene screenshot --view-position "0,10,-10" --look-at "0,0,0" @@ -308,6 +323,8 @@ def screenshot(filename: Optional[str], supersize: int, camera: Optional[str], params["includeImage"] = True if max_resolution: params["maxResolution"] = max_resolution + if capture_source: + params["captureSource"] = capture_source.lower() if batch: params["batch"] = batch if look_at: @@ -341,6 +358,8 @@ def screenshot(filename: Optional[str], supersize: int, camera: Optional[str], params["orbitDistance"] = orbit_distance if orbit_fov: params["orbitFov"] = orbit_fov + if scene_view_target: + params["sceneViewTarget"] = scene_view_target result = run_command("manage_scene", params, config) diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index 479343867..cce94dbc8 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -70,6 +70,8 @@ def _extract_images(response: dict[str, Any], action: str) -> ToolResult | None: "Read-only actions: get_hierarchy, get_active, get_build_settings, screenshot, scene_view_frame. " "Modifying actions: create, load, save. " "screenshot supports include_image=true to return an inline base64 PNG for AI vision. " + "screenshot supports capture_source='scene_view' to capture the active Unity Scene View rendering. " + "When using capture_source='scene_view', optionally pass scene_view_target to frame before capture. " "screenshot with batch='surround' captures 6 angles around the scene (no file saved) for comprehensive scene understanding. " "screenshot with batch='orbit' captures configurable azimuth x elevation grid for visual QA (use orbit_angles, orbit_elevations, orbit_distance, orbit_fov). " "screenshot with look_at/view_position creates a temp camera at that viewpoint and returns an inline image." @@ -108,6 +110,9 @@ async def manage_scene( max_resolution: Annotated[int | str, "Max resolution (longest edge in pixels) for the inline image. Default 640. " "Use 256-512 for quick looks, 640-1024 for detail."] | None = None, + capture_source: Annotated[Literal["game_view", "scene_view"], + "Screenshot source. 'game_view' (default) captures via Game View / camera path; " + "'scene_view' captures the active Unity Scene View rendering."] | None = None, # --- screenshot extended params (batch, positioned capture) --- batch: Annotated[str, "Batch capture mode. 'surround' captures 6 fixed angles (front/back/left/right/top/bird_eye). " @@ -188,6 +193,10 @@ async def manage_scene( params["includeImage"] = coerced_include_image if coerced_max_resolution is not None: params["maxResolution"] = coerced_max_resolution + if capture_source is not None: + if action != "screenshot": + return {"success": False, "message": "capture_source is only valid for action='screenshot'."} + params["captureSource"] = capture_source # screenshot extended params (batch, positioned capture) if batch: diff --git a/Server/tests/integration/test_manage_scene_screenshot_params.py b/Server/tests/integration/test_manage_scene_screenshot_params.py index dce067717..0a2314ece 100644 --- a/Server/tests/integration/test_manage_scene_screenshot_params.py +++ b/Server/tests/integration/test_manage_scene_screenshot_params.py @@ -208,6 +208,49 @@ async def fake_send(cmd, params, **kwargs): assert p["lookAt"] == "Enemy" +@pytest.mark.asyncio +async def test_screenshot_scene_view_capture_params(monkeypatch): + """capture_source='scene_view' and scene_view_target are forwarded.""" + captured = {} + + async def fake_send(cmd, params, **kwargs): + captured["params"] = params + return {"success": True, "data": {"filePath": "Assets/shot.png"}} + + monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) + + await manage_scene_mod.manage_scene( + ctx=DummyContext(), + action="screenshot", + capture_source="scene_view", + scene_view_target="Canvas", + include_image=True, + ) + + p = captured["params"] + assert p["action"] == "screenshot" + assert p["captureSource"] == "scene_view" + assert p["sceneViewTarget"] == "Canvas" + assert p["includeImage"] is True + + +@pytest.mark.asyncio +async def test_capture_source_rejected_for_non_screenshot_action(monkeypatch): + async def fake_send(cmd, params, **kwargs): # pragma: no cover - should not be called + return {"success": True, "data": {}} + + monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) + + result = await manage_scene_mod.manage_scene( + ctx=DummyContext(), + action="get_active", + capture_source="scene_view", + ) + + assert result["success"] is False + assert "capture_source is only valid" in result["message"] + + @pytest.mark.asyncio async def test_scene_view_frame_params(monkeypatch): captured = {} diff --git a/Server/uv.lock b/Server/uv.lock index 6d8807693..859814a58 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -123,14 +123,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db wheels = [ { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] @@ -872,7 +882,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.0" }, { name = "fastapi", specifier = ">=0.104.0" }, - { name = "fastmcp", specifier = ">=3.0.0,<4" }, + { name = "fastmcp", specifier = ">=3.0.2,<4" }, { name = "httpx", specifier = ">=0.27.2" }, { name = "mcp", specifier = ">=1.16.0" }, { name = "pydantic", specifier = ">=2.12.5" }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index c0066a89f..338537ccf 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -101,5 +101,20 @@ public void GetHierarchy_PaginatesRoots_AndSupportsChildrenPaging() Assert.IsNotNull(childItems); Assert.AreEqual(7, childItems.Count); } + + [Test] + public void Screenshot_SceneViewRejectsSupersizeAboveOne() + { + var raw = ManageScene.HandleCommand(new JObject + { + ["action"] = "screenshot", + ["captureSource"] = "scene_view", + ["superSize"] = 2, + }); + var response = raw as JObject ?? JObject.FromObject(raw); + + Assert.IsFalse(response.Value("success"), response.ToString()); + StringAssert.Contains("does not support super_size above 1", response.Value("message")); + } } }