Skip to content

Conversation

@dsarno
Copy link
Collaborator

@dsarno dsarno commented Dec 28, 2025

Summary

This PR makes ScriptableObject creation/patching reliable and repeatable when driven via MCP by:

  • Implementing a single unified ScriptableObject tool (manage_scriptable_object) backed by Unity SerializedObject/SerializedProperty paths (instead of reflection).
  • Fixing the “wrong placement” class of issues by validating/normalizing folder paths (preventing silent creation into unintended locations).
  • Adding targeted EditMode coverage for private/inherited fields, nested structs, arrays/lists, and object references.

Motivation / problems addressed

Before, MCP failed to create SOs well, with issues involving:

  • Wrong placement / folder path weirdness (relative paths, backslashes, “Packages/…”).
  • Partial setup failures when patching:
    • private [SerializeField] fields
    • inherited serialized fields
    • arrays/lists (.Array.size + .Array.data[i])
    • nested struct paths
    • UnityEngine.Object references by GUID or asset path
  • Better behavior during transient Unity states (compiling/reloading returns a retryable error code).

Key changes

  • Unity Editor tool

    • Added MCPForUnity/Editor/Tools/ManageScriptableObject.cs
    • Supports:
      • action=create (create asset + optional patches)
      • action=modify (patch existing asset by {guid|path})
    • Patching uses SerializedObject.FindProperty(propertyPath) and applies typed writes, including object reference assignment by GUID/path.
    • Folder handling:
      • normalizes slashes + removes double slashes
      • supports relative paths by rooting under Assets/
      • rejects non-project roots like Packages/, absolute paths, and file://… with invalid_folder_path.
  • Python server wrapper

    • Added Server/src/services/tools/manage_scriptable_object.py to expose the unified tool as a first-class MCP tool.
  • Tests

    • Added fixtures + tests under TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/… covering:
      • nested folder creation and correct placement
      • private [SerializeField] patching
      • inherited serialized field patching
      • nested struct field patching
      • list resize + ref assignment by GUID and by path
      • rejecting invalid folder roots (Packages/absolute/file URI)
      • path normalization (relative + backslashes; no // in result)
    • Added server integration test: Server/tests/integration/test_manage_scriptable_object_tool.py
  • Repo hygiene

    • .gitignore: ignore TestProjects/UnityMCPTests/Assets/Temp/ so temporary test assets never get committed again.
    • Removed vestigial SO tool implementations from the packaged server build output (Server/build/lib/...) so they don’t appear in the tool list anymore.

Testing

  • Unity EditMode: 156/156 passed
  • Unity PlayMode: no PlayMode tests in this project (0)
  • Pytest (repo root): 78 passed, 2 skipped, 7 xpassed

Notes

  • Tool surface is now simpler: ScriptableObject workflows should go through manage_scriptable_object (create/modify + patches) using Unity property paths, matching the design direction in FixscriptableobjecPlan.md.

Summary by Sourcery

Introduce a dedicated manage_scriptable_object workflow for reliable ScriptableObject creation and modification, and update surrounding tooling and tests to use and validate it.

New Features:

  • Add a Unity editor manage_scriptable_object tool to create and patch ScriptableObject assets via SerializedObject property paths.
  • Expose the manage_scriptable_object Unity tool as a first-class MCP server tool endpoint.

Enhancements:

  • Deprecate ScriptableObject creation and rich modification in the generic manage_asset tool in favor of the dedicated manage_scriptable_object workflow.
  • Improve CodexConfigHelper tests to restore only the platform service instead of resetting the entire MCP service locator to avoid disrupting active MCP sessions.

Documentation:

  • Clarify manage_asset tool documentation to direct ScriptableObject scenarios to manage_scriptable_object.

Tests:

  • Add Unity EditMode tests covering ScriptableObject creation, nested folder handling, field and list patching, object reference assignment, and folder path validation for the new tool.
  • Add Python integration tests to verify parameter forwarding for the manage_scriptable_object server tool.

Chores:

  • Update .gitignore and Unity test project meta files to support new temporary asset locations and fixtures.

Summary by CodeRabbit

  • New Features

    • Dedicated tool to create/modify ScriptableObjects with property patching, nested folder creation, and optional overwrite.
  • Bug Fixes

    • ScriptableObject creation removed from the general asset tool; use the dedicated ScriptableObject tool.
  • Documentation

    • README updated to document the new ScriptableObject tool.
  • Tests

    • New integration and Unity editor tests covering create/modify flows and patching.
  • Chores

    • Added ignore rule for Unity Temp artifacts.

✏️ Tip: You can customize this high-level summary in your review settings.

CodexConfigHelperTests was calling MCPServiceLocator.Reset() in TearDown, which disposes the active bridge/transport during MCP-driven test runs. Replace with restoring only the mutated service (IPlatformService).
Capture the original IPlatformService before this fixture runs and restore it in TearDown. This preserves the MCP connection safety fix (no MCPServiceLocator.Reset()) while avoiding global state leakage to subsequent tests.
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Dec 28, 2025

Reviewer's Guide

Introduces a dedicated manage_scriptable_object workflow, deprecates ScriptableObject creation in the generic manage_asset tool, wires the new SO tool through the Python server, and adds focused Unity/Python tests plus minor test-harness and .gitignore hygiene.

Sequence diagram for manage_scriptable_object create with patches

sequenceDiagram
    actor User
    participant Client
    participant PythonServer
    participant ToolWrapper as manage_scriptable_object_py
    participant UnityTransport
    participant UnityEditor
    participant CSHandler as ManageScriptableObject_cs
    participant AssetDB

    User->>Client: Request create ScriptableObject
    Client->>PythonServer: Tool call manage_scriptable_object\n{action:create, type_name, folder_path, asset_name, overwrite, patches}
    PythonServer->>ToolWrapper: Invoke tool function

    ToolWrapper->>ToolWrapper: parse_json_payload(target, patches)\ncoerce_bool(overwrite)
    ToolWrapper->>UnityTransport: send_with_unity_instance\ncommand: manage_scriptable_object\nparams: {action, typeName, folderPath, assetName, overwrite, patches}

    UnityTransport->>UnityEditor: async_send_command_with_retry
    UnityEditor->>CSHandler: HandleCommand(JObject params)

    CSHandler->>CSHandler: NormalizeAction(action)\nJsonUtil.CoerceJsonStringParameter\nCoerceJsonStringArrayParameter
    CSHandler->>CSHandler: HandleCreate(params)
    CSHandler->>CSHandler: TryNormalizeFolderPath(folderPath)
    CSHandler->>CSHandler: EnsureFolderExists(normalizedFolder)
    CSHandler->>CSHandler: ResolveType(typeName)

    CSHandler->>AssetDB: AssetDatabase.CreateAsset or overwrite path
    AssetDB-->>CSHandler: guid, finalPath

    CSHandler->>CSHandler: ApplyPatches(instance, patches)
    CSHandler->>CSHandler: SerializedObject.Update\nApplyPatch per patch
    CSHandler->>CSHandler: ApplyArrayResize / ApplySet\nTrySetValue, TrySetEnum
    CSHandler->>AssetDB: ApplyModifiedProperties\nSaveAssets

    CSHandler-->>UnityEditor: SuccessResponse{guid, path, patchResults, warnings}
    UnityEditor-->>UnityTransport: response
    UnityTransport-->>ToolWrapper: response
    ToolWrapper-->>PythonServer: dict response
    PythonServer-->>Client: tool result
    Client-->>User: Created SO info
Loading

Class diagram for ManageScriptableObject C# tool and Python wrapper

classDiagram
    class ManageScriptableObject {
        <<static>>
        - CodeCompilingOrReloading : string
        - CodeInvalidParams : string
        - CodeTypeNotFound : string
        - CodeInvalidFolderPath : string
        - CodeTargetNotFound : string
        - CodeAssetCreateFailed : string
        - ValidActions : HashSet~string~
        + HandleCommand(params : JObject) object
        - HandleCreate(params : JObject) object
        - HandleModify(params : JObject) object
        - ApplyPatches(target : Object, patches : JArray) (List~object~, List~string~)
        - ApplyPatch(so : SerializedObject, propertyPath : string, op : string, patchObj : JObject, changed : bool) object
        - ApplyArrayResize(so : SerializedObject, propertyPath : string, patchObj : JObject, changed : bool) object
        - ApplySet(so : SerializedObject, propertyPath : string, patchObj : JObject, changed : bool) object
        - TrySetValue(prop : SerializedProperty, valueToken : JToken, message : string) bool
        - TrySetEnum(prop : SerializedProperty, valueToken : JToken, message : string) bool
        - TryResolveTarget(targetToken : JToken, target : Object, targetPath : string, targetGuid : string, error : object) bool
        - CoerceJsonStringArrayParameter(params : JObject, paramName : string) void
        - EnsureFolderExists(folderPath : string, error : string) bool
        - SanitizeSlashes(path : string) string
        - TryNormalizeFolderPath(folderPath : string, normalized : string, error : string) bool
        - TryGetInt(token : JToken, value : int) bool
        - TryGetFloat(token : JToken, value : float) bool
        - TryGetBool(token : JToken, value : bool) bool
        - TryGetVector2(token : JToken, value : Vector2) bool
        - TryGetVector3(token : JToken, value : Vector3) bool
        - TryGetVector4(token : JToken, value : Vector4) bool
        - TryGetColor(token : JToken, value : Color) bool
        - NormalizeAction(raw : string) string
        - IsCreateAction(normalized : string) bool
        - ResolveType(typeName : string) Type
    }

    class ErrorResponse {
        + ErrorResponse(code : string)
        + ErrorResponse(code : string, details : object)
    }

    class SuccessResponse {
        + SuccessResponse(message : string, payload : object)
    }

    class manage_scriptable_object_py {
        + manage_scriptable_object(ctx : Context, action : string, type_name : string, folder_path : string, asset_name : string, overwrite : bool, target : dict~string, Any~, patches : list~dict~) dict~string, Any~
        - parse_json_payload(payload : Any) Any
        - coerce_bool(value : Any, default : bool) bool
    }

    class UnityTransportModule {
        + send_with_unity_instance(sender : function, unity_instance : Any, command : string, params : dict~string, Any~) Any
        + async_send_command_with_retry(unity_instance : Any, command : string, params : dict~string, Any~) Any
    }

    class ToolsRegistry {
        + mcp_for_unity_tool(description : string)
        + get_unity_instance_from_context(ctx : Context) Any
    }

    ManageScriptableObject ..> ErrorResponse : uses
    ManageScriptableObject ..> SuccessResponse : uses
    manage_scriptable_object_py ..> ManageScriptableObject : calls_via_transport
    manage_scriptable_object_py ..> UnityTransportModule : uses
    manage_scriptable_object_py ..> ToolsRegistry : uses
Loading

File-Level Changes

Change Details Files
Deprecate ScriptableObject creation from the generic asset tool in favor of the new dedicated ScriptableObject workflow.
  • Remove ScriptableObject creation branch from CreateAsset and update supported-type error message.
  • Keep ScriptableObject modification via ApplyObjectProperties but mark it as deprecated in favor of manage_scriptable_object.
  • Clarify manage_asset Python wrapper docstring for ScriptableObject users.
MCPForUnity/Editor/Tools/ManageAsset.cs
Server/src/services/tools/manage_asset.py
Add a robust Unity editor tool for creating and modifying ScriptableObjects via SerializedObject/SerializedProperty paths with folder normalization and error codes.
  • Implement ManageScriptableObject.HandleCommand entry point with action dispatch, transient Unity state handling, and JSON string coercion for complex params.
  • Add create flow that resolves ScriptableObject type, normalizes/validates folder paths, creates nested folders, supports GUID-preserving overwrite, and optionally applies patches.
  • Add modify flow that resolves a target asset by guid/path and applies a list of patches.
  • Implement ApplyPatches/ApplyPatch with array_resize and set operations, including object reference assignment by guid/path and typed value writes (ints, floats, bools, strings, enums, vectors, colors).
  • Add helpers for folder normalization (rejecting Packages, absolute paths, file URIs), type resolution across assemblies, and parsing numeric/vector/color values from JSON tokens.
MCPForUnity/Editor/Tools/ManageScriptableObject.cs
Add Unity EditMode fixtures and tests to validate ScriptableObject creation, folder rules, patching, and object reference behavior.
  • Introduce ScriptableObject test fixtures with private/inherited fields, nested structs, and material reference lists.
  • Add tests that create SOs in nested folders with patches applied to private/inherited/nested fields and verify correct asset placement and GUIDs.
  • Add tests that resize lists via SerializedProperty, then assign object references by guid and by path and assert per-patch ok flags.
  • Add negative tests for invalid actions, missing types/targets, invalid folder roots, and path normalization (relative paths, backslashes, double-slash collapse).
  • Add meta files and Temp/Packages project assets to keep the Unity test project consistent.
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs.meta
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs.meta
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs.meta
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures.meta
TestProjects/UnityMCPTests/Assets/Temp.meta
TestProjects/UnityMCPTests/Assets/Packages.meta
TestProjects/UnityMCPTests/ProjectSettings/SceneTemplateSettings.json
Expose manage_scriptable_object as a first-class MCP tool in the Python server with parameter coercion and integration tests.
  • Create manage_scriptable_object Python wrapper that accepts create/modify arguments, coerces JSON-string target/patches and boolean overwrite, and forwards a cleaned params dict to Unity.
  • Wire the wrapper through send_with_unity_instance/async_send_command_with_retry and add basic response-shape validation.
  • Add integration tests to assert that create/modify calls send the correct command name and parameter payload to Unity, including parsed patches and target objects.
Server/src/services/tools/manage_scriptable_object.py
Server/tests/integration/test_manage_scriptable_object_tool.py
Improve test harness behavior to avoid disrupting active MCP sessions and tidy repo hygiene.
  • Change CodexConfigHelperTests to capture the original platform service and restore only that service in TearDown instead of calling MCPServiceLocator.Reset(), avoiding bridge/transport disposal mid-run.
  • Update .gitignore to exclude Unity Temp test asset directories so transient Assets/Temp content is not committed.
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
.gitignore

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 28, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

The PR removes ScriptableObject creation from ManageAsset, adds a dedicated ManageScriptableObject C# editor tool with create/modify/patch support, exposes a Python wrapper (manage_scriptable_object), adds integration and Unity editor tests/fixtures, updates tests to avoid MCPServiceLocator.Reset(), and adds Unity metadata and .gitignore entries.

Changes

Cohort / File(s) Summary
ManageAsset adjustment
MCPForUnity/Editor/Tools/ManageAsset.cs
Server/src/services/tools/manage_asset.py
Removes ScriptableObject creation branch from CreateAsset; updates error messaging to exclude ScriptableObject and directs users to new tool; adds annotation/note in Python parameter help.
New ManageScriptableObject (C#)
MCPForUnity/Editor/Tools/ManageScriptableObject.cs
MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta
New editor tool with HandleCommand(JObject) supporting create/modify, folder normalization/creation, type resolution, GUID-preserving create/overwrite, and SerializedObject/SerializedProperty patch application (set, array_resize) with structured responses.
New ManageScriptableObject (server wrapper)
Server/src/services/tools/manage_scriptable_object.py
New async MCP tool entry that validates/coerces inputs (typeName, folderPath, assetName, overwrite, target, patches) and forwards commands to Unity via send_with_unity_instance.
Server integration tests
Server/tests/integration/test_manage_scriptable_object_tool.py
Tests that manage_scriptable_object forwards create and modify params to async_send_command_with_retry and parses/coerces inputs (overwrite, patches).
Unity test fixtures & types
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs
.../ManageScriptableObjectTestDefinition.cs + .meta files
Adds ScriptableObject base and derived test definitions (serialized fields, accessors) used by editor tests.
Unity editor tests
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs
.../ManageScriptableObjectTests.cs.meta
.../Fixtures.meta
New NUnit suite covering create/modify flows, patch behaviors, path normalization, invalid inputs, and setup/teardown helpers.
Test infra update
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
Replaces MCPServiceLocator.Reset() with saving/restoring original IPlatformService to avoid disrupting MCP session state.
Unity metadata & ignore
.gitignore
TestProjects/UnityMCPTests/Assets/Packages.meta
.../Assets/Temp.meta
TestProjects/UnityMCPTests/ProjectSettings/SceneTemplateSettings.json
Adds .gitignore entry for Unity stress-run Temp folder; adds Unity .meta files and scene template settings JSON.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server as Python Server
    participant Unity as Unity Editor
    participant AssetDB as AssetDatabase
    participant SO as SerializedObject

    rect rgb(240,248,255)
    note over Client,Server: Create ScriptableObject flow
    Client->>Server: manage_scriptable_object(action="create", params)
    Server->>Server: validate & coerce params
    Server->>Unity: send_with_unity_instance("manage_scriptable_object", params)
    Unity->>Unity: validate typeName, normalize folderPath, ensure folders
    Unity->>Unity: resolve ScriptableObject type
    Unity->>AssetDB: Create or overwrite asset (preserve GUID if needed)
    AssetDB->>Unity: asset ready
    Unity->>SO: create SerializedObject(asset)
    loop patches
        Unity->>SO: apply patch (set / array_resize)
        SO-->>Unity: per-patch result
    end
    Unity->>AssetDB: SaveAssets()
    Unity-->>Server: {success, guid, path, type, patch_results}
    Server-->>Client: response
    end
Loading
sequenceDiagram
    participant Client
    participant Server as Python Server
    participant Unity as Unity Editor
    participant SO as SerializedObject

    rect rgb(245,245,220)
    note over Client,Server: Modify ScriptableObject flow
    Client->>Server: manage_scriptable_object(action="modify", target, patches)
    Server->>Server: validate target & patches
    Server->>Unity: send_with_unity_instance("manage_scriptable_object", params)
    Unity->>Unity: resolve asset by guid/path
    Unity->>SO: load SerializedObject(asset)
    loop patches
        Unity->>SO: apply patch to SerializedProperty
        SO-->>Unity: per-patch result
    end
    Unity->>Unity: SaveAssets()
    Unity-->>Server: {success, target_metadata, patch_results}
    Server-->>Client: response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #418 — Modifies ManageAsset.cs handling of asset creation/path logic; overlaps with removal of ScriptableObject creation here.
  • #487 — Updates CodexConfigHelperTests.cs to change MCPServiceLocator reset behavior; same test-change pattern as this PR.
  • #343 — Hardened parameter validation and JSON coercion in ManageAsset tooling; touches the same asset-management surface.

Suggested reviewers

  • justinpbarnett

Poem

🐰 I hopped through folders, GUID in paw,

Patching properties the cleverest law.
New tools to craft and gently to mend,
Assets now ordered — a rabbit's proud bend. ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.22% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feature/Add new manage_scriptable_object tool' accurately describes the primary change: introducing a new tool for managing ScriptableObjects in Unity.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7bb6543 and fb604b1.

📒 Files selected for processing (1)
  • README.md
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: JohanHoltby
Repo: CoplayDev/unity-mcp PR: 309
File: MCPForUnity/Editor/Helpers/ServerInstaller.cs:478-508
Timestamp: 2025-10-13T13:41:00.086Z
Learning: In the MCPForUnityTools feature (MCPForUnity/Editor/Helpers/ServerInstaller.cs), the design intentionally forces users to have only one .py file per MCPForUnityTools folder to keep file tracking simple. Package-style tools (subdirectories with __init__.py) are not supported.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review
🔇 Additional comments (1)
README.md (1)

51-51: ✅ Well-placed and accurate documentation entry.

The new manage_scriptable_object tool is correctly positioned in the alphabetically-ordered Tools list and accurately summarizes its core capability (create/modify via SerializedObject property paths). The description aligns with the implementation and maintains stylistic consistency with other tool entries.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (5)
MCPForUnity/Editor/Tools/ManageScriptableObject.cs (3)

247-299: warnings list is initialized but never populated.

The warnings list (line 249) is returned in the tuple but is never added to within this method. This means patchApply.warnings at line 195 and warnings at line 242 will always be empty lists. Consider either:

  1. Removing the unused warnings infrastructure if not needed
  2. Adding warnings for non-critical issues (e.g., type coercion, deprecated property paths)

417-447: Consider warning when object reference resolution fails.

When both refGuid and refPath are provided but the asset cannot be loaded (lines 430-437), newRef remains null and the reference is silently cleared. Consider adding a warning to inform callers that the reference wasn't found:

 if (!string.IsNullOrEmpty(resolvedPath))
 {
     newRef = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(resolvedPath);
+    // If path resolved but asset not found, could add warning here
 }

This is a minor UX improvement for debugging failed patches.


677-722: Consider adding path traversal validation.

The path validation rejects absolute paths and restricted roots, but doesn't explicitly handle .. traversal. A malicious path like "Assets/../Packages/Malicious" would pass the StartsWith("Assets/") check but could escape the Assets directory.

While Unity's AssetDatabase.CreateFolder may reject such paths, explicit validation would provide defense-in-depth:

🔎 Suggested enhancement
 private static bool TryNormalizeFolderPath(string folderPath, out string normalized, out string error)
 {
     // ... existing code ...
     
+    // Reject path traversal attempts
+    if (s.Contains(".."))
+    {
+        error = "Folder path must not contain path traversal sequences.";
+        return false;
+    }
+
     if (s.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs (2)

221-256: Consider adding a Windows drive path test case.

The test covers Unix-style absolute paths (/tmp/...) and file:// URIs, but the PR objectives mention rejecting Windows drive/absolute paths. Adding a test case like C:\Users\... or D:\Projects\... would ensure cross-platform validation coverage.

🔎 Suggested additional test case
         Assert.IsFalse(badFileUri.Value<bool>("success"));
         Assert.AreEqual("invalid_folder_path", badFileUri.Value<string>("error"));
+
+        var badWindowsDrive = ToJObject(ManageScriptableObject.HandleCommand(new JObject
+        {
+            ["action"] = "create",
+            ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
+            ["folderPath"] = @"C:\Users\Test",
+            ["assetName"] = "BadFolder4",
+            ["overwrite"] = true,
+        }));
+        Assert.IsFalse(badWindowsDrive.Value<bool>("success"));
+        Assert.AreEqual("invalid_folder_path", badWindowsDrive.Value<string>("error"));
     }

258-278: Minor: Consider tracking _createdAssetPath for consistency.

This test creates an asset but doesn't set _createdAssetPath, unlike other tests. While cleanup still works because the asset is under TempRoot, tracking it explicitly would maintain consistency and ensure cleanup if the folder structure changes.

🔎 Suggested fix
         var res = ToJObject(ManageScriptableObject.HandleCommand(create));
         Assert.IsTrue(res.Value<bool>("success"), res.ToString());

-        var path = res["data"]?["path"]?.ToString();
+        _createdAssetPath = res["data"]?["path"]?.ToString();
+        var path = _createdAssetPath;
         Assert.IsNotNull(path, "Expected path in response.");
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 523e81b and 7bb6543.

📒 Files selected for processing (18)
  • .gitignore
  • MCPForUnity/Editor/Tools/ManageAsset.cs
  • MCPForUnity/Editor/Tools/ManageScriptableObject.cs
  • MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta
  • Server/src/services/tools/manage_asset.py
  • Server/src/services/tools/manage_scriptable_object.py
  • Server/tests/integration/test_manage_scriptable_object_tool.py
  • TestProjects/UnityMCPTests/Assets/Packages.meta
  • TestProjects/UnityMCPTests/Assets/Temp.meta
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures.meta
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs.meta
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs.meta
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs.meta
  • TestProjects/UnityMCPTests/ProjectSettings/SceneTemplateSettings.json
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
Learnt from: JohanHoltby
Repo: CoplayDev/unity-mcp PR: 309
File: MCPForUnity/Editor/Helpers/ServerInstaller.cs:478-508
Timestamp: 2025-10-13T13:41:00.086Z
Learning: In the MCPForUnityTools feature (MCPForUnity/Editor/Helpers/ServerInstaller.cs), the design intentionally forces users to have only one .py file per MCPForUnityTools folder to keep file tracking simple. Package-style tools (subdirectories with __init__.py) are not supported.
📚 Learning: 2025-10-13T13:41:00.086Z
Learnt from: JohanHoltby
Repo: CoplayDev/unity-mcp PR: 309
File: MCPForUnity/Editor/Helpers/ServerInstaller.cs:478-508
Timestamp: 2025-10-13T13:41:00.086Z
Learning: In the MCPForUnityTools feature (MCPForUnity/Editor/Helpers/ServerInstaller.cs), the design intentionally forces users to have only one .py file per MCPForUnityTools folder to keep file tracking simple. Package-style tools (subdirectories with __init__.py) are not supported.

Applied to files:

  • .gitignore
  • Server/src/services/tools/manage_scriptable_object.py
📚 Learning: 2025-10-13T13:27:23.040Z
Learnt from: msanatan
Repo: CoplayDev/unity-mcp PR: 316
File: TestProjects/UnityMCPTests/Assets/Tests/EditMode/Resources.meta:1-8
Timestamp: 2025-10-13T13:27:23.040Z
Learning: UnityMcpBridge is a legacy project kept for backwards compatibility; MCPForUnity is the only active Unity plugin project. GUID collisions between UnityMcpBridge and MCPForUnity are acceptable.

Applied to files:

  • .gitignore
📚 Learning: 2025-11-05T18:23:12.349Z
Learnt from: msanatan
Repo: CoplayDev/unity-mcp PR: 368
File: MCPForUnity/UnityMcpServer~/src/resources/menu_items.py:15-15
Timestamp: 2025-11-05T18:23:12.349Z
Learning: In Unity MCP, the `name` parameter in the `mcp_for_unity_resource` decorator is the external API name exposed to MCP clients (LLMs, AI agents). The command string passed to `async_send_command_with_retry` or `async_send_with_unity_instance` (e.g., "get_menu_items") is the internal command identifier that must match the C# side. These are decoupled, allowing external API naming to evolve independently of internal command routing.

Applied to files:

  • Server/src/services/tools/manage_scriptable_object.py
📚 Learning: 2025-09-03T16:00:55.839Z
Learnt from: dsarno
Repo: CoplayDev/unity-mcp PR: 0
File: :0-0
Timestamp: 2025-09-03T16:00:55.839Z
Learning: ComponentResolver in UnityMcpBridge/Editor/Tools/ManageGameObject.cs is a nested static class within ManageGameObject, not a sibling type. The `using static MCPForUnity.Editor.Tools.ManageGameObject;` import is required to access ComponentResolver methods directly without the outer class qualifier.

Applied to files:

  • MCPForUnity/Editor/Tools/ManageScriptableObject.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs
  • TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs
📚 Learning: 2025-09-04T01:01:11.927Z
Learnt from: dsarno
Repo: CoplayDev/unity-mcp PR: 260
File: UnityMcpBridge/UnityMcpServer~/src/server_version.txt:1-1
Timestamp: 2025-09-04T01:01:11.927Z
Learning: The UnityMcpBridge project is not maintaining changelogs yet, so don't suggest adding changelog entries for version bumps.

Applied to files:

  • TestProjects/UnityMCPTests/Assets/Packages.meta
🧬 Code graph analysis (6)
Server/src/services/tools/manage_scriptable_object.py (3)
Server/src/services/tools/utils.py (2)
  • coerce_bool (11-24)
  • parse_json_payload (27-60)
Server/src/transport/unity_transport.py (1)
  • send_with_unity_instance (78-103)
Server/src/transport/legacy/unity_connection.py (1)
  • async_send_command_with_retry (753-785)
MCPForUnity/Editor/Tools/ManageScriptableObject.cs (2)
MCPForUnity/Editor/Tools/JsonUtil.cs (2)
  • JsonUtil (6-29)
  • CoerceJsonStringParameter (12-28)
MCPForUnity/Editor/Helpers/AssetPathUtility.cs (2)
  • AssetPathUtility (15-201)
  • SanitizeAssetPath (20-34)
Server/tests/integration/test_manage_scriptable_object_tool.py (2)
Server/tests/integration/test_helpers.py (2)
  • DummyContext (16-55)
  • set_state (49-51)
Server/src/services/tools/manage_scriptable_object.py (1)
  • manage_scriptable_object (27-72)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs (2)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs (1)
  • ManageScriptableObjectTestDefinitionBase (6-10)
MCPForUnity/Editor/Tools/ManageScriptableObject.cs (1)
  • List (247-299)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs (1)
MCPForUnity/Editor/Services/MCPServiceLocator.cs (2)
  • MCPServiceLocator (11-91)
  • Register (40-62)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs (3)
MCPForUnity/Editor/Tools/BatchExecute.cs (1)
  • JArray (153-161)
MCPForUnity/Editor/Tools/ManageScriptableObject.cs (1)
  • HandleCommand (42-77)
MCPForUnity/Editor/Helpers/AssetPathUtility.cs (2)
  • AssetPathUtility (15-201)
  • SanitizeAssetPath (20-34)
🪛 Ruff (0.14.10)
Server/tests/integration/test_manage_scriptable_object_tool.py

10-10: Unused function argument: kwargs

(ARG001)


46-46: Unused function argument: kwargs

(ARG001)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Sourcery review
🔇 Additional comments (36)
TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs (2)

35-35: LGTM: Clean capture of original service state.

The field addition and OneTimeSetUp capture correctly preserve the platform service state before this fixture's tests potentially mock it. This enables targeted restoration without disrupting other services.

Also applies to: 44-44


59-68: Excellent targeted fix with clear documentation.

The refactor from MCPServiceLocator.Reset() to selective service restoration directly addresses the issue of disrupting active MCP sessions during test execution. The approach is sound:

  • Surgical restoration: Only restores the platform service that this fixture mutates, avoiding disposal of the bridge/transport manager that would kill active MCP connections.
  • Proper lifecycle: Restoration in TearDown (not OneTimeTearDown) ensures each test gets a clean slate while preventing test pollution.
  • Clear rationale: Comments explicitly document why this pattern is needed, helping future maintainers.

The defensive null check at Line 65 is fine, though MCPServiceLocator.Platform never returns null (lazy-initializes via ??= new PlatformService()).

.gitignore (1)

37-38: LGTM!

Good addition to prevent test artifacts from being committed. The pattern correctly targets the specific test output directory.

TestProjects/UnityMCPTests/Assets/Temp.meta (1)

1-8: LGTM!

Standard Unity folder meta file. Tracking the meta while ignoring the folder contents is the correct approach to preserve folder structure and GUID stability.

TestProjects/UnityMCPTests/Assets/Packages.meta (1)

1-8: LGTM!

Standard Unity folder meta file for the Packages directory.

TestProjects/UnityMCPTests/ProjectSettings/SceneTemplateSettings.json (1)

1-121: LGTM!

Standard Unity-generated scene template settings file with default dependency type configurations.

MCPForUnity/Editor/Tools/ManageAsset.cs (2)

251-253: LGTM!

Error message correctly updated to reflect that ScriptableObject creation has been removed from this tool. The supported types list (Folder, Material, PhysicsMaterial) now accurately reflects the tool's capabilities.


423-429: LGTM!

Good migration strategy: the modification path is preserved for backward compatibility with a clear deprecation note directing users to manage_scriptable_object. This allows existing workflows to continue functioning while encouraging adoption of the more robust serialized property-based approach.

Server/tests/integration/test_manage_scriptable_object_tool.py (2)

7-40: LGTM!

Comprehensive test for the create action covering parameter forwarding, JSON string parsing for patches, and boolean coercion for overwrite. The **kwargs in the mock is intentionally matching the real function signature which accepts optional keyword arguments like instance_id and loop.


43-70: LGTM!

Good coverage for the modify action, testing both JSON string target parsing and native list patches forwarding.

Server/src/services/tools/manage_scriptable_object.py (2)

1-22: LGTM!

Clear docstring documenting the Unity-side handler, command name, and supported actions. Imports are minimal and appropriate.


24-72: LGTM!

Well-structured tool implementation:

  • Proper use of Annotated types for clear parameter documentation
  • Tolerant JSON parsing handles LLM stringification of complex objects
  • Type validation with user-friendly error messages
  • Correct snake_case to camelCase mapping for Unity params
  • None filtering keeps Unity handler cleaner
  • Response validation with fallback error handling
Server/src/services/tools/manage_asset.py (1)

24-25: LGTM!

Good documentation update directing users to manage_scriptable_object for ScriptableObject operations. This provides clear guidance in the tool's API surface.

MCPForUnity/Editor/Tools/ManageScriptableObject.cs (8)

1-41: LGTM - Well-structured class setup with clear error codes.

The constant error codes and valid actions set are well-defined. The use of StringComparer.OrdinalIgnoreCase for the action set is appropriate.


42-77: LGTM - Clean command routing with appropriate state checks.

The handling of Unity's compile/reload state with a retryable hint is good for MCP clients. The action normalization and routing logic is clear.


142-172: Well-implemented GUID preservation for overwrites.

The logic correctly handles three scenarios:

  1. Existing asset of same type → CopySerialized preserves GUID
  2. Existing asset of different type → Delete and recreate
  3. No existing asset → Standard creation

The asset name restoration at line 157 is a good fix for Unity warnings.


214-245: LGTM - Clean modify flow with proper validation.


460-538: LGTM - Comprehensive type handling with proper error messages.

The switch covers common Unity property types, and the enum handling with both index and name lookup is flexible.


540-583: LGTM - Target resolution with proper fallback handling.

The GUID computation at line 581 ensures the response always includes the target GUID even when resolved by path.


846-876: LGTM - Color parsing with sensible alpha defaults.

The alpha channel correctly defaults to 1.0 when not provided or when parsing fails, which matches Unity's expected behavior for opaque colors.


890-929: LGTM - Robust type resolution with multiple fallback strategies.

The three-pass type resolution (Type.GetType → assembly scan → FullName scan) handles various edge cases, and the ReflectionTypeLoadException handling at line 915 is good defensive coding for assemblies with load issues.

MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta (1)

1-14: LGTM - Standard Unity meta file.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs.meta (1)

1-11: LGTM - Standard Unity meta file for test fixture.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs.meta (1)

1-11: LGTM - Standard Unity meta file for base fixture.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures.meta (1)

1-8: LGTM - Standard Unity folder meta file.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs.meta (1)

1-11: LGTM - Standard Unity meta file for test class.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinitionBase.cs (1)

1-14: LGTM - Clean base test fixture.

Follows Unity serialization best practices with a private [SerializeField] field and public read-only accessor. The comment about filename matching class name is helpful for maintainers.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/Fixtures/ManageScriptableObjectTestDefinition.cs (1)

1-27: LGTM - Well-designed test fixture for comprehensive testing.

The fixture covers key serialization scenarios:

  • Inheritance (via base class)
  • Nested structs (ManageScriptableObjectNestedData)
  • Object reference lists (List<Material>)
  • IReadOnlyList<T> accessor provides good encapsulation

This enables thorough testing of the ApplyPatches functionality in ManageScriptableObject.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs (8)

1-21: LGTM!

Clean setup with appropriate imports and well-organized constants and fields for tracking test artifacts.


22-48: LGTM!

Good test isolation with unique material names via GUIDs, and the shader fallback chain appropriately handles different render pipelines.


50-84: LGTM!

Thorough best-effort cleanup that handles individual assets and folder hierarchies, with the parent folder cleanup being a nice touch for test hygiene.


86-124: LGTM!

Comprehensive happy-path test covering folder creation, path normalization, and patch application across private fields, inherited fields, and nested struct properties.


126-189: LGTM!

Excellent coverage of modify workflows including array resize and object reference assignment via both GUID and path, with per-patch result verification for better failure diagnostics.


191-219: LGTM!

Good negative test coverage verifying specific error codes for invalid action, missing type, and missing target scenarios.


280-301: LGTM!

Clean recursive folder creation using AssetDatabase.CreateFolder, properly leveraging AssetPathUtility.SanitizeAssetPath for consistent path handling.


303-307: LGTM!

Simple and effective utility for normalizing command results to JObject.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 5 issues, and left some high level feedback:

  • The warnings list returned from ApplyPatches is never populated, so you can either remove it from the API surface or start adding concrete warning cases (e.g., best-effort fallbacks in ApplyArrayResize) to make it useful.
  • TryResolveTarget currently accepts any UnityEngine.Object, which means manage_scriptable_object can operate on non-ScriptableObject assets; consider either constraining this to ScriptableObject or explicitly documenting that broader behavior.
  • In ManageScriptableObject.HandleCreate you call AssetDatabase.SaveAssets() after patching, while ApplyPatches also saves assets when anyChanged is true; you could centralize the save call in one place to avoid redundant saves.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `warnings` list returned from `ApplyPatches` is never populated, so you can either remove it from the API surface or start adding concrete warning cases (e.g., best-effort fallbacks in `ApplyArrayResize`) to make it useful.
- `TryResolveTarget` currently accepts any `UnityEngine.Object`, which means `manage_scriptable_object` can operate on non-ScriptableObject assets; consider either constraining this to `ScriptableObject` or explicitly documenting that broader behavior.
- In `ManageScriptableObject.HandleCreate` you call `AssetDatabase.SaveAssets()` after patching, while `ApplyPatches` also saves assets when `anyChanged` is true; you could centralize the save call in one place to avoid redundant saves.

## Individual Comments

### Comment 1
<location> `MCPForUnity/Editor/Tools/ManageScriptableObject.cs:301-321` </location>
<code_context>
+            changed = false;
+            try
+            {
+                string normalizedOp = op.Trim().ToLowerInvariant();
+
+                switch (normalizedOp)
+                {
+                    case "array_resize":
+                        return ApplyArrayResize(so, propertyPath, patchObj, out changed);
+                    case "set":
+                    default:
+                        return ApplySet(so, propertyPath, patchObj, out changed);
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Unknown operations silently fall back to `set`, which can hide caller errors.

In `ApplyPatch`, any op other than `array_resize` is treated as `set`, so a typo like `"op": "arrary_resize"` will silently act as `set` instead of failing. Consider validating `normalizedOp` against the allowed operations and returning a structured error for unsupported values instead of defaulting to `set`.

```suggestion
        private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
        {
            changed = false;
            try
            {
                string normalizedOp = (op ?? string.Empty).Trim().ToLowerInvariant();

                switch (normalizedOp)
                {
                    case "array_resize":
                        return ApplyArrayResize(so, propertyPath, patchObj, out changed);

                    case "set":
                        return ApplySet(so, propertyPath, patchObj, out changed);

                    default:
                        return new
                        {
                            propertyPath,
                            op,
                            ok = false,
                            message = $"Unsupported op '{op}'. Supported ops are: 'set', 'array_resize'."
                        };
                }
            }
            catch (Exception ex)
            {
                return new { propertyPath, op, ok = false, message = ex.Message };
            }
        }
```
</issue_to_address>

### Comment 2
<location> `MCPForUnity/Editor/Tools/ManageScriptableObject.cs:279-280` </location>
<code_context>
+                    op = "set";
+                }
+
+                var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
+                anyChanged |= changed;
+                results.Add(patchResult);
+
</code_context>

<issue_to_address>
**suggestion (performance):** The `changed` flag is set to `ok` in `TrySetValue`, which can trigger saves even when values are unchanged.

In `ApplyPatches`, `anyChanged` is derived from `changed`, which is set in `ApplySet` as `changed = ok;`. Because `TrySetValue` returns `ok` on successful parsing rather than on actual value changes, writing the same value still sets `changed = true`, causing `ApplyModifiedProperties`, `SetDirty`, and `SaveAssets` to run unnecessarily. To avoid redundant serialization/saves, compare the existing and new values for each supported property type and only set `changed = true` when they differ.

Suggested implementation:

```csharp
                if (string.IsNullOrWhiteSpace(op))
                {
                    op = "set";
                }

                var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
                anyChanged |= changed;
                results.Add(patchResult);

namespace MCPForUnity.Editor.Tools

```

```csharp
        private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
        {
            changed = false;

            var valueToken = patchObj["value"];
            if (valueToken == null)
            {
                return new { propertyPath, op = "set", ok = false, message = "Missing required field: value" };
            }

            var property = so.FindProperty(propertyPath);
            if (property == null)
            {
                return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" };
            }

            var ok = TrySetValue(property, valueToken, out bool valueChanged, out string message);
            changed = valueChanged;

            if (ok)
            {
                property.serializedObject.ApplyModifiedProperties();
                return new { propertyPath, op = "set", ok = true };
            }

            return new { propertyPath, op = "set", ok = false, message };
        }

```

```csharp
        private static bool TrySetValue(SerializedProperty property, JToken valueToken, out bool changed, out string message)
        {
            message = null;
            changed = false;

            try
            {
                switch (property.propertyType)
                {
                    case SerializedPropertyType.Integer:
                    {
                        int newValue = valueToken.Value<int>();
                        int oldValue = property.intValue;
                        if (oldValue == newValue)
                            return true;

                        property.intValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.Boolean:
                    {
                        bool newValue = valueToken.Value<bool>();
                        bool oldValue = property.boolValue;
                        if (oldValue == newValue)
                            return true;

                        property.boolValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.Float:
                    {
                        float newValue = valueToken.Value<float>();
                        float oldValue = property.floatValue;
                        if (Mathf.Approximately(oldValue, newValue))
                            return true;

                        property.floatValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.String:
                    {
                        string newValue = valueToken.Value<string>();
                        string oldValue = property.stringValue;
                        if (string.Equals(oldValue, newValue, StringComparison.Ordinal))
                            return true;

                        property.stringValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.ObjectReference:
                    {
                        // Delegate to existing logic; it should set the reference only when changed if possible.
                        // If not, this still avoids marking changes for failed parses.
                        bool ok = TrySetObjectReference(property, valueToken, out bool refChanged, out message);
                        changed = refChanged;
                        return ok;
                    }

                    case SerializedPropertyType.Enum:
                    {
                        // Delegate to existing enum logic and propagate the changed flag.
                        bool ok = TrySetEnum(property, valueToken, out bool enumChanged, out message);
                        changed = enumChanged;
                        return ok;
                    }

                    // Add similar comparison logic for other supported property types as needed.
                    default:
                        message = $"Unsupported property type: {property.propertyType}";
                        return false;
                }

                return true;
            }
            catch (Exception ex)
            {
                message = $"Failed to set value: {ex.Message}";
                changed = false;
                return false;
            }
        }

```

Because only a portion of the file is visible, you will also need to:

1. Update any existing calls to `TrySetValue` (other than the one in `ApplySet` already patched above) to use the new signature:
   - From: `TrySetValue(property, valueToken, out string message)`
   - To: `TrySetValue(property, valueToken, out bool changed, out string message)` (and either use or discard `changed` as appropriate).
2. Adjust the helper methods for complex types to match the new pattern:
   - Change `TrySetObjectReference` to:  
     `private static bool TrySetObjectReference(SerializedProperty property, JToken valueToken, out bool changed, out string message)`  
     and implement it so `changed` is `true` only when the reference actually changes.
   - Change `TrySetEnum` to:  
     `private static bool TrySetEnum(SerializedProperty property, JToken valueToken, out bool changed, out string message)`  
     and set `changed` only when the enum value differs from the current one.
3. If you support additional property types (e.g., `Vector2`, `Vector3`, `Color`, arrays, etc.), mirror the same “compare then assign” pattern inside the `switch (property.propertyType)` so `changed` only becomes `true` when the underlying value is different.
4. Ensure `using UnityEngine;` and `using System;` are present at the top of the file so `Mathf` and `StringComparison` compile correctly.
</issue_to_address>

### Comment 3
<location> `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs:87` </location>
<code_context>
+        }
+
+        [Test]
+        public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()
+        {
+            var create = new JObject
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding a test that asserts GUID-preserving overwrite semantics for `overwrite=true`.

The `ManageScriptableObject` logic is supposed to overwrite an existing asset in-place (preserving its GUID) when `overwrite` is true and the existing type matches. Current tests cover folder creation and patching but not this GUID behavior. Please add or extend a test that:

1. Creates an asset at a known path.
2. Records its GUID.
3. Calls `create` again with the same `folderPath`/`assetName` and `overwrite=true`.
4. Asserts the path is unchanged and `AssetDatabase.AssetPathToGUID(path)` matches the original GUID.

This will exercise the GUID-preserving branch and guard against regressions in the overwrite flow.

Suggested implementation:

```csharp
            AssetDatabase.Refresh();
        }

        [Test]
        public void Create_WithOverwriteTrue_PreservesGuid_WhenExistingAssetTypeMatches()
        {
            // Arrange: ensure the nested folder exists
            var folderSegments = NestedFolder.Split('/');
            var currentPath = "Assets";
            for (var i = 1; i < folderSegments.Length; i++)
            {
                var nextPath = $"{currentPath}/{folderSegments[i]}";
                if (!AssetDatabase.IsValidFolder(nextPath))
                {
                    AssetDatabase.CreateFolder(currentPath, folderSegments[i]);
                }

                currentPath = nextPath;
            }

            var assetPath = $"{NestedFolder}/My_Test_Def.asset";

            // Create an initial asset at the target path
            var originalAsset = ScriptableObject.CreateInstance<ManageScriptableObjectTestDefinition>();
            originalAsset.displayName = "Original";
            originalAsset.baseNumber = 1;

            AssetDatabase.CreateAsset(originalAsset, assetPath);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            var originalGuid = AssetDatabase.AssetPathToGUID(assetPath);
            Assert.IsFalse(string.IsNullOrEmpty(originalGuid), "Expected initial asset to have a valid GUID.");

            // Act: run the create command again with overwrite=true for the same path
            var create = new JObject
            {
                ["action"] = "create",
                ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
                ["folderPath"] = NestedFolder,
                ["assetName"] = "My_Test_Def",
                ["overwrite"] = true,
                ["patches"] = new JArray
                {
                    new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Overwritten" },
                    new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 99 },
                },
            };

            // This should hit the overwrite-in-place branch and preserve the existing GUID
            ManageScriptableObject.Execute(create);

            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            // Assert: same path, same GUID, updated data
            var newGuid = AssetDatabase.AssetPathToGUID(assetPath);
            Assert.AreEqual(originalGuid, newGuid, "GUID should be preserved when overwriting an existing asset of the same type.");
            Assert.AreEqual(assetPath, AssetDatabase.GUIDToAssetPath(originalGuid), "Asset path should remain unchanged.");

            var overwrittenAsset =
                AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(assetPath);
            Assert.IsNotNull(overwrittenAsset, "Overwritten asset should still be loadable at the same path.");
            Assert.AreEqual("Overwritten", overwrittenAsset.displayName);
            Assert.AreEqual(99, overwrittenAsset.baseNumber);
        }

        [Test]
        public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()

```

1. The invocation `ManageScriptableObject.Execute(create);` should be updated to match whatever entry point the existing tests use to run the `ManageScriptableObject` logic (for example, `ManageScriptableObject.Run(create)`, `ManageScriptableObject.Handle(create)`, or similar). Use the same call signature and parameter shape as in the other tests in this fixture.
2. If `NestedFolder` already includes `"Assets"` or has a different format, adjust the folder creation logic accordingly so it does not attempt to create duplicate root segments.
3. Ensure the necessary `using` directives are present at the top of the file (they likely already are, but this test relies on `ScriptableObject`, `AssetDatabase`, `JObject`, and `JArray`).
</issue_to_address>

### Comment 4
<location> `TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectTests.cs:127` </location>
<code_context>
+        }
+
+        [Test]
+        public void Modify_ArrayResize_ThenAssignObjectRefs_ByGuidAndByPath()
+        {
+            // Create base asset first with no patches.
</code_context>

<issue_to_address>
**suggestion (testing):** Add coverage for clearing an object reference to null via patches.

`ApplySet` is designed to clear `ObjectReference` properties when `ref` is omitted and `value` is `null`, but this path isn’t currently tested. Please extend this test (or add a new one) to apply a patch like `{"propertyPath": "materials.Array.data[0]", "op": "set", "value": null}` after the initial assignments, and assert that `asset.Materials[0]` becomes `null` while `asset.Materials[1]` is unchanged. This will exercise the null-clearing branch.

Suggested implementation:

```csharp
            var create = new JObject
            {
                ["action"] = "create",
                ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
                ["folderPath"] = TempRoot,
                ["assetName"] = "Modify_Target",
                ["overwrite"] = true
            };
            var createRes = ToJObject(ManageScriptableObject.HandleCommand(create));
            Assert.IsTrue(createRes.Value<bool>("success"), createRes.ToString());

            var createdAssetPath = createRes.Value<string>("assetPath");
            Assert.IsFalse(string.IsNullOrWhiteSpace(createdAssetPath), "Expected assetPath in create response.");

            // Create two material assets that we'll assign by guid and by path.
            var material1Path = System.IO.Path.Combine(TempRoot, "Modify_ArrayResize_Material_1.mat").Replace('\\', '/');
            var material2Path = System.IO.Path.Combine(TempRoot, "Modify_ArrayResize_Material_2.mat").Replace('\\', '/');

            var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
            Assert.IsNotNull(shader, "Expected to find at least one default shader for test materials.");

            var material1 = new Material(shader);
            var material2 = new Material(shader);

            AssetDatabase.CreateAsset(material1, material1Path);
            AssetDatabase.CreateAsset(material2, material2Path);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            var material1Guid = AssetDatabase.AssetPathToGUID(material1Path);
            Assert.IsFalse(string.IsNullOrWhiteSpace(material1Guid), "Expected GUID for first material.");

            // First modify: resize materials array to 2, then assign by guid and by path.
            var modifyInitial = new JObject
            {
                ["action"] = "modify",
                ["assetPath"] = createdAssetPath,
                ["patches"] = new JArray
                {
                    // Resize array
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.size",
                        ["value"] = 2
                    },
                    // Assign element 0 by guid
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[0]",
                        ["ref"] = new JObject
                        {
                            ["guid"] = material1Guid
                        }
                    },
                    // Assign element 1 by path
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[1]",
                        ["ref"] = new JObject
                        {
                            ["assetPath"] = material2Path
                        }
                    }
                }
            };

            var modifyInitialRes = ToJObject(ManageScriptableObject.HandleCommand(modifyInitial));
            Assert.IsTrue(modifyInitialRes.Value<bool>("success"), modifyInitialRes.ToString());

            // Verify both material references were applied.
            var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(createdAssetPath);
            Assert.IsNotNull(asset, "Modified asset should load as TestDefinition after initial modify.");
            Assert.IsNotNull(asset!.Materials, "Materials array should not be null after resize.");
            Assert.AreEqual(2, asset.Materials.Length, "Materials array size should be 2 after resize.");
            Assert.IsNotNull(asset.Materials[0], "Materials[0] should be assigned by guid after initial modify.");
            Assert.IsNotNull(asset.Materials[1], "Materials[1] should be assigned by path after initial modify.");

            // Second modify: clear the first material reference by setting value:null and omitting ref.
            var modifyClearRef = new JObject
            {
                ["action"] = "modify",
                ["assetPath"] = createdAssetPath,
                ["patches"] = new JArray
                {
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[0]",
                        // ApplySet should interpret value:null with no "ref" as "clear the ObjectReference".
                        ["value"] = JValue.CreateNull()
                    }
                }
            };

            var modifyClearRefRes = ToJObject(ManageScriptableObject.HandleCommand(modifyClearRef));
            Assert.IsTrue(modifyClearRefRes.Value<bool>("success"), modifyClearRefRes.ToString());

            // Reload and verify that only the first reference was cleared.
            asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(createdAssetPath);
            Assert.IsNotNull(asset, "Modified asset should still be loadable after clearing reference.");
            Assert.IsNotNull(asset!.Materials, "Materials array should still exist after clearing reference.");
            Assert.AreEqual(2, asset.Materials.Length, "Materials array size should remain 2 after clearing reference.");
            Assert.IsNull(asset.Materials[0], "Materials[0] should be cleared (null) when value:null and no ref are applied.");
            Assert.IsNotNull(asset.Materials[1], "Materials[1] should remain unchanged when only Materials[0] is cleared.");

```

This edit assumes that:
1. `ManageScriptableObjectTestDefinition` has a `public Material[] Materials` (or similarly named) field/prop serialized under the path `"materials"`. If the backing field/property uses a different name, update `"materials.Array.*"` and `asset.Materials` accordingly.
2. `ToJObject`, `TempRoot`, and `ManageScriptableObject.HandleCommand` are already defined and used consistently elsewhere in the test file.
3. The original test body did not contain additional logic after the `Assert.IsTrue(createRes...)` line. If it did, you should merge any existing modify/verify logic with the new two-phase modify flow above (e.g., incorporate existing patches into `modifyInitial`).
</issue_to_address>

### Comment 5
<location> `Server/tests/integration/test_manage_scriptable_object_tool.py:7` </location>
<code_context>
+import services.tools.manage_scriptable_object as mod
+
+
+def test_manage_scriptable_object_forwards_create_params(monkeypatch):
+    captured = {}
+
</code_context>

<issue_to_address>
**suggestion (testing):** Add integration tests for invalid `target`/`patches` inputs in the Python wrapper.

Since the wrapper validates input via `parse_json_payload`, consider adding tests that cover malformed payloads:

- Non-JSON `target` string → expect `{"success": False, "message": ...}` describing the expected shape.
- Non-list `patches` (e.g., scalar or dict) → expect the "patches must be a list" error.

These will ensure the defensive behavior is exercised and preserved.

Suggested implementation:

```python
def test_manage_scriptable_object_forwards_create_params(monkeypatch):
    captured = {}

    async def fake_async_send(cmd, params, **kwargs):
        captured["cmd"] = cmd
        captured["params"] = params


def test_manage_scriptable_object_rejects_non_json_target(monkeypatch):
    ctx = DummyContext()

    async def noop_async_send(*_, **__):
        return None

    # Avoid any real side effects when the tool tries to send commands
    monkeypatch.setattr(ctx, "async_send", noop_async_send)

    result = asyncio.run(
        mod.manage_scriptable_object(
            ctx,
            target="not valid json",  # malformed JSON string
            patches=[],               # structurally valid for this test
        )
    )

    assert isinstance(result, dict)
    assert result.get("success") is False
    # Message should explain the issue with the target / JSON shape
    message = result.get("message", "")
    assert message
    assert "target" in message or "json" in message.lower()


def test_manage_scriptable_object_rejects_non_list_patches(monkeypatch):
    ctx = DummyContext()

    async def noop_async_send(*_, **__):
        return None

    monkeypatch.setattr(ctx, "async_send", noop_async_send)

    # Valid JSON target, but invalid patches payloads
    for invalid_patches in ({}, 123, "not-a-list", None):
        result = asyncio.run(
            mod.manage_scriptable_object(
                ctx,
                target="{}",  # minimally valid JSON object
                patches=invalid_patches,
            )
        )

        assert isinstance(result, dict)
        assert result.get("success") is False
        # Message should reflect that patches must be a list
        message = result.get("message", "")
        assert message
        assert "list" in message.lower()

```

These tests assume:
1. `manage_scriptable_object` is an `async` function taking `(context, target, patches)` and returning a `dict` with `success` and `message` keys. If the actual wrapper signature or return shape differs, update the calls and assertions accordingly.
2. The error message for malformed `target` mentions either "target" or "JSON", and for invalid `patches` includes "list". If your concrete error messages are more specific (e.g. `"patches must be a list"`), adjust the string checks to match them exactly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +301 to +321
private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
{
changed = false;
try
{
string normalizedOp = op.Trim().ToLowerInvariant();

switch (normalizedOp)
{
case "array_resize":
return ApplyArrayResize(so, propertyPath, patchObj, out changed);
case "set":
default:
return ApplySet(so, propertyPath, patchObj, out changed);
}
}
catch (Exception ex)
{
return new { propertyPath, op, ok = false, message = ex.Message };
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Unknown operations silently fall back to set, which can hide caller errors.

In ApplyPatch, any op other than array_resize is treated as set, so a typo like "op": "arrary_resize" will silently act as set instead of failing. Consider validating normalizedOp against the allowed operations and returning a structured error for unsupported values instead of defaulting to set.

Suggested change
private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
{
changed = false;
try
{
string normalizedOp = op.Trim().ToLowerInvariant();
switch (normalizedOp)
{
case "array_resize":
return ApplyArrayResize(so, propertyPath, patchObj, out changed);
case "set":
default:
return ApplySet(so, propertyPath, patchObj, out changed);
}
}
catch (Exception ex)
{
return new { propertyPath, op, ok = false, message = ex.Message };
}
}
private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed)
{
changed = false;
try
{
string normalizedOp = (op ?? string.Empty).Trim().ToLowerInvariant();
switch (normalizedOp)
{
case "array_resize":
return ApplyArrayResize(so, propertyPath, patchObj, out changed);
case "set":
return ApplySet(so, propertyPath, patchObj, out changed);
default:
return new
{
propertyPath,
op,
ok = false,
message = $"Unsupported op '{op}'. Supported ops are: 'set', 'array_resize'."
};
}
}
catch (Exception ex)
{
return new { propertyPath, op, ok = false, message = ex.Message };
}
}

Comment on lines +279 to +280
var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
anyChanged |= changed;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): The changed flag is set to ok in TrySetValue, which can trigger saves even when values are unchanged.

In ApplyPatches, anyChanged is derived from changed, which is set in ApplySet as changed = ok;. Because TrySetValue returns ok on successful parsing rather than on actual value changes, writing the same value still sets changed = true, causing ApplyModifiedProperties, SetDirty, and SaveAssets to run unnecessarily. To avoid redundant serialization/saves, compare the existing and new values for each supported property type and only set changed = true when they differ.

Suggested implementation:

                if (string.IsNullOrWhiteSpace(op))
                {
                    op = "set";
                }

                var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed);
                anyChanged |= changed;
                results.Add(patchResult);

namespace MCPForUnity.Editor.Tools
        private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed)
        {
            changed = false;

            var valueToken = patchObj["value"];
            if (valueToken == null)
            {
                return new { propertyPath, op = "set", ok = false, message = "Missing required field: value" };
            }

            var property = so.FindProperty(propertyPath);
            if (property == null)
            {
                return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" };
            }

            var ok = TrySetValue(property, valueToken, out bool valueChanged, out string message);
            changed = valueChanged;

            if (ok)
            {
                property.serializedObject.ApplyModifiedProperties();
                return new { propertyPath, op = "set", ok = true };
            }

            return new { propertyPath, op = "set", ok = false, message };
        }
        private static bool TrySetValue(SerializedProperty property, JToken valueToken, out bool changed, out string message)
        {
            message = null;
            changed = false;

            try
            {
                switch (property.propertyType)
                {
                    case SerializedPropertyType.Integer:
                    {
                        int newValue = valueToken.Value<int>();
                        int oldValue = property.intValue;
                        if (oldValue == newValue)
                            return true;

                        property.intValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.Boolean:
                    {
                        bool newValue = valueToken.Value<bool>();
                        bool oldValue = property.boolValue;
                        if (oldValue == newValue)
                            return true;

                        property.boolValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.Float:
                    {
                        float newValue = valueToken.Value<float>();
                        float oldValue = property.floatValue;
                        if (Mathf.Approximately(oldValue, newValue))
                            return true;

                        property.floatValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.String:
                    {
                        string newValue = valueToken.Value<string>();
                        string oldValue = property.stringValue;
                        if (string.Equals(oldValue, newValue, StringComparison.Ordinal))
                            return true;

                        property.stringValue = newValue;
                        changed = true;
                        break;
                    }

                    case SerializedPropertyType.ObjectReference:
                    {
                        // Delegate to existing logic; it should set the reference only when changed if possible.
                        // If not, this still avoids marking changes for failed parses.
                        bool ok = TrySetObjectReference(property, valueToken, out bool refChanged, out message);
                        changed = refChanged;
                        return ok;
                    }

                    case SerializedPropertyType.Enum:
                    {
                        // Delegate to existing enum logic and propagate the changed flag.
                        bool ok = TrySetEnum(property, valueToken, out bool enumChanged, out message);
                        changed = enumChanged;
                        return ok;
                    }

                    // Add similar comparison logic for other supported property types as needed.
                    default:
                        message = $"Unsupported property type: {property.propertyType}";
                        return false;
                }

                return true;
            }
            catch (Exception ex)
            {
                message = $"Failed to set value: {ex.Message}";
                changed = false;
                return false;
            }
        }

Because only a portion of the file is visible, you will also need to:

  1. Update any existing calls to TrySetValue (other than the one in ApplySet already patched above) to use the new signature:
    • From: TrySetValue(property, valueToken, out string message)
    • To: TrySetValue(property, valueToken, out bool changed, out string message) (and either use or discard changed as appropriate).
  2. Adjust the helper methods for complex types to match the new pattern:
    • Change TrySetObjectReference to:
      private static bool TrySetObjectReference(SerializedProperty property, JToken valueToken, out bool changed, out string message)
      and implement it so changed is true only when the reference actually changes.
    • Change TrySetEnum to:
      private static bool TrySetEnum(SerializedProperty property, JToken valueToken, out bool changed, out string message)
      and set changed only when the enum value differs from the current one.
  3. If you support additional property types (e.g., Vector2, Vector3, Color, arrays, etc.), mirror the same “compare then assign” pattern inside the switch (property.propertyType) so changed only becomes true when the underlying value is different.
  4. Ensure using UnityEngine; and using System; are present at the top of the file so Mathf and StringComparison compile correctly.

}

[Test]
public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test that asserts GUID-preserving overwrite semantics for overwrite=true.

The ManageScriptableObject logic is supposed to overwrite an existing asset in-place (preserving its GUID) when overwrite is true and the existing type matches. Current tests cover folder creation and patching but not this GUID behavior. Please add or extend a test that:

  1. Creates an asset at a known path.
  2. Records its GUID.
  3. Calls create again with the same folderPath/assetName and overwrite=true.
  4. Asserts the path is unchanged and AssetDatabase.AssetPathToGUID(path) matches the original GUID.

This will exercise the GUID-preserving branch and guard against regressions in the overwrite flow.

Suggested implementation:

            AssetDatabase.Refresh();
        }

        [Test]
        public void Create_WithOverwriteTrue_PreservesGuid_WhenExistingAssetTypeMatches()
        {
            // Arrange: ensure the nested folder exists
            var folderSegments = NestedFolder.Split('/');
            var currentPath = "Assets";
            for (var i = 1; i < folderSegments.Length; i++)
            {
                var nextPath = $"{currentPath}/{folderSegments[i]}";
                if (!AssetDatabase.IsValidFolder(nextPath))
                {
                    AssetDatabase.CreateFolder(currentPath, folderSegments[i]);
                }

                currentPath = nextPath;
            }

            var assetPath = $"{NestedFolder}/My_Test_Def.asset";

            // Create an initial asset at the target path
            var originalAsset = ScriptableObject.CreateInstance<ManageScriptableObjectTestDefinition>();
            originalAsset.displayName = "Original";
            originalAsset.baseNumber = 1;

            AssetDatabase.CreateAsset(originalAsset, assetPath);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            var originalGuid = AssetDatabase.AssetPathToGUID(assetPath);
            Assert.IsFalse(string.IsNullOrEmpty(originalGuid), "Expected initial asset to have a valid GUID.");

            // Act: run the create command again with overwrite=true for the same path
            var create = new JObject
            {
                ["action"] = "create",
                ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
                ["folderPath"] = NestedFolder,
                ["assetName"] = "My_Test_Def",
                ["overwrite"] = true,
                ["patches"] = new JArray
                {
                    new JObject { ["propertyPath"] = "displayName", ["op"] = "set", ["value"] = "Overwritten" },
                    new JObject { ["propertyPath"] = "baseNumber", ["op"] = "set", ["value"] = 99 },
                },
            };

            // This should hit the overwrite-in-place branch and preserve the existing GUID
            ManageScriptableObject.Execute(create);

            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            // Assert: same path, same GUID, updated data
            var newGuid = AssetDatabase.AssetPathToGUID(assetPath);
            Assert.AreEqual(originalGuid, newGuid, "GUID should be preserved when overwriting an existing asset of the same type.");
            Assert.AreEqual(assetPath, AssetDatabase.GUIDToAssetPath(originalGuid), "Asset path should remain unchanged.");

            var overwrittenAsset =
                AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(assetPath);
            Assert.IsNotNull(overwrittenAsset, "Overwritten asset should still be loadable at the same path.");
            Assert.AreEqual("Overwritten", overwrittenAsset.displayName);
            Assert.AreEqual(99, overwrittenAsset.baseNumber);
        }

        [Test]
        public void Create_CreatesNestedFolders_PlacesAssetCorrectly_AndAppliesPatches()
  1. The invocation ManageScriptableObject.Execute(create); should be updated to match whatever entry point the existing tests use to run the ManageScriptableObject logic (for example, ManageScriptableObject.Run(create), ManageScriptableObject.Handle(create), or similar). Use the same call signature and parameter shape as in the other tests in this fixture.
  2. If NestedFolder already includes "Assets" or has a different format, adjust the folder creation logic accordingly so it does not attempt to create duplicate root segments.
  3. Ensure the necessary using directives are present at the top of the file (they likely already are, but this test relies on ScriptableObject, AssetDatabase, JObject, and JArray).

}

[Test]
public void Modify_ArrayResize_ThenAssignObjectRefs_ByGuidAndByPath()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for clearing an object reference to null via patches.

ApplySet is designed to clear ObjectReference properties when ref is omitted and value is null, but this path isn’t currently tested. Please extend this test (or add a new one) to apply a patch like {"propertyPath": "materials.Array.data[0]", "op": "set", "value": null} after the initial assignments, and assert that asset.Materials[0] becomes null while asset.Materials[1] is unchanged. This will exercise the null-clearing branch.

Suggested implementation:

            var create = new JObject
            {
                ["action"] = "create",
                ["typeName"] = typeof(ManageScriptableObjectTestDefinition).FullName,
                ["folderPath"] = TempRoot,
                ["assetName"] = "Modify_Target",
                ["overwrite"] = true
            };
            var createRes = ToJObject(ManageScriptableObject.HandleCommand(create));
            Assert.IsTrue(createRes.Value<bool>("success"), createRes.ToString());

            var createdAssetPath = createRes.Value<string>("assetPath");
            Assert.IsFalse(string.IsNullOrWhiteSpace(createdAssetPath), "Expected assetPath in create response.");

            // Create two material assets that we'll assign by guid and by path.
            var material1Path = System.IO.Path.Combine(TempRoot, "Modify_ArrayResize_Material_1.mat").Replace('\\', '/');
            var material2Path = System.IO.Path.Combine(TempRoot, "Modify_ArrayResize_Material_2.mat").Replace('\\', '/');

            var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
            Assert.IsNotNull(shader, "Expected to find at least one default shader for test materials.");

            var material1 = new Material(shader);
            var material2 = new Material(shader);

            AssetDatabase.CreateAsset(material1, material1Path);
            AssetDatabase.CreateAsset(material2, material2Path);
            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            var material1Guid = AssetDatabase.AssetPathToGUID(material1Path);
            Assert.IsFalse(string.IsNullOrWhiteSpace(material1Guid), "Expected GUID for first material.");

            // First modify: resize materials array to 2, then assign by guid and by path.
            var modifyInitial = new JObject
            {
                ["action"] = "modify",
                ["assetPath"] = createdAssetPath,
                ["patches"] = new JArray
                {
                    // Resize array
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.size",
                        ["value"] = 2
                    },
                    // Assign element 0 by guid
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[0]",
                        ["ref"] = new JObject
                        {
                            ["guid"] = material1Guid
                        }
                    },
                    // Assign element 1 by path
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[1]",
                        ["ref"] = new JObject
                        {
                            ["assetPath"] = material2Path
                        }
                    }
                }
            };

            var modifyInitialRes = ToJObject(ManageScriptableObject.HandleCommand(modifyInitial));
            Assert.IsTrue(modifyInitialRes.Value<bool>("success"), modifyInitialRes.ToString());

            // Verify both material references were applied.
            var asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(createdAssetPath);
            Assert.IsNotNull(asset, "Modified asset should load as TestDefinition after initial modify.");
            Assert.IsNotNull(asset!.Materials, "Materials array should not be null after resize.");
            Assert.AreEqual(2, asset.Materials.Length, "Materials array size should be 2 after resize.");
            Assert.IsNotNull(asset.Materials[0], "Materials[0] should be assigned by guid after initial modify.");
            Assert.IsNotNull(asset.Materials[1], "Materials[1] should be assigned by path after initial modify.");

            // Second modify: clear the first material reference by setting value:null and omitting ref.
            var modifyClearRef = new JObject
            {
                ["action"] = "modify",
                ["assetPath"] = createdAssetPath,
                ["patches"] = new JArray
                {
                    new JObject
                    {
                        ["op"] = "set",
                        ["propertyPath"] = "materials.Array.data[0]",
                        // ApplySet should interpret value:null with no "ref" as "clear the ObjectReference".
                        ["value"] = JValue.CreateNull()
                    }
                }
            };

            var modifyClearRefRes = ToJObject(ManageScriptableObject.HandleCommand(modifyClearRef));
            Assert.IsTrue(modifyClearRefRes.Value<bool>("success"), modifyClearRefRes.ToString());

            // Reload and verify that only the first reference was cleared.
            asset = AssetDatabase.LoadAssetAtPath<ManageScriptableObjectTestDefinition>(createdAssetPath);
            Assert.IsNotNull(asset, "Modified asset should still be loadable after clearing reference.");
            Assert.IsNotNull(asset!.Materials, "Materials array should still exist after clearing reference.");
            Assert.AreEqual(2, asset.Materials.Length, "Materials array size should remain 2 after clearing reference.");
            Assert.IsNull(asset.Materials[0], "Materials[0] should be cleared (null) when value:null and no ref are applied.");
            Assert.IsNotNull(asset.Materials[1], "Materials[1] should remain unchanged when only Materials[0] is cleared.");

This edit assumes that:

  1. ManageScriptableObjectTestDefinition has a public Material[] Materials (or similarly named) field/prop serialized under the path "materials". If the backing field/property uses a different name, update "materials.Array.*" and asset.Materials accordingly.
  2. ToJObject, TempRoot, and ManageScriptableObject.HandleCommand are already defined and used consistently elsewhere in the test file.
  3. The original test body did not contain additional logic after the Assert.IsTrue(createRes...) line. If it did, you should merge any existing modify/verify logic with the new two-phase modify flow above (e.g., incorporate existing patches into modifyInitial).

import services.tools.manage_scriptable_object as mod


def test_manage_scriptable_object_forwards_create_params(monkeypatch):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add integration tests for invalid target/patches inputs in the Python wrapper.

Since the wrapper validates input via parse_json_payload, consider adding tests that cover malformed payloads:

  • Non-JSON target string → expect {"success": False, "message": ...} describing the expected shape.
  • Non-list patches (e.g., scalar or dict) → expect the "patches must be a list" error.

These will ensure the defensive behavior is exercised and preserved.

Suggested implementation:

def test_manage_scriptable_object_forwards_create_params(monkeypatch):
    captured = {}

    async def fake_async_send(cmd, params, **kwargs):
        captured["cmd"] = cmd
        captured["params"] = params


def test_manage_scriptable_object_rejects_non_json_target(monkeypatch):
    ctx = DummyContext()

    async def noop_async_send(*_, **__):
        return None

    # Avoid any real side effects when the tool tries to send commands
    monkeypatch.setattr(ctx, "async_send", noop_async_send)

    result = asyncio.run(
        mod.manage_scriptable_object(
            ctx,
            target="not valid json",  # malformed JSON string
            patches=[],               # structurally valid for this test
        )
    )

    assert isinstance(result, dict)
    assert result.get("success") is False
    # Message should explain the issue with the target / JSON shape
    message = result.get("message", "")
    assert message
    assert "target" in message or "json" in message.lower()


def test_manage_scriptable_object_rejects_non_list_patches(monkeypatch):
    ctx = DummyContext()

    async def noop_async_send(*_, **__):
        return None

    monkeypatch.setattr(ctx, "async_send", noop_async_send)

    # Valid JSON target, but invalid patches payloads
    for invalid_patches in ({}, 123, "not-a-list", None):
        result = asyncio.run(
            mod.manage_scriptable_object(
                ctx,
                target="{}",  # minimally valid JSON object
                patches=invalid_patches,
            )
        )

        assert isinstance(result, dict)
        assert result.get("success") is False
        # Message should reflect that patches must be a list
        message = result.get("message", "")
        assert message
        assert "list" in message.lower()

These tests assume:

  1. manage_scriptable_object is an async function taking (context, target, patches) and returning a dict with success and message keys. If the actual wrapper signature or return shape differs, update the calls and assertions accordingly.
  2. The error message for malformed target mentions either "target" or "JSON", and for invalid patches includes "list". If your concrete error messages are more specific (e.g. "patches must be a list"), adjust the string checks to match them exactly.

@dsarno dsarno merged commit 28f60b4 into CoplayDev:main Dec 29, 2025
2 checks passed
@dsarno dsarno deleted the fix/scriptableobject-tools branch January 2, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant