commit 4ae8e14b9ebdf6660190d713b868612f6442e2ac Author: Junhak Date: Mon Mar 9 01:05:56 2026 +0900 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..391faa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# This .gitignore file should be placed at the root of your Unity project directory +# +# Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore +# +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ + + +# MemoryCaptures can get excessive in size. +# They also could contain extremely sensitive data +/[Mm]emoryCaptures/ + +# Recordings can get excessive in size +/[Rr]ecordings/ + +# Uncomment this line if you wish to ignore the asset store tools plugin +# /[Aa]ssets/AssetStoreTools* + +# Autogenerated Jetbrains Rider plugin +/[Aa]ssets/Plugins/Editor/JetBrains* + +# Visual Studio cache directory +.vs/ + +# Gradle cache directory +.gradle/ + +# Autogenerated VS/MD/Consulo solution and project files +ExportedObj/ +.consulo/ +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Unity3D generated meta files +*.pidb.meta +*.pdb.meta +*.mdb.meta + +# Unity3D generated file on crash reports +sysinfo.txt + +# Builds +*.apk +*.aab +*.unitypackage +*.app + +# Crashlytics generated file +crashlytics-build.properties + +# Packed Addressables +/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* + +# Temporary auto-generated Android Assets +/[Aa]ssets/[Ss]treamingAssets/aa.meta +/[Aa]ssets/[Ss]treamingAssets/aa/* +/Assets/Plugins/XRLib/Core/Initializer.cs diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ddb6ff8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "visualstudiotoolsforunity.vstuc" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..da60e25 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,10 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Unity", + "type": "vstuc", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..63e4a1f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,71 @@ +{ + "files.exclude": { + "**/.DS_Store": true, + "**/.git": true, + "**/.vs": true, + "**/.gitmodules": true, + "**/.vsconfig": true, + "**/*.booproj": true, + "**/*.pidb": true, + "**/*.suo": true, + "**/*.user": true, + "**/*.userprefs": true, + "**/*.unityproj": true, + "**/*.dll": true, + "**/*.exe": true, + "**/*.pdf": true, + "**/*.mid": true, + "**/*.midi": true, + "**/*.wav": true, + "**/*.gif": true, + "**/*.ico": true, + "**/*.jpg": true, + "**/*.jpeg": true, + "**/*.png": true, + "**/*.psd": true, + "**/*.tga": true, + "**/*.tif": true, + "**/*.tiff": true, + "**/*.3ds": true, + "**/*.3DS": true, + "**/*.fbx": true, + "**/*.FBX": true, + "**/*.lxo": true, + "**/*.LXO": true, + "**/*.ma": true, + "**/*.MA": true, + "**/*.obj": true, + "**/*.OBJ": true, + "**/*.asset": true, + "**/*.cubemap": true, + "**/*.flare": true, + "**/*.mat": true, + "**/*.meta": true, + "**/*.prefab": true, + "**/*.unity": true, + "build/": true, + "Build/": true, + "Library/": true, + "library/": true, + "obj/": true, + "Obj/": true, + "Logs/": true, + "logs/": true, + "ProjectSettings/": true, + "UserSettings/": true, + "temp/": true, + "Temp/": true + }, + "files.associations": { + "*.asset": "yaml", + "*.meta": "yaml", + "*.prefab": "yaml", + "*.unity": "yaml", + }, + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "*.sln": "*.csproj", + "*.slnx": "*.csproj" + }, + "dotnet.defaultSolution": "ClaudeCodeTest.slnx" +} \ No newline at end of file diff --git a/.vsconfig b/.vsconfig new file mode 100644 index 0000000..f019fd0 --- /dev/null +++ b/.vsconfig @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.VisualStudio.Workload.ManagedGame" + ] +} diff --git a/Assets/InputSystem_Actions.inputactions b/Assets/InputSystem_Actions.inputactions new file mode 100644 index 0000000..1a12cb9 --- /dev/null +++ b/Assets/InputSystem_Actions.inputactions @@ -0,0 +1,1057 @@ +{ + "name": "InputSystem_Actions", + "maps": [ + { + "name": "Player", + "id": "df70fa95-8a34-4494-b137-73ab6b9c7d37", + "actions": [ + { + "name": "Move", + "type": "Value", + "id": "351f2ccd-1f9f-44bf-9bec-d62ac5c5f408", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Look", + "type": "Value", + "id": "6b444451-8a00-4d00-a97e-f47457f736a8", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Attack", + "type": "Button", + "id": "6c2ab1b8-8984-453a-af3d-a3c78ae1679a", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Interact", + "type": "Button", + "id": "852140f2-7766-474d-8707-702459ba45f3", + "expectedControlType": "Button", + "processors": "", + "interactions": "Hold", + "initialStateCheck": false + }, + { + "name": "Crouch", + "type": "Button", + "id": "27c5f898-bc57-4ee1-8800-db469aca5fe3", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Jump", + "type": "Button", + "id": "f1ba0d36-48eb-4cd5-b651-1c94a6531f70", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Previous", + "type": "Button", + "id": "2776c80d-3c14-4091-8c56-d04ced07a2b0", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Next", + "type": "Button", + "id": "b7230bb6-fc9b-4f52-8b25-f5e19cb2c2ba", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Sprint", + "type": "Button", + "id": "641cd816-40e6-41b4-8c3d-04687c349290", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + } + ], + "bindings": [ + { + "name": "", + "id": "978bfe49-cc26-4a3d-ab7b-7d7a29327403", + "path": "/leftStick", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "WASD", + "id": "00ca640b-d935-4593-8157-c05846ea39b3", + "path": "Dpad", + "interactions": "", + "processors": "", + "groups": "", + "action": "Move", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "e2062cb9-1b15-46a2-838c-2f8d72a0bdd9", + "path": "/w", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "8180e8bd-4097-4f4e-ab88-4523101a6ce9", + "path": "/upArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "320bffee-a40b-4347-ac70-c210eb8bc73a", + "path": "/s", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "1c5327b5-f71c-4f60-99c7-4e737386f1d1", + "path": "/downArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "d2581a9b-1d11-4566-b27d-b92aff5fabbc", + "path": "/a", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "2e46982e-44cc-431b-9f0b-c11910bf467a", + "path": "/leftArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "fcfe95b8-67b9-4526-84b5-5d0bc98d6400", + "path": "/d", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "77bff152-3580-4b21-b6de-dcd0c7e41164", + "path": "/rightArrow", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Move", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "1635d3fe-58b6-4ba9-a4e2-f4b964f6b5c8", + "path": "/{Primary2DAxis}", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "3ea4d645-4504-4529-b061-ab81934c3752", + "path": "/stick", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Move", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "c1f7a91b-d0fd-4a62-997e-7fb9b69bf235", + "path": "/rightStick", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8c8e490b-c610-4785-884f-f04217b23ca4", + "path": "/delta", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse;Touch", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "3e5f5442-8668-4b27-a940-df99bad7e831", + "path": "/{Hatswitch}", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Look", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "143bb1cd-cc10-4eca-a2f0-a3664166fe91", + "path": "/buttonWest", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "05f6913d-c316-48b2-a6bb-e225f14c7960", + "path": "/leftButton", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "886e731e-7071-4ae4-95c0-e61739dad6fd", + "path": "/primaryTouch/tap", + "interactions": "", + "processors": "", + "groups": ";Touch", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "ee3d0cd2-254e-47a7-a8cb-bc94d9658c54", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8255d333-5683-4943-a58a-ccb207ff1dce", + "path": "/{PrimaryAction}", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "b3c1c7f0-bd20-4ee7-a0f1-899b24bca6d7", + "path": "/enter", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Attack", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "cbac6039-9c09-46a1-b5f2-4e5124ccb5ed", + "path": "/2", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Next", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e15ca19d-e649-4852-97d5-7fe8ccc44e94", + "path": "/dpad/right", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Next", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "f2e9ba44-c423-42a7-ad56-f20975884794", + "path": "/leftShift", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8cbb2f4b-a784-49cc-8d5e-c010b8c7f4e6", + "path": "/leftStickPress", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "d8bf24bf-3f2f-4160-a97c-38ec1eb520ba", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Sprint", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "eb40bb66-4559-4dfa-9a2f-820438abb426", + "path": "/space", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "daba33a1-ad0c-4742-a909-43ad1cdfbeb6", + "path": "/buttonSouth", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "603f3daf-40bd-4854-8724-93e8017f59e3", + "path": "/secondaryButton", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Jump", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1534dc16-a6aa-499d-9c3a-22b47347b52a", + "path": "/1", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Previous", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "25060bbd-a3a6-476e-8fba-45ae484aad05", + "path": "/dpad/left", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Previous", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "1c04ea5f-b012-41d1-a6f7-02e963b52893", + "path": "/e", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Interact", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "b3f66d0b-7751-423f-908b-a11c5bd95930", + "path": "/buttonNorth", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Interact", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4f4649ac-64a8-4a73-af11-b3faef356a4d", + "path": "/buttonEast", + "interactions": "", + "processors": "", + "groups": "Gamepad", + "action": "Crouch", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "36e52cba-0905-478e-a818-f4bfcb9f3b9a", + "path": "/c", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Crouch", + "isComposite": false, + "isPartOfComposite": false + } + ] + }, + { + "name": "UI", + "id": "272f6d14-89ba-496f-b7ff-215263d3219f", + "actions": [ + { + "name": "Navigate", + "type": "PassThrough", + "id": "c95b2375-e6d9-4b88-9c4c-c5e76515df4b", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Submit", + "type": "Button", + "id": "7607c7b6-cd76-4816-beef-bd0341cfe950", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Cancel", + "type": "Button", + "id": "15cef263-9014-4fd5-94d9-4e4a6234a6ef", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "Point", + "type": "PassThrough", + "id": "32b35790-4ed0-4e9a-aa41-69ac6d629449", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "Click", + "type": "PassThrough", + "id": "3c7022bf-7922-4f7c-a998-c437916075ad", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": true + }, + { + "name": "RightClick", + "type": "PassThrough", + "id": "44b200b1-1557-4083-816c-b22cbdf77ddf", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "MiddleClick", + "type": "PassThrough", + "id": "dad70c86-b58c-4b17-88ad-f5e53adf419e", + "expectedControlType": "Button", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "ScrollWheel", + "type": "PassThrough", + "id": "0489e84a-4833-4c40-bfae-cea84b696689", + "expectedControlType": "Vector2", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "TrackedDevicePosition", + "type": "PassThrough", + "id": "24908448-c609-4bc3-a128-ea258674378a", + "expectedControlType": "Vector3", + "processors": "", + "interactions": "", + "initialStateCheck": false + }, + { + "name": "TrackedDeviceOrientation", + "type": "PassThrough", + "id": "9caa3d8a-6b2f-4e8e-8bad-6ede561bd9be", + "expectedControlType": "Quaternion", + "processors": "", + "interactions": "", + "initialStateCheck": false + } + ], + "bindings": [ + { + "name": "Gamepad", + "id": "809f371f-c5e2-4e7a-83a1-d867598f40dd", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "14a5d6e8-4aaf-4119-a9ef-34b8c2c548bf", + "path": "/leftStick/up", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "9144cbe6-05e1-4687-a6d7-24f99d23dd81", + "path": "/rightStick/up", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "2db08d65-c5fb-421b-983f-c71163608d67", + "path": "/leftStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "58748904-2ea9-4a80-8579-b500e6a76df8", + "path": "/rightStick/down", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "8ba04515-75aa-45de-966d-393d9bbd1c14", + "path": "/leftStick/left", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "712e721c-bdfb-4b23-a86c-a0d9fcfea921", + "path": "/rightStick/left", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "fcd248ae-a788-4676-a12e-f4d81205600b", + "path": "/leftStick/right", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "1f04d9bc-c50b-41a1-bfcc-afb75475ec20", + "path": "/rightStick/right", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "fb8277d4-c5cd-4663-9dc7-ee3f0b506d90", + "path": "/dpad", + "interactions": "", + "processors": "", + "groups": ";Gamepad", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "Joystick", + "id": "e25d9774-381c-4a61-b47c-7b6b299ad9f9", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "3db53b26-6601-41be-9887-63ac74e79d19", + "path": "/stick/up", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "0cb3e13e-3d90-4178-8ae6-d9c5501d653f", + "path": "/stick/down", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "0392d399-f6dd-4c82-8062-c1e9c0d34835", + "path": "/stick/left", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "942a66d9-d42f-43d6-8d70-ecb4ba5363bc", + "path": "/stick/right", + "interactions": "", + "processors": "", + "groups": "Joystick", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "Keyboard", + "id": "ff527021-f211-4c02-933e-5976594c46ed", + "path": "2DVector", + "interactions": "", + "processors": "", + "groups": "", + "action": "Navigate", + "isComposite": true, + "isPartOfComposite": false + }, + { + "name": "up", + "id": "563fbfdd-0f09-408d-aa75-8642c4f08ef0", + "path": "/w", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "up", + "id": "eb480147-c587-4a33-85ed-eb0ab9942c43", + "path": "/upArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "2bf42165-60bc-42ca-8072-8c13ab40239b", + "path": "/s", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "down", + "id": "85d264ad-e0a0-4565-b7ff-1a37edde51ac", + "path": "/downArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "74214943-c580-44e4-98eb-ad7eebe17902", + "path": "/a", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "left", + "id": "cea9b045-a000-445b-95b8-0c171af70a3b", + "path": "/leftArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "8607c725-d935-4808-84b1-8354e29bab63", + "path": "/d", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "right", + "id": "4cda81dc-9edd-4e03-9d7c-a71a14345d0b", + "path": "/rightArrow", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Navigate", + "isComposite": false, + "isPartOfComposite": true + }, + { + "name": "", + "id": "9e92bb26-7e3b-4ec4-b06b-3c8f8e498ddc", + "path": "*/{Submit}", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse;Gamepad;Touch;Joystick;XR", + "action": "Submit", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "82627dcc-3b13-4ba9-841d-e4b746d6553e", + "path": "*/{Cancel}", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse;Gamepad;Touch;Joystick;XR", + "action": "Cancel", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "c52c8e0b-8179-41d3-b8a1-d149033bbe86", + "path": "/position", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "e1394cbc-336e-44ce-9ea8-6007ed6193f7", + "path": "/position", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "5693e57a-238a-46ed-b5ae-e64e6e574302", + "path": "/touch*/position", + "interactions": "", + "processors": "", + "groups": "Touch", + "action": "Point", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4faf7dc9-b979-4210-aa8c-e808e1ef89f5", + "path": "/leftButton", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "8d66d5ba-88d7-48e6-b1cd-198bbfef7ace", + "path": "/tip", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "47c2a644-3ebc-4dae-a106-589b7ca75b59", + "path": "/touch*/press", + "interactions": "", + "processors": "", + "groups": "Touch", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "bb9e6b34-44bf-4381-ac63-5aa15d19f677", + "path": "/trigger", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "Click", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "38c99815-14ea-4617-8627-164d27641299", + "path": "/scroll", + "interactions": "", + "processors": "", + "groups": ";Keyboard&Mouse", + "action": "ScrollWheel", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "4c191405-5738-4d4b-a523-c6a301dbf754", + "path": "/rightButton", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "RightClick", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "24066f69-da47-44f3-a07e-0015fb02eb2e", + "path": "/middleButton", + "interactions": "", + "processors": "", + "groups": "Keyboard&Mouse", + "action": "MiddleClick", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "7236c0d9-6ca3-47cf-a6ee-a97f5b59ea77", + "path": "/devicePosition", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "TrackedDevicePosition", + "isComposite": false, + "isPartOfComposite": false + }, + { + "name": "", + "id": "23e01e3a-f935-4948-8d8b-9bcac77714fb", + "path": "/deviceRotation", + "interactions": "", + "processors": "", + "groups": "XR", + "action": "TrackedDeviceOrientation", + "isComposite": false, + "isPartOfComposite": false + } + ] + } + ], + "controlSchemes": [ + { + "name": "Keyboard&Mouse", + "bindingGroup": "Keyboard&Mouse", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + }, + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Gamepad", + "bindingGroup": "Gamepad", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Touch", + "bindingGroup": "Touch", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "Joystick", + "bindingGroup": "Joystick", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + }, + { + "name": "XR", + "bindingGroup": "XR", + "devices": [ + { + "devicePath": "", + "isOptional": false, + "isOR": false + } + ] + } + ] +} \ No newline at end of file diff --git a/Assets/InputSystem_Actions.inputactions.meta b/Assets/InputSystem_Actions.inputactions.meta new file mode 100644 index 0000000..6b38b04 --- /dev/null +++ b/Assets/InputSystem_Actions.inputactions.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 052faaac586de48259a63d0c4782560b +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3} + generateWrapperCode: 0 + wrapperCodePath: + wrapperClassName: + wrapperCodeNamespace: diff --git a/Assets/MCPForUnity.meta b/Assets/MCPForUnity.meta new file mode 100644 index 0000000..48917aa --- /dev/null +++ b/Assets/MCPForUnity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 45b75a96cdaa04af6b020f407d1a877c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor.meta b/Assets/MCPForUnity/Editor.meta new file mode 100644 index 0000000..26495d4 --- /dev/null +++ b/Assets/MCPForUnity/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31e7fac5858840340a75cc6df0ad3d9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/AssemblyInfo.cs b/Assets/MCPForUnity/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..bae75b6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] diff --git a/Assets/MCPForUnity/Editor/AssemblyInfo.cs.meta b/Assets/MCPForUnity/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..5a9deca --- /dev/null +++ b/Assets/MCPForUnity/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: be61633e00d934610ac1ff8192ffbe3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/AssemblyInfo.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients.meta b/Assets/MCPForUnity/Editor/Clients.meta new file mode 100644 index 0000000..b4105b3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9d47f01d06964ee7843765d1bd71205 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators.meta b/Assets/MCPForUnity/Editor/Clients/Configurators.meta new file mode 100644 index 0000000..a259c21 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 59ff83375c2c74c8385c4a22549778dd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs new file mode 100644 index 0000000..9a83620 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class AntigravityConfigurator : JsonFileMcpConfigurator + { + public AntigravityConfigurator() : base(new McpClient + { + name = "Antigravity", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gemini", "antigravity", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Antigravity", + "Click the more_horiz menu in the Agent pane > MCP Servers", + "Select 'Install' for Unity MCP or use the Configure button above", + "Restart Antigravity if necessary" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta new file mode 100644 index 0000000..4f08cd4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 331b33961513042e3945d0a1d06615b5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/AntigravityConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs new file mode 100644 index 0000000..bf14559 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CherryStudioConfigurator : JsonFileMcpConfigurator + { + public const string ClientName = "Cherry Studio"; + + public CherryStudioConfigurator() : base(new McpClient + { + name = ClientName, + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Cherry Studio", "config"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Cherry Studio", "config"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Cherry Studio", "config"), + SupportsHttpTransport = false + }) + { } + + public override bool SupportsAutoConfigure => false; + + public override IList GetInstallationSteps() => new List + { + "Open Cherry Studio", + "Go to Settings (⚙️) → MCP Server", + "Click 'Add Server' button", + "For STDIO mode (recommended):", + " - Name: unity-mcp", + " - Type: STDIO", + " - Command: uvx", + " - Arguments: Copy from the Manual Configuration JSON below", + "Click Save and restart Cherry Studio", + "", + "Note: Cherry Studio uses UI-based configuration.", + "Use the manual snippet below as reference for the values to enter." + }; + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + client.SetStatus(McpStatus.NotConfigured, "Cherry Studio requires manual UI configuration"); + return client.status; + } + + public override void Configure() + { + throw new InvalidOperationException( + "Cherry Studio uses UI-based configuration. " + + "Please use the Manual Configuration snippet and Installation Steps to configure manually." + ); + } + + public override string GetManualSnippet() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + + if (useHttp) + { + return "# Cherry Studio does not support WebSocket transport.\n" + + "# Cherry Studio supports STDIO and SSE transports.\n" + + "# \n" + + "# To use Cherry Studio:\n" + + "# 1. Switch transport to 'Stdio' in Advanced Settings below\n" + + "# 2. Return to this configuration screen\n" + + "# 3. Copy the STDIO configuration snippet that will appear\n" + + "# \n" + + "# OPTION 2: SSE mode (future support)\n" + + "# Note: Unity MCP does not currently have an SSE endpoint.\n" + + "# This may be added in a future update."; + } + + return base.GetManualSnippet() + "\n\n" + + "# Cherry Studio Configuration Instructions:\n" + + "# Cherry Studio uses UI-based configuration, not a JSON file.\n" + + "# \n" + + "# To configure:\n" + + "# 1. Open Cherry Studio\n" + + "# 2. Go to Settings (⚙️) → MCP Server\n" + + "# 3. Click 'Add Server'\n" + + "# 4. Enter the following values from the JSON above:\n" + + "# - Name: unity-mcp\n" + + "# - Type: STDIO\n" + + "# - Command: (copy 'command' value from JSON)\n" + + "# - Arguments: (copy 'args' array values, space-separated or as individual entries)\n" + + "# - Active: true\n" + + "# 5. Click Save\n" + + "# 6. Restart Cherry Studio"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta new file mode 100644 index 0000000..c7d5d2e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6de06c6bb0399154d840a1e4c84be869 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/CherryStudioConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs new file mode 100644 index 0000000..c890d7c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Claude Code configurator using the CLI-based registration (claude mcp add/remove). + /// This integrates with Claude Code's native MCP management. + /// + public class ClaudeCodeConfigurator : ClaudeCliMcpConfigurator + { + public ClaudeCodeConfigurator() : base(new McpClient + { + name = "Claude Code", + SupportsHttpTransport = true, + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed (comes with Claude Code)", + "Click Register to add UnityMCP via 'claude mcp add'", + "The server will be automatically available in Claude Code", + "Use Unregister to remove via 'claude mcp remove'" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta new file mode 100644 index 0000000..85f9ee9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d0d22681fc594475db1c189f2d9abdf7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeCodeConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs new file mode 100644 index 0000000..7286134 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class ClaudeDesktopConfigurator : JsonFileMcpConfigurator + { + public const string ClientName = "Claude Desktop"; + + public ClaudeDesktopConfigurator() : base(new McpClient + { + name = ClientName, + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Claude", "claude_desktop_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Claude", "claude_desktop_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Claude", "claude_desktop_config.json"), + SupportsHttpTransport = false, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Claude Desktop", + "Go to Settings > Developer > Edit Config\nOR open the config path", + "Paste the configuration JSON", + "Save and restart Claude Desktop" + }; + + public override void Configure() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (useHttp) + { + throw new InvalidOperationException("Claude Desktop does not support HTTP transport. Switch to stdio in settings before configuring."); + } + + base.Configure(); + } + + public override string GetManualSnippet() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (useHttp) + { + return "# Claude Desktop does not support HTTP transport.\n" + + "# Open Advanced Settings and disable HTTP transport to use stdio, then regenerate."; + } + + return base.GetManualSnippet(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta new file mode 100644 index 0000000..9df852c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d5e5d87c9db57495f842dc366f1ebd65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/ClaudeDesktopConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs new file mode 100644 index 0000000..4ef3624 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configures the CodeBuddy CLI (~/.codebuddy.json) MCP settings. + /// + public class CodeBuddyCliConfigurator : JsonFileMcpConfigurator + { + public CodeBuddyCliConfigurator() : base(new McpClient + { + name = "CodeBuddy CLI", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codebuddy.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install CodeBuddy CLI and ensure '~/.codebuddy.json' exists", + "Click Configure to add the UnityMCP entry (or manually edit the file above)", + "Restart your CLI session if needed" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta new file mode 100644 index 0000000..2d20ead --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 923728a98c8c74cfaa6e9203c408f34e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/CodeBuddyCliConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs new file mode 100644 index 0000000..9337d4c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CodexConfigurator : CodexMcpConfigurator + { + public CodexConfigurator() : base(new McpClient + { + name = "Codex", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "config.toml") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' in a terminal\nOR open the config file at the path above", + "Paste the configuration TOML", + "Save and restart Codex" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta new file mode 100644 index 0000000..2d374ad --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c7037ef8b168e49f79247cb31c3be75a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/CodexConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs new file mode 100644 index 0000000..84120a4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CopilotCliConfigurator : JsonFileMcpConfigurator + { + public CopilotCliConfigurator() : base(new McpClient + { + name = "GitHub Copilot CLI", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot", "mcp-config.json") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot CLI (https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli)", + "Open or create mcp-config.json at the path above", + "Paste the configuration JSON (or use /mcp add in the CLI)", + "Restart your Copilot CLI session" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta new file mode 100644 index 0000000..7fa9987 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 14a4b9a7f749248d496466c2a3a53e56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/CopilotCliConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs new file mode 100644 index 0000000..d63b226 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class CursorConfigurator : JsonFileMcpConfigurator + { + public CursorConfigurator() : base(new McpClient + { + name = "Cursor", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Cursor", + "Go to File > Preferences > Cursor Settings > MCP > Add new global MCP server\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Cursor" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta new file mode 100644 index 0000000..60e70b2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b708eda314746481fb8f4a1fb0652b03 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/CursorConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs new file mode 100644 index 0000000..e62b645 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class KiloCodeConfigurator : JsonFileMcpConfigurator + { + public KiloCodeConfigurator() : base(new McpClient + { + name = "Kilo Code", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install Kilo Code extension in VS Code", + "Open Kilo Code settings (gear icon in sidebar)", + "Navigate to MCP Servers section and click 'Edit Global MCP Settings'\nOR open the config file at the path above", + "Paste the configuration JSON into the mcpServers object", + "Save and restart VS Code" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta new file mode 100644 index 0000000..d8a11e9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 3286d62ffe5644f5ea60488fd7e6513d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/KiloCodeConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs new file mode 100644 index 0000000..445b6e5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class KiroConfigurator : JsonFileMcpConfigurator + { + public KiroConfigurator() : base(new McpClient + { + name = "Kiro", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", "settings", "mcp.json"), + EnsureEnvObject = true, + DefaultUnityFields = { { "disabled", false } } + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Kiro", + "Go to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Kiro" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta new file mode 100644 index 0000000..96e9162 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: e9b73ff071a6043dda1f2ec7d682ef71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/KiroConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs new file mode 100644 index 0000000..34ab535 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + /// + /// Configurator for OpenCode (opencode.ai) - a Go-based terminal AI coding assistant. + /// OpenCode uses ~/.config/opencode/opencode.json with a custom "mcp" format. + /// + public class OpenCodeConfigurator : McpClientConfiguratorBase + { + private const string ServerName = "unityMCP"; + private const string SchemaUrl = "https://opencode.ai/config.json"; + + public OpenCodeConfigurator() : base(new McpClient + { + name = "OpenCode", + windowsConfigPath = BuildConfigPath(), + macConfigPath = BuildConfigPath(), + linuxConfigPath = BuildConfigPath() + }) + { } + + private static string BuildConfigPath() + { + string xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string configBase = !string.IsNullOrEmpty(xdgConfigHome) + ? xdgConfigHome + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + return Path.Combine(configBase, "opencode", "opencode.json"); + } + + public override string GetConfigPath() => CurrentOsPath(); + + /// + /// Attempts to load and parse the config file. + /// Returns null if file doesn't exist or cannot be read. + /// Returns parsed JObject if valid JSON found. + /// Logs warning if file exists but contains malformed JSON. + /// + private JObject TryLoadConfig(string path) + { + if (!File.Exists(path)) + return null; + + string content; + try + { + content = File.ReadAllText(path); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Failed to read config file {path}: {ex.Message}"); + return null; + } + + try + { + return JsonConvert.DeserializeObject(content) ?? new JObject(); + } + catch (JsonException ex) + { + // Malformed JSON - log warning and return null. + // When Configure() receives null, it will do: TryLoadConfig(path) ?? new JObject() + // This creates a fresh empty JObject, which replaces the entire file with only the unityMCP section. + // Existing config sections are lost. To preserve sections, a different recovery strategy + // (e.g., line-by-line parsing, JSON repair, or manual user intervention) would be needed. + UnityEngine.Debug.LogWarning($"[OpenCodeConfigurator] Malformed JSON in {path}: {ex.Message}"); + return null; + } + } + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + var config = TryLoadConfig(path); + + if (config == null) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + var unityMcp = config["mcp"]?[ServerName] as JObject; + + if (unityMcp == null) + { + client.SetStatus(McpStatus.NotConfigured); + return client.status; + } + + string configuredUrl = unityMcp["url"]?.ToString(); + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + + if (UrlsEqual(configuredUrl, expectedUrl)) + { + client.SetStatus(McpStatus.Configured); + } + else if (attemptAutoRewrite) + { + Configure(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + + return client.status; + } + + public override void Configure() + { + try + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + + // Load existing config or start fresh, preserving all other properties and MCP servers + var config = TryLoadConfig(path) ?? new JObject(); + + // Only add $schema if creating a new file + if (!File.Exists(path)) + { + config["$schema"] = SchemaUrl; + } + + // Preserve existing mcp section and only update our server entry + var mcpSection = config["mcp"] as JObject ?? new JObject(); + config["mcp"] = mcpSection; + + mcpSection[ServerName] = BuildServerEntry(); + + McpConfigurationHelper.WriteAtomicFile(path, JsonConvert.SerializeObject(config, Formatting.Indented)); + client.SetStatus(McpStatus.Configured); + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + } + } + + public override string GetManualSnippet() + { + var snippet = new JObject + { + ["mcp"] = new JObject { [ServerName] = BuildServerEntry() } + }; + return JsonConvert.SerializeObject(snippet, Formatting.Indented); + } + + public override IList GetInstallationSteps() => new List + { + "Install OpenCode (https://opencode.ai)", + "Click Configure to add Unity MCP to ~/.config/opencode/opencode.json", + "Restart OpenCode", + "The Unity MCP server should be detected automatically" + }; + + private static JObject BuildServerEntry() => new JObject + { + ["type"] = "remote", + ["url"] = HttpEndpointUtility.GetMcpRpcUrl(), + ["enabled"] = true + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta new file mode 100644 index 0000000..a48a112 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 489f99ffb7e6743e88e3203552c8b37b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/OpenCodeConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs new file mode 100644 index 0000000..2558a42 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class RiderConfigurator : JsonFileMcpConfigurator + { + public RiderConfigurator() : base(new McpClient + { + name = "Rider GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "github-copilot", "intellij", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "github-copilot", "intellij", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "github-copilot", "intellij", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot plugin in Rider", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart Rider" + }; + } +} + diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta new file mode 100644 index 0000000..e30b168 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2511b0d05271d486bb61f8cc9fd11363 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/RiderConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs new file mode 100644 index 0000000..f32b688 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class TraeConfigurator : JsonFileMcpConfigurator + { + public TraeConfigurator() : base(new McpClient + { + name = "Trae", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Trae", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Trae", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Trae", "mcp.json"), + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Trae and go to Settings > MCP", + "Select Add Server > Add Manually", + "Paste the JSON or point to the mcp.json file\n"+ + "Windows: %AppData%\\Trae\\mcp.json\n" + + "macOS: ~/Library/Application Support/Trae/mcp.json\n" + + "Linux: ~/.config/Trae/mcp.json\n", + "Save and restart Trae" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta new file mode 100644 index 0000000..617d0d4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b3ab39e22ae0948ab94beae307f9902e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/TraeConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs new file mode 100644 index 0000000..9057930 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class VSCodeConfigurator : JsonFileMcpConfigurator + { + public VSCodeConfigurator() : base(new McpClient + { + name = "VSCode GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code", "User", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot extension", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart VSCode" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta new file mode 100644 index 0000000..4f45de6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: bcc7ead475a4d4ea2978151c217757b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs new file mode 100644 index 0000000..55adb28 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class VSCodeInsidersConfigurator : JsonFileMcpConfigurator + { + public VSCodeInsidersConfigurator() : base(new McpClient + { + name = "VSCode Insiders GitHub Copilot", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code - Insiders", "User", "mcp.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "Code - Insiders", "User", "mcp.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "Code - Insiders", "User", "mcp.json"), + IsVsCodeLayout = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Install GitHub Copilot extension in VS Code Insiders", + "Open or create mcp.json at the path above", + "Paste the configuration JSON", + "Save and restart VS Code Insiders" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta new file mode 100644 index 0000000..a714695 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2c4a1b0d3b34489cbf0f8c40c49c4f3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/VSCodeInsidersConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs new file mode 100644 index 0000000..4437170 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients.Configurators +{ + public class WindsurfConfigurator : JsonFileMcpConfigurator + { + public WindsurfConfigurator() : base(new McpClient + { + name = "Windsurf", + windowsConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + macConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + linuxConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", "windsurf", "mcp_config.json"), + HttpUrlProperty = "serverUrl", + DefaultUnityFields = { { "disabled", false } }, + StripEnvWhenNotRequired = true + }) + { } + + public override IList GetInstallationSteps() => new List + { + "Open Windsurf", + "Go to File > Preferences > Windsurf Settings > MCP > Manage MCPs > View raw config\nOR open the config file at the path above", + "Paste the configuration JSON", + "Save and restart Windsurf" + }; + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta new file mode 100644 index 0000000..f730d68 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b528971e189f141d38db577f155bd222 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/Configurators/WindsurfConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs b/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs new file mode 100644 index 0000000..9fdea29 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs @@ -0,0 +1,47 @@ +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Contract for MCP client configurators. Each client is responsible for + /// status detection, auto-configure, and manual snippet/steps. + /// + public interface IMcpClientConfigurator + { + /// Stable identifier (e.g., "cursor"). + string Id { get; } + + /// Display name shown in the UI. + string DisplayName { get; } + + /// Current status cached by the configurator. + McpStatus Status { get; } + + /// + /// The transport type the client is currently configured for. + /// Returns Unknown if the client is not configured or the transport cannot be determined. + /// + ConfiguredTransport ConfiguredTransport { get; } + + /// True if this client supports auto-configure. + bool SupportsAutoConfigure { get; } + + /// Label to show on the configure button for the current state. + string GetConfigureActionLabel(); + + /// Returns the platform-specific config path (or message for CLI-managed clients). + string GetConfigPath(); + + /// Checks and updates status; returns current status. + McpStatus CheckStatus(bool attemptAutoRewrite = true); + + /// Runs auto-configuration (register/write file/CLI etc.). + void Configure(); + + /// Returns the manual configuration snippet (JSON/TOML/commands). + string GetManualSnippet(); + + /// Returns ordered human-readable installation steps. + System.Collections.Generic.IList GetInstallationSteps(); + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta b/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta new file mode 100644 index 0000000..865b091 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f5a5078d9e6e14027a1abfebf4018634 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/IMcpClientConfigurator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs new file mode 100644 index 0000000..0d69caf --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -0,0 +1,925 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// Shared base class for MCP configurators. + public abstract class McpClientConfiguratorBase : IMcpClientConfigurator + { + protected readonly McpClient client; + + protected McpClientConfiguratorBase(McpClient client) + { + this.client = client; + } + + internal McpClient Client => client; + + public string Id => client.name.Replace(" ", "").ToLowerInvariant(); + public virtual string DisplayName => client.name; + public McpStatus Status => client.status; + public ConfiguredTransport ConfiguredTransport => client.configuredTransport; + public virtual bool SupportsAutoConfigure => true; + public virtual string GetConfigureActionLabel() => "Configure"; + + public abstract string GetConfigPath(); + public abstract McpStatus CheckStatus(bool attemptAutoRewrite = true); + public abstract void Configure(); + public abstract string GetManualSnippet(); + public abstract IList GetInstallationSteps(); + + protected string GetUvxPathOrError() + { + string uvx = MCPServiceLocator.Paths.GetUvxPath(); + if (string.IsNullOrEmpty(uvx)) + { + throw new InvalidOperationException("uvx not found. Install uv/uvx or set the override in Advanced Settings."); + } + return uvx; + } + + protected string CurrentOsPath() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return client.windowsConfigPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return client.macConfigPath; + return client.linuxConfigPath; + } + + protected bool UrlsEqual(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + { + return false; + } + + if (Uri.TryCreate(a.Trim(), UriKind.Absolute, out var uriA) && + Uri.TryCreate(b.Trim(), UriKind.Absolute, out var uriB)) + { + return Uri.Compare( + uriA, + uriB, + UriComponents.HttpRequestUrl, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + string Normalize(string value) => value.Trim().TrimEnd('/'); + return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase); + } + } + + /// JSON-file based configurator (Cursor, Windsurf, VS Code, etc.). + public abstract class JsonFileMcpConfigurator : McpClientConfiguratorBase + { + public JsonFileMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + string configJson = File.ReadAllText(path); + string[] args = null; + string configuredUrl = null; + bool configExists = false; + + if (client.IsVsCodeLayout) + { + var vsConfig = JsonConvert.DeserializeObject(configJson) as JObject; + if (vsConfig != null) + { + var unityToken = + vsConfig["servers"]?["unityMCP"] + ?? vsConfig["mcp"]?["servers"]?["unityMCP"]; + + if (unityToken is JObject unityObj) + { + configExists = true; + + var argsToken = unityObj["args"]; + if (argsToken is JArray) + { + args = argsToken.ToObject(); + } + + var urlToken = unityObj["url"] ?? unityObj["serverUrl"]; + if (urlToken != null && urlToken.Type != JTokenType.Null) + { + configuredUrl = urlToken.ToString(); + } + } + } + } + else + { + McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); + if (standardConfig?.mcpServers?.unityMCP != null) + { + args = standardConfig.mcpServers.unityMCP.args; + configuredUrl = standardConfig.mcpServers.unityMCP.url; + configExists = true; + } + } + + if (!configExists) + { + client.SetStatus(McpStatus.MissingConfig); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // Determine and set the configured transport type + if (args != null && args.Length > 0) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + // Distinguish HTTP Local from HTTP Remote by matching against both URLs + string localRpcUrl = HttpEndpointUtility.GetLocalMcpRpcUrl(); + string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); + if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(configuredUrl, remoteRpcUrl)) + { + client.configuredTransport = Models.ConfiguredTransport.HttpRemote; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Http; + } + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + bool matches = false; + if (args != null && args.Length > 0) + { + string expectedUvxUrl = AssetPathUtility.GetMcpServerPackageSource(); + string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configuredUvxUrl) && + McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl); + } + else if (!string.IsNullOrEmpty(configuredUrl)) + { + // Match against the active scope's URL + string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl(); + matches = UrlsEqual(configuredUrl, expectedUrl); + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + + if (attemptAutoRewrite) + { + var result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.WriteMcpConfiguration(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return ConfigJsonBuilder.BuildManualConfigJson(uvx, client); + } + catch (Exception ex) + { + var errorObj = new { error = ex.Message }; + return JsonConvert.SerializeObject(errorObj); + } + } + + public override IList GetInstallationSteps() => new List { "Configuration steps not available for this client." }; + } + + /// Codex (TOML) configurator. + public abstract class CodexMcpConfigurator : McpClientConfiguratorBase + { + public CodexMcpConfigurator(McpClient client) : base(client) { } + + public override string GetConfigPath() => CurrentOsPath(); + + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + try + { + string path = GetConfigPath(); + if (!File.Exists(path)) + { + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + string toml = File.ReadAllText(path); + if (CodexConfigHelper.TryParseCodexServer(toml, out _, out var args, out var url)) + { + // Determine and set the configured transport type + if (!string.IsNullOrEmpty(url)) + { + // Distinguish HTTP Local from HTTP Remote + string remoteRpcUrl = HttpEndpointUtility.GetRemoteMcpRpcUrl(); + if (!string.IsNullOrEmpty(remoteRpcUrl) && UrlsEqual(url, remoteRpcUrl)) + { + client.configuredTransport = Models.ConfiguredTransport.HttpRemote; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Http; + } + } + else if (args != null && args.Length > 0) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + bool matches = false; + if (!string.IsNullOrEmpty(url)) + { + // Match against the active scope's URL + matches = UrlsEqual(url, HttpEndpointUtility.GetMcpRpcUrl()); + } + else if (args != null && args.Length > 0) + { + string expected = AssetPathUtility.GetMcpServerPackageSource(); + string configured = McpConfigurationHelper.ExtractUvxUrl(args); + matches = !string.IsNullOrEmpty(configured) && + McpConfigurationHelper.PathsEqual(configured, expected); + } + + if (matches) + { + client.SetStatus(McpStatus.Configured); + return client.status; + } + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + if (attemptAutoRewrite) + { + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + else + { + client.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + string path = GetConfigPath(); + McpConfigurationHelper.EnsureConfigDirectoryExists(path); + string result = McpConfigurationHelper.ConfigureCodexClient(path, client); + if (result == "Configured successfully") + { + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + else + { + throw new InvalidOperationException(result); + } + } + + public override string GetManualSnippet() + { + try + { + string uvx = GetUvxPathOrError(); + return CodexConfigHelper.BuildCodexServerBlock(uvx); + } + catch (Exception ex) + { + return $"# error: {ex.Message}"; + } + } + + public override IList GetInstallationSteps() => new List + { + "Run 'codex config edit' or open the config path", + "Paste the TOML", + "Save and restart Codex" + }; + } + + /// CLI-based configurator (Claude Code). + public abstract class ClaudeCliMcpConfigurator : McpClientConfiguratorBase + { + public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } + + public override bool SupportsAutoConfigure => true; + public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Register"; + + public override string GetConfigPath() => "Managed via Claude CLI"; + + /// + /// Checks the Claude CLI registration status. + /// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access. + /// + public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + // Capture main-thread-only values before delegating to thread-safe method + string projectDir = Path.GetDirectoryName(Application.dataPath); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + // Resolve claudePath on the main thread (EditorPrefs access) + string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath(); + return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite); + } + + /// + /// Internal thread-safe version of CheckStatus. + /// Can be called from background threads because all main-thread-only values are passed as parameters. + /// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time. + /// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread. + /// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration + /// on the main thread based on the returned status. + /// + internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false) + { + try + { + if (string.IsNullOrEmpty(claudePath)) + { + client.SetStatus(McpStatus.NotConfigured, "Claude CLI not found"); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + return client.status; + } + + // projectDir is required - no fallback to Application.dataPath + if (string.IsNullOrEmpty(projectDir)) + { + throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution"); + } + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + // Check if UnityMCP exists (handles both "UnityMCP" and legacy "unityMCP") + if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend)) + { + if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) + { + // UnityMCP is registered - now verify transport mode matches + // useHttpTransport parameter is required (non-nullable) to ensure thread safety + bool currentUseHttp = useHttpTransport; + + // Get detailed info about the registration to check transport type + // Try both "UnityMCP" and "unityMCP" (legacy naming) + string getStdout = null, getStderr = null; + bool gotInfo = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend) + || ExecPath.TryRun(claudePath, "mcp get unityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend); + if (gotInfo) + { + // Parse the output to determine registered transport mode + // The CLI output format contains "Type: http" or "Type: stdio" + bool registeredWithHttp = getStdout.Contains("Type: http", StringComparison.OrdinalIgnoreCase); + bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase); + + // Set the configured transport based on what we detected + // For HTTP, we can't distinguish local/remote from CLI output alone, + // so infer from the current scope setting when HTTP is detected. + if (registeredWithHttp) + { + client.configuredTransport = HttpEndpointUtility.IsRemoteScope() + ? Models.ConfiguredTransport.HttpRemote + : Models.ConfiguredTransport.Http; + } + else if (registeredWithStdio) + { + client.configuredTransport = Models.ConfiguredTransport.Stdio; + } + else + { + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + // Check for transport mismatch (3-way: Stdio, Http, HttpRemote) + bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp); + + // For stdio transport, also check package version + bool hasVersionMismatch = false; + string configuredPackageSource = null; + string expectedPackageSource = null; + if (registeredWithStdio) + { + expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource(); + configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout); + hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) && + !string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase); + } + + // If there's any mismatch and auto-rewrite is enabled, re-register + if (hasTransportMismatch || hasVersionMismatch) + { + // Configure() requires main thread (accesses EditorPrefs, Application.dataPath) + // Only attempt auto-rewrite if we're on the main thread + bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1; + if (attemptAutoRewrite && isMainThread) + { + string reason = hasTransportMismatch + ? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})" + : $"Package version mismatch (registered: {configuredPackageSource}, expected: {expectedPackageSource})"; + McpLog.Info($"{reason}. Re-registering..."); + try + { + // Force re-register by ensuring status is not Configured (which would toggle to Unregister) + client.SetStatus(McpStatus.IncorrectPath); + Configure(); + return client.status; + } + catch (Exception ex) + { + McpLog.Warn($"Auto-reregister failed: {ex.Message}"); + client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register."); + return client.status; + } + } + else + { + if (hasTransportMismatch) + { + string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register."; + client.SetStatus(McpStatus.Error, errorMsg); + McpLog.Warn(errorMsg); + } + else + { + client.SetStatus(McpStatus.IncorrectPath, $"Package version mismatch: registered with '{configuredPackageSource}' but current version is '{expectedPackageSource}'."); + } + return client.status; + } + } + } + + client.SetStatus(McpStatus.Configured); + return client.status; + } + } + + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + catch (Exception ex) + { + client.SetStatus(McpStatus.Error, ex.Message); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + return client.status; + } + + public override void Configure() + { + if (client.status == McpStatus.Configured) + { + Unregister(); + } + else + { + Register(); + } + } + + /// + /// Thread-safe version of Configure that uses pre-captured main-thread values. + /// All parameters must be captured on the main thread before calling this method. + /// + public void ConfigureWithCapturedValues( + string projectDir, string claudePath, string pathPrepend, + bool useHttpTransport, string httpUrl, + string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh, + string apiKey, + Models.ConfiguredTransport serverTransport) + { + if (client.status == McpStatus.Configured) + { + UnregisterWithCapturedValues(projectDir, claudePath, pathPrepend); + } + else + { + RegisterWithCapturedValues(projectDir, claudePath, pathPrepend, + useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh, + apiKey, serverTransport); + } + } + + /// + /// Thread-safe registration using pre-captured values. + /// + private void RegisterWithCapturedValues( + string projectDir, string claudePath, string pathPrepend, + bool useHttpTransport, string httpUrl, + string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh, + string apiKey, + Models.ConfiguredTransport serverTransport) + { + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string args; + if (useHttpTransport) + { + // Only include API key header for remote-hosted mode + if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + } + else + { + // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead + string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty; + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; + } + + // Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy) + McpLog.Info("Removing any existing UnityMCP registrations before adding..."); + ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + + // Now add the registration + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + + McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); + client.SetStatus(McpStatus.Configured); + client.configuredTransport = serverTransport; + } + + /// + /// Thread-safe unregistration using pre-captured values. + /// + private void UnregisterWithCapturedValues(string projectDir, string claudePath, string pathPrepend) + { + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + // Remove both "UnityMCP" and "unityMCP" (legacy naming) + McpLog.Info("Removing all UnityMCP registrations..."); + ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + + McpLog.Info("MCP server successfully unregistered from Claude Code."); + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + private void Register() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + + string args; + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + // Only include API key header for remote-hosted mode + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + string safeKey = SanitizeShellHeaderValue(apiKey); + args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\""; + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + } + else + { + args = $"mcp add --transport http UnityMCP {httpUrl}"; + } + } + else + { + var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}"; + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + try + { + string claudeDir = Path.GetDirectoryName(claudePath); + if (!string.IsNullOrEmpty(claudeDir)) + { + pathPrepend = string.IsNullOrEmpty(pathPrepend) + ? claudeDir + : $"{claudeDir}:{pathPrepend}"; + } + } + catch { } + + // Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy) + McpLog.Info("Removing any existing UnityMCP registrations before adding..."); + ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + + // Now add the registration with the current transport mode + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + } + + McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); + + // Set status to Configured immediately after successful registration + // The UI will trigger an async verification check separately to avoid blocking + client.SetStatus(McpStatus.Configured); + client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport(); + } + + private void Unregister() + { + var pathService = MCPServiceLocator.Paths; + string claudePath = pathService.GetClaudeCliPath(); + + if (string.IsNullOrEmpty(claudePath)) + { + throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first."); + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor) + { + pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + } + else if (Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = "/usr/local/bin:/usr/bin:/bin"; + } + + // Remove both "UnityMCP" and "unityMCP" (legacy naming) + McpLog.Info("Removing all UnityMCP registrations..."); + ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend); + + McpLog.Info("MCP server successfully unregistered from Claude Code."); + client.SetStatus(McpStatus.NotConfigured); + client.configuredTransport = Models.ConfiguredTransport.Unknown; + } + + public override string GetManualSnippet() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + + if (useHttpTransport) + { + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + // Only include API key header for remote-hosted mode + string headerArg = ""; + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : ""; + } + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --transport http UnityMCP {httpUrl}{headerArg}\n\n" + + "# Unregister the MCP server:\n" + + "claude mcp remove UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list"; + } + + if (string.IsNullOrEmpty(uvxPath)) + { + return "# Error: Configuration not available - check paths in Advanced Settings"; + } + + string packageSource = AssetPathUtility.GetMcpServerPackageSource(); + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + + return "# Register the MCP server with Claude Code:\n" + + $"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{packageSource}\" mcp-for-unity\n\n" + + "# Unregister the MCP server:\n" + + "claude mcp remove UnityMCP\n\n" + + "# List registered servers:\n" + + "claude mcp list"; + } + + public override IList GetInstallationSteps() => new List + { + "Ensure Claude CLI is installed", + "Use Register to add UnityMCP (or run claude mcp add UnityMCP)", + "Restart Claude Code" + }; + + /// + /// Sanitizes a value for safe inclusion inside a double-quoted shell argument. + /// Escapes characters that are special within double quotes (", \, `, $, !) + /// to prevent shell injection or argument splitting. + /// + private static string SanitizeShellHeaderValue(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + var sb = new System.Text.StringBuilder(value.Length); + foreach (char c in value) + { + switch (c) + { + case '"': + case '\\': + case '`': + case '$': + case '!': + sb.Append('\\'); + sb.Append(c); + break; + default: + sb.Append(c); + break; + } + } + return sb.ToString(); + } + + /// + /// Extracts the package source (--from argument value) from claude mcp get output. + /// The output format includes args like: --from "mcpforunityserver==9.0.1" + /// + private static string ExtractPackageSourceFromCliOutput(string cliOutput) + { + if (string.IsNullOrEmpty(cliOutput)) + return null; + + // Look for --from followed by the package source + // The CLI output may have it quoted or unquoted + int fromIndex = cliOutput.IndexOf("--from", StringComparison.OrdinalIgnoreCase); + if (fromIndex < 0) + return null; + + // Move past "--from" and any whitespace + int startIndex = fromIndex + 6; + while (startIndex < cliOutput.Length && char.IsWhiteSpace(cliOutput[startIndex])) + startIndex++; + + if (startIndex >= cliOutput.Length) + return null; + + // Check if value is quoted + char quoteChar = cliOutput[startIndex]; + if (quoteChar == '"' || quoteChar == '\'') + { + startIndex++; + int endIndex = cliOutput.IndexOf(quoteChar, startIndex); + if (endIndex > startIndex) + return cliOutput.Substring(startIndex, endIndex - startIndex); + } + else + { + // Unquoted - read until whitespace or end of line + int endIndex = startIndex; + while (endIndex < cliOutput.Length && !char.IsWhiteSpace(cliOutput[endIndex])) + endIndex++; + + if (endIndex > startIndex) + return cliOutput.Substring(startIndex, endIndex - startIndex); + } + + return null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta b/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta new file mode 100644 index 0000000..da94d06 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8d408fd7733cb4a1eb80f785307db2ff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs b/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs new file mode 100644 index 0000000..57e4dc1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Clients +{ + /// + /// Central registry that auto-discovers configurators via TypeCache. + /// + public static class McpClientRegistry + { + private static List cached; + + public static IReadOnlyList All + { + get + { + if (cached == null) + { + cached = BuildRegistry(); + } + return cached; + } + } + + private static List BuildRegistry() + { + var configurators = new List(); + + foreach (var type in TypeCache.GetTypesDerivedFrom()) + { + if (type.IsAbstract || !type.IsClass || !type.IsPublic) + continue; + + // Require a public parameterless constructor + if (type.GetConstructor(Type.EmptyTypes) == null) + continue; + + try + { + if (Activator.CreateInstance(type) is IMcpClientConfigurator instance) + { + configurators.Add(instance); + } + } + catch (Exception ex) + { + McpLog.Warn($"UnityMCP: Failed to instantiate configurator {type.Name}: {ex.Message}"); + } + } + + // Alphabetical order by display name + configurators = configurators.OrderBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase).ToList(); + return configurators; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta b/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta new file mode 100644 index 0000000..24c57ea --- /dev/null +++ b/Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4ce08555f995e4e848a826c63f18cb35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Clients/McpClientRegistry.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Constants.meta b/Assets/MCPForUnity/Editor/Constants.meta new file mode 100644 index 0000000..7c23235 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f7e009cbf3e74f6c987331c2b438ec59 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs b/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs new file mode 100644 index 0000000..76579e6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs @@ -0,0 +1,10 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Protocol-level constants for API key authentication. + /// + internal static class AuthConstants + { + internal const string ApiKeyHeader = "X-API-Key"; + } +} diff --git a/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs.meta b/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs.meta new file mode 100644 index 0000000..2c43b99 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/AuthConstants.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 96844bc39e9a94cf18b18f8127f3854f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Constants/AuthConstants.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs new file mode 100644 index 0000000..488bf39 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -0,0 +1,66 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Centralized list of EditorPrefs keys used by the MCP for Unity package. + /// Keeping them in one place avoids typos and simplifies migrations. + /// + internal static class EditorPrefKeys + { + internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport"; + internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote" + internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid"; + internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort"; + internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc"; + internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash"; + internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath"; + internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken"; + internal const string DebugLogs = "MCPForUnity.DebugLogs"; + internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; + internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; + internal const string ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload"; + internal const string ResumeStdioAfterReload = "MCPForUnity.ResumeStdioAfterReload"; + + internal const string UvxPathOverride = "MCPForUnity.UvxPath"; + internal const string ClaudeCliPathOverride = "MCPForUnity.ClaudeCliPath"; + + internal const string HttpBaseUrl = "MCPForUnity.HttpUrl"; + internal const string HttpRemoteBaseUrl = "MCPForUnity.HttpRemoteUrl"; + internal const string SessionId = "MCPForUnity.SessionId"; + internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl"; + internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride"; + internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh"; + internal const string UseBetaServer = "MCPForUnity.UseBetaServer"; + internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp"; + + internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath"; + internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath"; + internal const string PackageDeployLastTargetPath = "MCPForUnity.PackageDeploy.LastTargetPath"; + internal const string PackageDeployLastSourcePath = "MCPForUnity.PackageDeploy.LastSourcePath"; + + internal const string ServerSrc = "MCPForUnity.ServerSrc"; + internal const string UseEmbeddedServer = "MCPForUnity.UseEmbeddedServer"; + internal const string LockCursorConfig = "MCPForUnity.LockCursorConfig"; + internal const string AutoRegisterEnabled = "MCPForUnity.AutoRegisterEnabled"; + internal const string ToolEnabledPrefix = "MCPForUnity.ToolEnabled."; + internal const string ToolFoldoutStatePrefix = "MCPForUnity.ToolFoldout."; + internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled."; + internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout."; + internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel"; + + internal const string SetupCompleted = "MCPForUnity.SetupCompleted"; + internal const string SetupDismissed = "MCPForUnity.SetupDismissed"; + + internal const string CustomToolRegistrationEnabled = "MCPForUnity.CustomToolRegistrationEnabled"; + + internal const string LastUpdateCheck = "MCPForUnity.LastUpdateCheck"; + internal const string LatestKnownVersion = "MCPForUnity.LatestKnownVersion"; + internal const string LastAssetStoreUpdateCheck = "MCPForUnity.LastAssetStoreUpdateCheck"; + internal const string LatestKnownAssetStoreVersion = "MCPForUnity.LatestKnownAssetStoreVersion"; + internal const string LastStdIoUpgradeVersion = "MCPForUnity.LastStdIoUpgradeVersion"; + + internal const string TelemetryDisabled = "MCPForUnity.TelemetryDisabled"; + internal const string CustomerUuid = "MCPForUnity.CustomerUUID"; + + internal const string ApiKey = "MCPForUnity.ApiKey"; + } +} diff --git a/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta b/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta new file mode 100644 index 0000000..3f69bac --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7317786cfb9304b0db20ca73a774b9fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Constants/EditorPrefKeys.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs b/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs new file mode 100644 index 0000000..7506caa --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs @@ -0,0 +1,14 @@ +namespace MCPForUnity.Editor.Constants +{ + /// + /// Constants for health check status values. + /// Used for coordinating health state between Connection and Advanced sections. + /// + public static class HealthStatus + { + public const string Unknown = "Unknown"; + public const string Healthy = "Healthy"; + public const string PingFailed = "Ping Failed"; + public const string Unhealthy = "Unhealthy"; + } +} diff --git a/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs.meta b/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs.meta new file mode 100644 index 0000000..7db5589 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Constants/HealthStatus.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c15ed2426f43860479f1b8a99a343d16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Constants/HealthStatus.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies.meta b/Assets/MCPForUnity/Editor/Dependencies.meta new file mode 100644 index 0000000..77685d1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 221a4d6e595be6897a5b17b77aedd4d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs new file mode 100644 index 0000000..c3802c4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Dependencies.PlatformDetectors; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Main orchestrator for dependency validation and management + /// + public static class DependencyManager + { + private static readonly List _detectors = new List + { + new WindowsPlatformDetector(), + new MacOSPlatformDetector(), + new LinuxPlatformDetector() + }; + + private static IPlatformDetector _currentDetector; + + /// + /// Get the platform detector for the current operating system + /// + public static IPlatformDetector GetCurrentPlatformDetector() + { + if (_currentDetector == null) + { + _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); + if (_currentDetector == null) + { + throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); + } + } + return _currentDetector; + } + + /// + /// Perform a comprehensive dependency check + /// + public static DependencyCheckResult CheckAllDependencies() + { + var result = new DependencyCheckResult(); + + try + { + var detector = GetCurrentPlatformDetector(); + McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); + + // Check Python + var pythonStatus = detector.DetectPython(); + result.Dependencies.Add(pythonStatus); + + // Check uv + var uvStatus = detector.DetectUv(); + result.Dependencies.Add(uvStatus); + + // Generate summary and recommendations + result.GenerateSummary(); + GenerateRecommendations(result, detector); + + McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); + } + catch (Exception ex) + { + McpLog.Error($"Error during dependency check: {ex.Message}"); + result.Summary = $"Dependency check failed: {ex.Message}"; + result.IsSystemReady = false; + } + + return result; + } + + /// + /// Get installation recommendations for the current platform + /// + public static string GetInstallationRecommendations() + { + try + { + var detector = GetCurrentPlatformDetector(); + return detector.GetInstallationRecommendations(); + } + catch (Exception ex) + { + return $"Error getting installation recommendations: {ex.Message}"; + } + } + + /// + /// Get platform-specific installation URLs + /// + public static (string pythonUrl, string uvUrl) GetInstallationUrls() + { + try + { + var detector = GetCurrentPlatformDetector(); + return (detector.GetPythonInstallUrl(), detector.GetUvInstallUrl()); + } + catch + { + return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); + } + } + + private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) + { + var missing = result.GetMissingDependencies(); + + if (missing.Count == 0) + { + result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); + return; + } + + foreach (var dep in missing) + { + if (dep.Name == "Python") + { + result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); + } + else if (dep.Name == "uv Package Manager") + { + result.RecommendedActions.Add($"Install uv package manager from: {detector.GetUvInstallUrl()}"); + } + else if (dep.Name == "MCP Server") + { + result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); + } + } + + if (result.GetMissingRequired().Count > 0) + { + result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Local Setup Window) for guided installation."); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta new file mode 100644 index 0000000..dc58085 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/DependencyManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/Models.meta b/Assets/MCPForUnity/Editor/Dependencies/Models.meta new file mode 100644 index 0000000..1e05b9d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4c0f2e87395b4c6c9df8c21b6d0fae13 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs new file mode 100644 index 0000000..5dd2eda --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Result of a comprehensive dependency check + /// + [Serializable] + public class DependencyCheckResult + { + /// + /// List of all dependency statuses checked + /// + public List Dependencies { get; set; } + + /// + /// Overall system readiness for MCP operations + /// + public bool IsSystemReady { get; set; } + + /// + /// Whether all required dependencies are available + /// + public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; + + /// + /// Whether any optional dependencies are missing + /// + public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; + + /// + /// Summary message about the dependency state + /// + public string Summary { get; set; } + + /// + /// Recommended next steps for the user + /// + public List RecommendedActions { get; set; } + + /// + /// Timestamp when this check was performed + /// + public DateTime CheckedAt { get; set; } + + public DependencyCheckResult() + { + Dependencies = new List(); + RecommendedActions = new List(); + CheckedAt = DateTime.UtcNow; + } + + /// + /// Get dependencies by availability status + /// + public List GetMissingDependencies() + { + return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Get missing required dependencies + /// + public List GetMissingRequired() + { + return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Generate a user-friendly summary of the dependency state + /// + public void GenerateSummary() + { + var missing = GetMissingDependencies(); + var missingRequired = GetMissingRequired(); + + if (missing.Count == 0) + { + Summary = "All dependencies are available and ready."; + IsSystemReady = true; + } + else if (missingRequired.Count == 0) + { + Summary = $"System is ready. {missing.Count} optional dependencies are missing."; + IsSystemReady = true; + } + else + { + Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; + IsSystemReady = false; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta new file mode 100644 index 0000000..cd0f4df --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f6df82faa423f4e9ebb13a3dcee8ba19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs new file mode 100644 index 0000000..e755eca --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs @@ -0,0 +1,65 @@ +using System; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Represents the status of a dependency check + /// + [Serializable] + public class DependencyStatus + { + /// + /// Name of the dependency being checked + /// + public string Name { get; set; } + + /// + /// Whether the dependency is available and functional + /// + public bool IsAvailable { get; set; } + + /// + /// Version information if available + /// + public string Version { get; set; } + + /// + /// Path to the dependency executable/installation + /// + public string Path { get; set; } + + /// + /// Additional details about the dependency status + /// + public string Details { get; set; } + + /// + /// Error message if dependency check failed + /// + public string ErrorMessage { get; set; } + + /// + /// Whether this dependency is required for basic functionality + /// + public bool IsRequired { get; set; } + + /// + /// Suggested installation method or URL + /// + public string InstallationHint { get; set; } + + public DependencyStatus(string name, bool isRequired = true) + { + Name = name; + IsRequired = isRequired; + IsAvailable = false; + } + + public override string ToString() + { + var status = IsAvailable ? "✓" : "✗"; + var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : ""; + return $"{status} {Name}{version}"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta new file mode 100644 index 0000000..0fba554 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ddeeeca2f876f4083a84417404175199 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta new file mode 100644 index 0000000..8c86307 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bdbaced669d14798a4ceeebfbff2b22c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs new file mode 100644 index 0000000..3231105 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -0,0 +1,45 @@ +using MCPForUnity.Editor.Dependencies.Models; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Interface for platform-specific dependency detection + /// + public interface IPlatformDetector + { + /// + /// Platform name this detector handles + /// + string PlatformName { get; } + + /// + /// Whether this detector can run on the current platform + /// + bool CanDetect { get; } + + /// + /// Detect Python installation on this platform + /// + DependencyStatus DetectPython(); + + /// + /// Detect uv package manager on this platform + /// + DependencyStatus DetectUv(); + + /// + /// Get platform-specific installation recommendations + /// + string GetInstallationRecommendations(); + + /// + /// Get platform-specific Python installation URL + /// + string GetPythonInstallUrl(); + + /// + /// Get platform-specific uv installation URL + /// + string GetUvInstallUrl(); + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta new file mode 100644 index 0000000..7585b1e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 67d73d0e8caef4e60942f4419c6b76bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs new file mode 100644 index 0000000..be9db17 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -0,0 +1,207 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Linux-specific dependency detection + /// + public class LinuxPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Linux"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Try running python directly first + if (TryValidatePython("python3", out string version, out string fullPath) || + TryValidatePython("python", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + + // Fallback: try 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/source/"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#linux"; + } + + public override string GetInstallationRecommendations() + { + return @"Linux Installation Recommendations: + +1. Python: Install via package manager or pyenv + - Ubuntu/Debian: sudo apt install python3 python3-pip + - Fedora/RHEL: sudo dnf install python3 python3-pip + - Arch: sudo pacman -S python python-pip + - Or use pyenv: https://github.com/pyenv/pyenv + +2. uv Package Manager: Install via curl + - Run: curl -LsSf https://astral.sh/uv/install.sh | sh + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by MCP for Unity + +Note: Make sure ~/.local/bin is in your PATH for user-local installations."; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta new file mode 100644 index 0000000..53e3215 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b682b492eb80d4ed6834b76f72c9f0f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs new file mode 100644 index 0000000..b162f11 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -0,0 +1,205 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// macOS-specific dependency detection + /// + public class MacOSPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "macOS"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // 1. Try 'which' command with augmented PATH (prioritizing Homebrew) + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // 2. Fallback: Try running python directly from PATH + if (TryValidatePython("python3", out string v, out string p) || + TryValidatePython("python", out v, out p)) + { + status.IsAvailable = true; + status.Version = v; + status.Path = p; + status.Details = $"Found Python {v} in PATH"; + return status; + } + + status.ErrorMessage = "Python not found in PATH or standard locations"; + status.Details = "Install Python 3.10+ via Homebrew ('brew install python3') and ensure it's in your PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/macos/"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#macos"; + } + + public override string GetInstallationRecommendations() + { + return @"macOS Installation Recommendations: + +1. Python: Install via Homebrew (recommended) or python.org + - Homebrew: brew install python3 + - Direct download: https://python.org/downloads/macos/ + +2. uv Package Manager: Install via curl or Homebrew + - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh + - Homebrew: brew install uv + +3. MCP Server: Will be installed automatically by MCP for Unity Bridge + +Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // Try uv first, then uvx, using ExecPath.TryRun for proper timeout handling + if (TryValidateUvWithPath("uv", augmentedPath, out string version, out string fullPath) || + TryValidateUvWithPath("uvx", augmentedPath, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found uv {version} in PATH"; + status.ErrorMessage = null; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta new file mode 100644 index 0000000..c5065c7 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c6f602b0a8ca848859197f9a949a7a5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs new file mode 100644 index 0000000..c955381 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -0,0 +1,137 @@ +using System; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Base class for platform-specific dependency detection + /// + public abstract class PlatformDetectorBase : IPlatformDetector + { + public abstract string PlatformName { get; } + public abstract bool CanDetect { get; } + + public abstract DependencyStatus DetectPython(); + public abstract string GetPythonInstallUrl(); + public abstract string GetUvInstallUrl(); + public abstract string GetInstallationRecommendations(); + + public virtual DependencyStatus DetectUv() + { + var status = new DependencyStatus("uv Package Manager", isRequired: true) + { + InstallationHint = GetUvInstallUrl() + }; + + try + { + // Get uv path from PathResolverService (respects override) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + + // Verify uv executable and get version + if (MCPServiceLocator.Paths.TryValidateUvxExecutable(uvxPath, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = uvxPath; + + // Check if we used fallback from override to system path + if (MCPServiceLocator.Paths.HasUvxPathFallback) + { + status.Details = $"Found uv {version} (fallback to system path)"; + status.ErrorMessage = "Override path not found, using system path"; + } + else + { + status.Details = MCPServiceLocator.Paths.HasUvxPathOverride + ? $"Found uv {version} (override path)" + : $"Found uv {version} in system path"; + } + return status; + } + + status.ErrorMessage = "uvx not found"; + status.Details = "Install uv package manager or configure path override in Advanced Settings."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uvx: {ex.Message}"; + } + + return status; + } + + + protected bool TryParseVersion(string version, out int major, out int minor) + { + major = 0; + minor = 0; + + try + { + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + } + } + catch + { + // Ignore parsing errors + } + + return false; + } + // In PlatformDetectorBase.cs + protected bool TryValidateUvWithPath(string command, string augmentedPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string commandToRun = command; + if (TryFindInPath(command, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, + 5000, augmentedPath)) + return false; + + string output = string.IsNullOrWhiteSpace(stdout) ? stderr.Trim() : stdout.Trim(); + + if (output.StartsWith("uvx ") || output.StartsWith("uv ")) + { + int spaceIndex = output.IndexOf(' '); + if (spaceIndex >= 0) + { + var remainder = output.Substring(spaceIndex + 1).Trim(); + int nextSpace = remainder.IndexOf(' '); + int parenIndex = remainder.IndexOf('('); + int endIndex = Math.Min( + nextSpace >= 0 ? nextSpace : int.MaxValue, + parenIndex >= 0 ? parenIndex : int.MaxValue + ); + version = endIndex < int.MaxValue ? remainder.Substring(0, endIndex).Trim() : remainder; + fullPath = commandToRun; + return true; + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + + // Add abstract method for subclasses to implement + protected abstract bool TryFindInPath(string executable, out string fullPath); + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta new file mode 100644 index 0000000..e5d6a8c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 44d715aedea2b8b41bf914433bbb2c49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs new file mode 100644 index 0000000..706e503 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Windows-specific dependency detection + /// + public class WindowsPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Windows"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Try running python directly first (works with Windows App Execution Aliases) + if (TryValidatePython("python3.exe", out string version, out string fullPath) || + TryValidatePython("python.exe", out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + + // Fallback: try 'where' command + if (TryFindInPath("python3.exe", out string pathResult) || + TryFindInPath("python.exe", out pathResult)) + { + if (TryValidatePython(pathResult, out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH"; + return status; + } + } + + // Fallback: try to find python via uv + if (TryFindPythonViaUv(out version, out fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} via uv"; + return status; + } + + status.ErrorMessage = "Python not found in PATH"; + status.Details = "Install Python 3.10+ and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; + } + + public override string GetUvInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#windows"; + } + + public override string GetInstallationRecommendations() + { + return @"Windows Installation Recommendations: + +1. Python: Install from Microsoft Store or python.org + - Microsoft Store: Search for 'Python 3.10' or higher + - Direct download: https://python.org/downloads/windows/ + +2. uv Package Manager: Install via PowerShell + - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by MCP for Unity Bridge"; + } + + public override DependencyStatus DetectUv() + { + // First, honor overrides and cross-platform resolution via the base implementation + var status = base.DetectUv(); + if (status.IsAvailable) + { + return status; + } + + // If the user configured an override path but fallback was not used, keep the base result + // (failure typically means the override path is invalid and no system fallback found) + if (MCPServiceLocator.Paths.HasUvxPathOverride && !MCPServiceLocator.Paths.HasUvxPathFallback) + { + return status; + } + + try + { + string augmentedPath = BuildAugmentedPath(); + + // try to find uv + if (TryValidateUvWithPath("uv.exe", augmentedPath, out string uvVersion, out string uvPath)) + { + status.IsAvailable = true; + status.Version = uvVersion; + status.Path = uvPath; + status.Details = $"Found uv {uvVersion} at {uvPath}"; + return status; + } + + // try to find uvx + if (TryValidateUvWithPath("uvx.exe", augmentedPath, out string uvxVersion, out string uvxPath)) + { + status.IsAvailable = true; + status.Version = uvxVersion; + status.Path = uvxPath; + status.Details = $"Found uvx {uvxVersion} at {uvxPath} (fallback)"; + return status; + } + + status.ErrorMessage = "uv not found in PATH"; + status.Details = "Install uv package manager and ensure it's added to PATH."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting uv: {ex.Message}"; + } + + return status; + } + + + private bool TryFindPythonViaUv(out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + // Try to list installed python versions via uvx + if (!ExecPath.TryRun("uv", "python list", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; + + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains("")) continue; + + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + string potentialPath = parts[parts.Length - 1]; + if (File.Exists(potentialPath) && + (potentialPath.EndsWith("python.exe") || potentialPath.EndsWith("python3.exe"))) + { + if (TryValidatePython(potentialPath, out version, out fullPath)) + { + return true; + } + } + } + } + } + catch + { + // Ignore errors if uv is not installed or fails + } + + return false; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + string augmentedPath = BuildAugmentedPath(); + + // First, try to resolve the absolute path for better UI/logging display + string commandToRun = pythonPath; + if (TryFindInPath(pythonPath, out string resolvedPath)) + { + commandToRun = resolvedPath; + } + + // Run 'python --version' to get the version + if (!ExecPath.TryRun(commandToRun, "--version", null, out string stdout, out string stderr, 5000, augmentedPath)) + return false; + + // Check stdout first, then stderr (some Python distributions output to stderr) + string output = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + if (output.StartsWith("Python ")) + { + version = output.Substring(7); + fullPath = commandToRun; + + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major == 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected override bool TryFindInPath(string executable, out string fullPath) + { + fullPath = ExecPath.FindInPath(executable, BuildAugmentedPath()); + return !string.IsNullOrEmpty(fullPath); + } + + protected string BuildAugmentedPath() + { + var additions = GetPathAdditions(); + if (additions.Length == 0) return null; + + // Only return the additions - ExecPath.TryRun will prepend to existing PATH + return string.Join(Path.PathSeparator, additions); + } + + private string[] GetPathAdditions() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + var additions = new List(); + + // uv common installation paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "uv")); + if (!string.IsNullOrEmpty(programFiles)) + additions.Add(Path.Combine(programFiles, "uv")); + + // npm global paths + if (!string.IsNullOrEmpty(appData)) + additions.Add(Path.Combine(appData, "npm")); + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "npm")); + + // Python common paths + if (!string.IsNullOrEmpty(localAppData)) + additions.Add(Path.Combine(localAppData, "Programs", "Python")); + // Instead of hardcoded versions, enumerate existing directories + if (!string.IsNullOrEmpty(programFiles)) + { + try + { + var pythonDirs = Directory.GetDirectories(programFiles, "Python3*") + .OrderByDescending(d => d); // Newest first + foreach (var dir in pythonDirs) + { + additions.Add(dir); + } + } + catch { /* Ignore if directory doesn't exist */ } + } + + // User scripts + if (!string.IsNullOrEmpty(homeDir)) + additions.Add(Path.Combine(homeDir, ".local", "bin")); + + return additions.ToArray(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta new file mode 100644 index 0000000..860924e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 1aedc29caa5704c07b487d20a27e9334 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/External.meta b/Assets/MCPForUnity/Editor/External.meta new file mode 100644 index 0000000..ce757b1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/External.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c11944bcfb9ec4576bab52874b7df584 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/External/Tommy.cs b/Assets/MCPForUnity/Editor/External/Tommy.cs new file mode 100644 index 0000000..22e83b8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/External/Tommy.cs @@ -0,0 +1,2138 @@ +#region LICENSE + +/* + * MIT License + * + * Copyright (c) 2020 Denis Zhidkikh + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace MCPForUnity.External.Tommy +{ + #region TOML Nodes + + public abstract class TomlNode : IEnumerable + { + public virtual bool HasValue { get; } = false; + public virtual bool IsArray { get; } = false; + public virtual bool IsTable { get; } = false; + public virtual bool IsString { get; } = false; + public virtual bool IsInteger { get; } = false; + public virtual bool IsFloat { get; } = false; + public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; + public virtual bool IsDateTimeLocal { get; } = false; + public virtual bool IsDateTimeOffset { get; } = false; + public virtual bool IsBoolean { get; } = false; + public virtual string Comment { get; set; } + public virtual int CollapseLevel { get; set; } + + public virtual TomlTable AsTable => this as TomlTable; + public virtual TomlString AsString => this as TomlString; + public virtual TomlInteger AsInteger => this as TomlInteger; + public virtual TomlFloat AsFloat => this as TomlFloat; + public virtual TomlBoolean AsBoolean => this as TomlBoolean; + public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; + public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; + public virtual TomlDateTime AsDateTime => this as TomlDateTime; + public virtual TomlArray AsArray => this as TomlArray; + + public virtual int ChildrenCount => 0; + + public virtual TomlNode this[string key] + { + get => null; + set { } + } + + public virtual TomlNode this[int index] + { + get => null; + set { } + } + + public virtual IEnumerable Children + { + get { yield break; } + } + + public virtual IEnumerable Keys + { + get { yield break; } + } + + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + + public virtual bool TryGetNode(string key, out TomlNode node) + { + node = null; + return false; + } + + public virtual bool HasKey(string key) => false; + + public virtual bool HasItemAt(int index) => false; + + public virtual void Add(string key, TomlNode node) { } + + public virtual void Add(TomlNode node) { } + + public virtual void Delete(TomlNode node) { } + + public virtual void Delete(string key) { } + + public virtual void Delete(int index) { } + + public virtual void AddRange(IEnumerable nodes) + { + foreach (var tomlNode in nodes) Add(tomlNode); + } + + public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); + + public virtual string ToInlineToml() => ToString(); + + #region Native type to TOML cast + + public static implicit operator TomlNode(string value) => new TomlString { Value = value }; + + public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; + + public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; + + public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; + + public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; + + public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; + + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; + + public static implicit operator TomlNode(TomlNode[] nodes) + { + var result = new TomlArray(); + result.AddRange(nodes); + return result; + } + + #endregion + + #region TOML to native type cast + + public static implicit operator string(TomlNode value) => value.ToString(); + + public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; + + public static implicit operator long(TomlNode value) => value.AsInteger.Value; + + public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; + + public static implicit operator double(TomlNode value) => value.AsFloat.Value; + + public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; + + public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; + + public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; + + #endregion + } + + public class TomlString : TomlNode + { + public override bool HasValue { get; } = true; + public override bool IsString { get; } = true; + public bool IsMultiline { get; set; } + public bool MultilineTrimFirstLine { get; set; } + public bool PreferLiteral { get; set; } + + public string Value { get; set; } + + public override string ToString() => Value; + + public override string ToInlineToml() + { + // Automatically convert literal to non-literal if there are too many literal string symbols + if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; + var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, + IsMultiline ? 3 : 1); + var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); + if (IsMultiline) + result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); + if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) + result = $"{Environment.NewLine}{result}"; + return $"{quotes}{result}{quotes}"; + } + } + + public class TomlInteger : TomlNode + { + public enum Base + { + Binary = 2, + Octal = 8, + Decimal = 10, + Hexadecimal = 16 + } + + public override bool IsInteger { get; } = true; + public override bool HasValue { get; } = true; + public Base IntegerBase { get; set; } = Base.Decimal; + + public long Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => + IntegerBase != Base.Decimal + ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" + : Value.ToString(CultureInfo.InvariantCulture); + } + + public class TomlFloat : TomlNode, IFormattable + { + public override bool IsFloat { get; } = true; + public override bool HasValue { get; } = true; + + public double Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); + + public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToInlineToml() => + Value switch + { + var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, + var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, + var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, + var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() + }; + } + + public class TomlBoolean : TomlNode + { + public override bool IsBoolean { get; } = true; + public override bool HasValue { get; } = true; + + public bool Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; + } + + public class TomlDateTime : TomlNode, IFormattable + { + public int SecondsPrecision { get; set; } + public override bool HasValue { get; } = true; + public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; + public virtual string ToString(IFormatProvider formatProvider) => string.Empty; + protected virtual string ToInlineTomlInternal() => string.Empty; + + public override string ToInlineToml() => ToInlineTomlInternal() + .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) + .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); + } + + public class TomlDateTimeOffset : TomlDateTime + { + public override bool IsDateTimeOffset { get; } = true; + public DateTimeOffset Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); + } + + public class TomlDateTimeLocal : TomlDateTime + { + public enum DateTimeStyle + { + Date, + Time, + DateTime + } + + public override bool IsDateTimeLocal { get; } = true; + public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; + public DateTime Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + public override string ToInlineToml() => + Style switch + { + DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), + DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + }; + } + + public class TomlArray : TomlNode + { + private List values; + + public override bool HasValue { get; } = true; + public override bool IsArray { get; } = true; + public bool IsMultiline { get; set; } + public bool IsTableArray { get; set; } + public List RawArray => values ??= new List(); + + public override TomlNode this[int index] + { + get + { + if (index < RawArray.Count) return RawArray[index]; + var lazy = new TomlLazy(this); + this[index] = lazy; + return lazy; + } + set + { + if (index == RawArray.Count) + RawArray.Add(value); + else + RawArray[index] = value; + } + } + + public override int ChildrenCount => RawArray.Count; + + public override IEnumerable Children => RawArray.AsEnumerable(); + + public override void Add(TomlNode node) => RawArray.Add(node); + + public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes); + + public override void Delete(TomlNode node) => RawArray.Remove(node); + + public override void Delete(int index) => RawArray.RemoveAt(index); + + public override string ToString() => ToString(false); + + public string ToString(bool multiline) + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.ARRAY_START_SYMBOL); + if (ChildrenCount != 0) + { + var arrayStart = multiline ? $"{Environment.NewLine} " : " "; + var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; + var arrayEnd = multiline ? Environment.NewLine : " "; + sb.Append(arrayStart) + .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) + .Append(arrayEnd); + } + sb.Append(TomlSyntax.ARRAY_END_SYMBOL); + return sb.ToString(); + } + + public override void WriteTo(TextWriter tw, string name = null) + { + // If it's a normal array, write it as usual + if (!IsTableArray) + { + tw.WriteLine(ToString(IsMultiline)); + return; + } + + if (!(Comment is null)) + { + tw.WriteLine(); + Comment.AsComment(tw); + } + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + + var first = true; + + foreach (var tomlNode in RawArray) + { + if (!(tomlNode is TomlTable tbl)) + throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); + + // Ensure it's parsed as a section + tbl.IsInline = false; + + if (!first) + { + tw.WriteLine(); + + Comment?.AsComment(tw); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + + first = false; + + // Don't write section since it's already written here + tbl.WriteTo(tw, name, false); + } + } + } + + public class TomlTable : TomlNode + { + private Dictionary children; + internal bool isImplicit; + + public override bool HasValue { get; } = false; + public override bool IsTable { get; } = true; + public bool IsInline { get; set; } + public Dictionary RawTable => children ??= new Dictionary(); + + public override TomlNode this[string key] + { + get + { + if (RawTable.TryGetValue(key, out var result)) return result; + var lazy = new TomlLazy(this); + RawTable[key] = lazy; + return lazy; + } + set => RawTable[key] = value; + } + + public override int ChildrenCount => RawTable.Count; + public override IEnumerable Children => RawTable.Select(kv => kv.Value); + public override IEnumerable Keys => RawTable.Select(kv => kv.Key); + public override bool HasKey(string key) => RawTable.ContainsKey(key); + public override void Add(string key, TomlNode node) => RawTable.Add(key, node); + public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); + public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); + public override void Delete(string key) => RawTable.Remove(key); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); + + if (ChildrenCount != 0) + { + var collapsed = CollectCollapsedItems(normalizeOrder: false); + + if (collapsed.Count != 0) + sb.Append(' ') + .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => + $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); + sb.Append(' '); + } + + sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); + return sb.ToString(); + } + + private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) + { + var nodes = new LinkedList>(); + var postNodes = normalizeOrder ? new LinkedList>() : nodes; + + foreach (var keyValuePair in RawTable) + { + var node = keyValuePair.Value; + var key = keyValuePair.Key.AsKey(); + + if (node is TomlTable tbl) + { + var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); + // Write main table first before writing collapsed items + if (subnodes.Count == 0 && node.CollapseLevel == level) + { + postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + foreach (var kv in subnodes) + postNodes.AddLast(kv); + } + else if (node.CollapseLevel == level) + nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + + if (normalizeOrder) + foreach (var kv in postNodes) + nodes.AddLast(kv); + + return nodes; + } + + public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); + + internal void WriteTo(TextWriter tw, string name, bool writeSectionName) + { + // The table is inline table + if (IsInline && name != null) + { + tw.WriteLine(ToInlineToml()); + return; + } + + var collapsedItems = CollectCollapsedItems(); + + if (collapsedItems.Count == 0) + return; + + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); + + Comment?.AsComment(tw); + + if (name != null && (hasRealValues || Comment != null) && writeSectionName) + { + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + else if (Comment != null) // Add some spacing between the first node and the comment + { + tw.WriteLine(); + } + + var namePrefix = name == null ? "" : $"{name}."; + var first = true; + + foreach (var collapsedItem in collapsedItems) + { + var key = collapsedItem.Key; + if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) + { + if (!first) tw.WriteLine(); + first = false; + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + continue; + } + first = false; + + collapsedItem.Value.Comment?.AsComment(tw); + tw.Write(key); + tw.Write(' '); + tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); + tw.Write(' '); + + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + } + } + } + + internal class TomlLazy : TomlNode + { + private readonly TomlNode parent; + private TomlNode replacement; + + public TomlLazy(TomlNode parent) => this.parent = parent; + + public override TomlNode this[int index] + { + get => Set()[index]; + set => Set()[index] = value; + } + + public override TomlNode this[string key] + { + get => Set()[key]; + set => Set()[key] = value; + } + + public override void Add(TomlNode node) => Set().Add(node); + + public override void Add(string key, TomlNode node) => Set().Add(key, node); + + public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes); + + private TomlNode Set() where T : TomlNode, new() + { + if (replacement != null) return replacement; + + var newNode = new T + { + Comment = Comment + }; + + if (parent.IsTable) + { + var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); + if (key == null) return default(T); + + parent[key] = newNode; + } + else if (parent.IsArray) + { + var index = parent.Children.TakeWhile(child => child != this).Count(); + if (index == parent.ChildrenCount) return default(T); + parent[index] = newNode; + } + else + { + return default(T); + } + + replacement = newNode; + return newNode; + } + } + + #endregion + + #region Parser + + public class TOMLParser : IDisposable + { + public enum ParseState + { + None, + KeyValuePair, + SkipToNextLine, + Table + } + + private readonly TextReader reader; + private ParseState currentState; + private int line, col; + private List syntaxErrors; + + public TOMLParser(TextReader reader) + { + this.reader = reader; + line = col = 0; + } + + public bool ForceASCII { get; set; } + + public void Dispose() => reader?.Dispose(); + + public TomlTable Parse() + { + syntaxErrors = new List(); + line = col = 1; + var rootNode = new TomlTable(); + var currentNode = rootNode; + currentState = ParseState.None; + var keyParts = new List(); + var arrayTable = false; + StringBuilder latestComment = null; + var firstComment = true; + + int currentChar; + while ((currentChar = reader.Peek()) >= 0) + { + var c = (char)currentChar; + + if (currentState == ParseState.None) + { + // Skip white space + if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; + + if (TomlSyntax.IsNewLine(c)) + { + // Check if there are any comments and so far no items being declared + if (latestComment != null && firstComment) + { + rootNode.Comment = latestComment.ToString().TrimEnd(); + latestComment = null; + firstComment = false; + } + + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + + goto consume_character; + } + + // Start of a comment; ignore until newline + if (c == TomlSyntax.COMMENT_SYMBOL) + { + latestComment ??= new StringBuilder(); + latestComment.AppendLine(ParseComment()); + AdvanceLine(1); + continue; + } + + // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! + firstComment = false; + + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + currentState = ParseState.Table; + goto consume_character; + } + + if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) + { + currentState = ParseState.KeyValuePair; + } + else + { + AddError($"Unexpected character \"{c}\""); + continue; + } + } + + if (currentState == ParseState.KeyValuePair) + { + var keyValuePair = ReadKeyValuePair(keyParts); + + if (keyValuePair == null) + { + latestComment = null; + keyParts.Clear(); + + if (currentState != ParseState.None) + AddError("Failed to parse key-value pair!"); + continue; + } + + keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); + var inserted = InsertNode(keyValuePair, currentNode, keyParts); + latestComment = null; + keyParts.Clear(); + if (inserted) + currentState = ParseState.SkipToNextLine; + continue; + } + + if (currentState == ParseState.Table) + { + if (keyParts.Count == 0) + { + // We have array table + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + // Consume the character + ConsumeChar(); + arrayTable = true; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) + { + keyParts.Clear(); + continue; + } + + if (keyParts.Count == 0) + { + AddError("Table name is emtpy."); + arrayTable = false; + latestComment = null; + keyParts.Clear(); + } + + continue; + } + + if (c == TomlSyntax.TABLE_END_SYMBOL) + { + if (arrayTable) + { + // Consume the ending bracket so we can peek the next character + ConsumeChar(); + var nextChar = reader.Peek(); + if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) + { + AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + continue; + } + } + + currentNode = CreateTable(rootNode, keyParts, arrayTable); + if (currentNode != null) + { + currentNode.IsInline = false; + currentNode.Comment = latestComment?.ToString()?.TrimEnd(); + } + + keyParts.Clear(); + arrayTable = false; + latestComment = null; + + if (currentNode == null) + { + if (currentState != ParseState.None) + AddError("Error creating table array!"); + // Reset a node to root in order to try and continue parsing + currentNode = rootNode; + continue; + } + + currentState = ParseState.SkipToNextLine; + goto consume_character; + } + + if (keyParts.Count != 0) + { + AddError($"Unexpected character \"{c}\""); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + } + } + + if (currentState == ParseState.SkipToNextLine) + { + if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) + goto consume_character; + + if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) + { + currentState = ParseState.None; + AdvanceLine(); + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + col++; + ParseComment(); + continue; + } + + goto consume_character; + } + + AddError($"Unexpected character \"{c}\" at the end of the line."); + } + + consume_character: + reader.Read(); + col++; + } + + if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) + AddError("Unexpected end of file!"); + + if (syntaxErrors.Count > 0) + throw new TomlParseException(rootNode, syntaxErrors); + + return rootNode; + } + + private bool AddError(string message, bool skipLine = true) + { + syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); + // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) + if (skipLine) + { + reader.ReadLine(); + AdvanceLine(1); + } + currentState = ParseState.None; + return false; + } + + private void AdvanceLine(int startCol = 0) + { + line++; + col = startCol; + } + + private int ConsumeChar() + { + col++; + return reader.Read(); + } + + #region Key-Value pair parsing + + /** + * Reads a single key-value pair. + * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). + * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). + * + * Example: + * foo = "bar" ==> foo = "bar" + * ^ ^ + */ + private TomlNode ReadKeyValuePair(List keyParts) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) + { + if (keyParts.Count != 0) + { + AddError("Encountered extra characters in key definition!"); + return null; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) + return null; + + continue; + } + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.KEY_VALUE_SEPARATOR) + { + ConsumeChar(); + return ReadValue(); + } + + AddError($"Unexpected character \"{c}\" in key name."); + return null; + } + + return null; + } + + /** + * Reads a single value. + * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). + * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private TomlNode ReadValue(bool skipNewlines = false) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("No value found!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + if (skipNewlines) + { + reader.Read(); + AdvanceLine(1); + continue; + } + + AddError("Encountered a newline when expecting a value!"); + return null; + } + + if (TomlSyntax.IsQuoted(c)) + { + var isMultiline = IsTripleQuote(c, out var excess); + + // Error occurred in triple quote parsing + if (currentState == ParseState.None) + return null; + + var value = isMultiline + ? ReadQuotedValueMultiLine(c) + : ReadQuotedValueSingleLine(c, excess); + + if (value is null) + return null; + + return new TomlString + { + Value = value, + IsMultiline = isMultiline, + PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL + }; + } + + return c switch + { + TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() + }; + } + + return null; + } + + /** + * Reads a single key name. + * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). + * Consumes all the characters until the `until` character is met (but does not consume the character itself). + * + * Example 1: + * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) + * ^ ^ + * + * Example 2: + * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) + * ^ ^ + */ + private bool ReadKeyName(ref List parts, char until) + { + var buffer = new StringBuilder(); + var quoted = false; + var prevWasSpace = false; + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + // Reached the final character + if (c == until) break; + + if (TomlSyntax.IsWhiteSpace(c)) + { + prevWasSpace = true; + goto consume_character; + } + + if (buffer.Length == 0) prevWasSpace = false; + + if (c == TomlSyntax.SUBKEY_SEPARATOR) + { + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + buffer.Length = 0; + quoted = false; + prevWasSpace = false; + goto consume_character; + } + + if (prevWasSpace) + return AddError("Invalid spacing in key name"); + + if (TomlSyntax.IsQuoted(c)) + { + if (quoted) + + return AddError("Expected a subkey separator but got extra data instead!"); + + if (buffer.Length != 0) + return AddError("Encountered a quote in the middle of subkey name!"); + + // Consume the quote character and read the key name + col++; + buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); + quoted = true; + continue; + } + + if (TomlSyntax.IsBareKey(c)) + { + buffer.Append(c); + goto consume_character; + } + + // If we see an invalid symbol, let the next parser handle it + break; + + consume_character: + reader.Read(); + col++; + } + + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + + return true; + } + + #endregion + + #region Non-string value parsing + + /** + * Reads the whole raw value until the first non-value character is encountered. + * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. + * Example: + * + * 1_0_0_0 ==> 1_0_0_0 + * ^ ^ + */ + private string ReadRawValue() + { + var result = new StringBuilder(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; + result.Append(c); + ConsumeChar(); + } + + // Replace trim with manual space counting? + return result.ToString().Trim(); + } + + /** + * Reads and parses a non-string, non-composite TOML value. + * Assumes the cursor at the first character that is related to the value (with possible spaces). + * Consumes all the characters that are related to the value. + * + * Example + * 1_0_0_0 # This is a comment + * + * ==> 1_0_0_0 # This is a comment + * ^ ^ + */ + private TomlNode ReadTomlValue() + { + var value = ReadRawValue(); + TomlNode node = value switch + { + var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), + var v when TomlSyntax.IsNaN(v) => double.NaN, + var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, + var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, + var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger + { + Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), + IntegerBase = (TomlInteger.Base)numberBase + }, + var _ => null + }; + if (node != null) return node; + + // Normalize by removing space separator + value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalDateTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out var dateTimeResult, + out var precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + SecondsPrecision = precision + }; + + if (DateTime.TryParseExact(value, + TomlSyntax.LocalDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dateTimeResult)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Date + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out dateTimeResult, + out precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Time, + SecondsPrecision = precision + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339Formats, + DateTimeStyles.None, + DateTimeOffset.TryParseExact, + out var dateTimeOffsetResult, + out precision)) + return new TomlDateTimeOffset + { + Value = dateTimeOffsetResult, + SecondsPrecision = precision + }; + + AddError($"Value \"{value}\" is not a valid TOML value!"); + return null; + } + + /** + * Reads an array value. + * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. + * + * Example: + * [1, 2, 3] ==> [1, 2, 3] + * ^ ^ + */ + private TomlArray ReadArray() + { + // Consume the start of array character + ConsumeChar(); + var result = new TomlArray(); + TomlNode currentValue = null; + var expectValue = true; + + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (c == TomlSyntax.ARRAY_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + reader.ReadLine(); + AdvanceLine(1); + continue; + } + + if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + goto consume_character; + } + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators"); + return null; + } + + result.Add(currentValue); + currentValue = null; + expectValue = true; + goto consume_character; + } + + if (!expectValue) + { + AddError("Missing separator between values"); + return null; + } + currentValue = ReadValue(true); + if (currentValue == null) + { + if (currentState != ParseState.None) + AddError("Failed to determine and parse a value!"); + return null; + } + expectValue = false; + + continue; + consume_character: + ConsumeChar(); + } + + if (currentValue != null) result.Add(currentValue); + return result; + } + + /** + * Reads an inline table. + * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. + * + * Example: + * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } + * ^ ^ + */ + private TomlNode ReadInlineTable() + { + ConsumeChar(); + var result = new TomlTable { IsInline = true }; + TomlNode currentValue = null; + var separator = false; + var keyParts = new List(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("Incomplete inline table definition!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + AddError("Inline tables are only allowed to be on single line"); + return null; + } + + if (TomlSyntax.IsWhiteSpace(c)) + goto consume_character; + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators in inline table!"); + return null; + } + + if (!InsertNode(currentValue, result, keyParts)) + return null; + keyParts.Clear(); + currentValue = null; + separator = true; + goto consume_character; + } + + separator = false; + currentValue = ReadKeyValuePair(keyParts); + continue; + + consume_character: + ConsumeChar(); + } + + if (separator) + { + AddError("Trailing commas are not allowed in inline tables."); + return null; + } + + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) + return null; + + return result; + } + + #endregion + + #region String parsing + + /** + * Checks if the string value a multiline string (i.e. a triple quoted string). + * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. + * + * If the result is false, returns the consumed character through the `excess` variable. + * + * Example 1: + * """test""" ==> """test""" + * ^ ^ + * + * Example 2: + * "test" ==> "test" (doesn't return the first quote) + * ^ ^ + * + * Example 3: + * "" ==> "" (returns the extra `"` through the `excess` variable) + * ^ ^ + */ + private bool IsTripleQuote(char quote, out char excess) + { + // Copypasta, but it's faster... + + int cur; + // Consume the first quote + ConsumeChar(); + if ((cur = reader.Peek()) < 0) + { + excess = '\0'; + return AddError("Unexpected end of file!"); + } + + if ((char)cur != quote) + { + excess = '\0'; + return false; + } + + // Consume the second quote + excess = (char)ConsumeChar(); + if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; + + // Consume the final quote + ConsumeChar(); + excess = '\0'; + return true; + } + + /** + * A convenience method to process a single character within a quote. + */ + private bool ProcessQuotedValueCharacter(char quote, + bool isNonLiteral, + char c, + StringBuilder sb, + ref bool escaped) + { + if (TomlSyntax.MustBeEscaped(c)) + return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); + + if (escaped) + { + sb.Append(c); + escaped = false; + return false; + } + + if (c == quote) + { + if (!isNonLiteral && reader.Peek() == quote) + { + reader.Read(); + col++; + sb.Append(quote); + return false; + } + + return true; + } + if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) + escaped = true; + if (c == TomlSyntax.NEWLINE_CHARACTER) + return AddError("Encountered newline in single line string!"); + + sb.Append(c); + return false; + } + + /** + * Reads a single-line string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string (including the closing quote). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') + { + var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + + if (initialData != '\0') + { + var shouldReturn = + ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); + if (currentState == ParseState.None) return null; + if (shouldReturn) + if (isNonLiteral) + { + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + else + return sb.ToString(); + } + + int cur; + var readDone = false; + while ((cur = reader.Read()) >= 0) + { + // Consume the character + col++; + var c = (char)cur; + readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); + if (readDone) + { + if (currentState == ParseState.None) return null; + break; + } + } + + if (!readDone) + { + AddError("Unclosed string."); + return null; + } + + if (!isNonLiteral) return sb.ToString(); + if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; + AddError(unescapedEx.Message); + return null; + } + + /** + * Reads a multiline string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string and the three closing quotes. + * + * Example: + * """test""" ==> """test""" + * ^ ^ + */ + private string ReadQuotedValueMultiLine(char quote) + { + var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + var skipWhitespace = false; + var skipWhitespaceLineSkipped = false; + var quotesEncountered = 0; + var first = true; + int cur; + while ((cur = ConsumeChar()) >= 0) + { + var c = (char)cur; + if (TomlSyntax.MustBeEscaped(c, true)) + { + AddError($"The character U+{(int)c:X8} must be escaped!"); + return null; + } + // Trim the first newline + if (first && TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + first = false; + else + AdvanceLine(); + continue; + } + + first = false; + //TODO: Reuse ProcessQuotedValueCharacter + // Skip the current character if it is going to be escaped later + if (escaped) + { + sb.Append(c); + escaped = false; + continue; + } + + // If we are currently skipping empty spaces, skip + if (skipWhitespace) + { + if (TomlSyntax.IsEmptySpace(c)) + { + if (TomlSyntax.IsLineBreak(c)) + { + skipWhitespaceLineSkipped = true; + AdvanceLine(); + } + continue; + } + + if (!skipWhitespaceLineSkipped) + { + AddError("Non-whitespace character after trim marker."); + return null; + } + + skipWhitespaceLineSkipped = false; + skipWhitespace = false; + } + + // If we encounter an escape sequence... + if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) + { + var next = reader.Peek(); + var nc = (char)next; + if (next >= 0) + { + // ...and the next char is empty space, we must skip all whitespaces + if (TomlSyntax.IsEmptySpace(nc)) + { + skipWhitespace = true; + continue; + } + + // ...and we have \" or \, skip the character + if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; + } + } + + // Count the consecutive quotes + if (c == quote) + quotesEncountered++; + else + quotesEncountered = 0; + + // If the are three quotes, count them as closing quotes + if (quotesEncountered == 3) break; + + sb.Append(c); + } + + // TOML actually allows to have five ending quotes like + // """"" => "" belong to the string + """ is the actual ending + quotesEncountered = 0; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == quote && ++quotesEncountered < 3) + { + sb.Append(c); + ConsumeChar(); + } + else break; + } + + // Remove last two quotes (third one wasn't included by default) + sb.Length -= 2; + if (!isBasic) return sb.ToString(); + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + + #endregion + + #region Node creation + + private bool InsertNode(TomlNode node, TomlNode root, IList path) + { + var latestNode = root; + if (path.Count > 1) + for (var index = 0; index < path.Count - 1; index++) + { + var subkey = path[index]; + if (latestNode.TryGetNode(subkey, out var currentNode)) + { + if (currentNode.HasValue) + return AddError($"The key {".".Join(path)} already has a value assigned to it!"); + } + else + { + currentNode = new TomlTable(); + latestNode[subkey] = currentNode; + } + + latestNode = currentNode; + if (latestNode is TomlTable { IsInline: true }) + return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); + } + + if (latestNode.HasKey(path[path.Count - 1])) + return AddError($"The key {".".Join(path)} is already defined!"); + latestNode[path[path.Count - 1]] = node; + node.CollapseLevel = path.Count - 1; + return true; + } + + private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable) + { + if (path.Count == 0) return null; + var latestNode = root; + for (var index = 0; index < path.Count; index++) + { + var subkey = path[index]; + + if (latestNode.TryGetNode(subkey, out var node)) + { + if (node.IsArray && arrayTable) + { + var arr = (TomlArray)node; + + if (!arr.IsTableArray) + { + AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (index == path.Count - 1) + { + latestNode = new TomlTable(); + arr.Add(latestNode); + break; + } + + latestNode = arr[arr.ChildrenCount - 1]; + continue; + } + + if (node is TomlTable { IsInline: true }) + { + AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); + return null; + } + + if (node.HasValue) + { + if (!(node is TomlArray { IsTableArray: true } array)) + { + AddError($"The key {".".Join(path)} has a value assigned to it!"); + return null; + } + + latestNode = array[array.ChildrenCount - 1]; + continue; + } + + if (index == path.Count - 1) + { + if (arrayTable && !node.IsArray) + { + AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (node is TomlTable { isImplicit: false }) + { + AddError($"The table {".".Join(path)} is defined multiple times!"); + return null; + } + } + } + else + { + if (index == path.Count - 1 && arrayTable) + { + var table = new TomlTable(); + var arr = new TomlArray + { + IsTableArray = true + }; + arr.Add(table); + latestNode[subkey] = arr; + latestNode = table; + break; + } + + node = new TomlTable { isImplicit = true }; + latestNode[subkey] = node; + } + + latestNode = node; + } + + var result = (TomlTable)latestNode; + result.isImplicit = false; + return result; + } + + #endregion + + #region Misc parsing + + private string ParseComment() + { + ConsumeChar(); + var commentLine = reader.ReadLine()?.Trim() ?? ""; + if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) + AddError("Comment must not contain control characters other than tab.", false); + return commentLine; + } + #endregion + } + + #endregion + + public static class TOML + { + public static bool ForceASCII { get; set; } = false; + + public static TomlTable Parse(TextReader reader) + { + using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; + return parser.Parse(); + } + } + + #region Exception Types + + public class TomlFormatException : Exception + { + public TomlFormatException(string message) : base(message) { } + } + + public class TomlParseException : Exception + { + public TomlParseException(TomlTable parsed, IEnumerable exceptions) : + base("TOML file contains format errors") + { + ParsedTable = parsed; + SyntaxErrors = exceptions; + } + + public TomlTable ParsedTable { get; } + + public IEnumerable SyntaxErrors { get; } + } + + public class TomlSyntaxException : Exception + { + public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) + { + ParseState = state; + Line = line; + Column = col; + } + + public TOMLParser.ParseState ParseState { get; } + + public int Line { get; } + + public int Column { get; } + } + + #endregion + + #region Parse utilities + + internal static class TomlSyntax + { + #region Type Patterns + + public const string TRUE_VALUE = "true"; + public const string FALSE_VALUE = "false"; + public const string NAN_VALUE = "nan"; + public const string POS_NAN_VALUE = "+nan"; + public const string NEG_NAN_VALUE = "-nan"; + public const string INF_VALUE = "inf"; + public const string POS_INF_VALUE = "+inf"; + public const string NEG_INF_VALUE = "-inf"; + + public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; + + public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; + + public static bool IsNegInf(string s) => s == NEG_INF_VALUE; + + public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; + + public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); + + public static bool IsFloat(string s) => FloatPattern.IsMatch(s); + + public static bool IsIntegerWithBase(string s, out int numberBase) + { + numberBase = 10; + var match = BasedIntegerPattern.Match(s); + if (!match.Success) return false; + IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); + return true; + } + + /** + * A pattern to verify the integer value according to the TOML specification. + */ + public static readonly Regex IntegerPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); + + /** + * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. + */ + public static readonly Regex BasedIntegerPattern = + new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A pattern to verify the float value according to the TOML specification. + */ + public static readonly Regex FloatPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A helper dictionary to map TOML base codes into the radii. + */ + public static readonly Dictionary IntegerBases = new() + { + ["x"] = 16, + ["o"] = 8, + ["b"] = 2 + }; + + /** + * A helper dictionary to map non-decimal bases to their TOML identifiers + */ + public static readonly Dictionary BaseIdentifiers = new() + { + [2] = "b", + [8] = "o", + [16] = "x" + }; + + public const string RFC3339EmptySeparator = " "; + public const string ISO861Separator = "T"; + public const string ISO861ZeroZone = "+00:00"; + public const string RFC3339ZeroZone = "Z"; + + /** + * Valid date formats with timezone as per RFC3339. + */ + public static readonly string[] RFC3339Formats = + { + "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" + }; + + /** + * Valid date formats without timezone (assumes local) as per RFC3339. + */ + public static readonly string[] RFC3339LocalDateTimeFormats = + { + "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" + }; + + /** + * Valid full date format as per TOML spec. + */ + public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; + + /** + * Valid time formats as per TOML spec. + */ + public static readonly string[] RFC3339LocalTimeFormats = + { + "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", + "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" + }; + + #endregion + + #region Character definitions + + public const char ARRAY_END_SYMBOL = ']'; + public const char ITEM_SEPARATOR = ','; + public const char ARRAY_START_SYMBOL = '['; + public const char BASIC_STRING_SYMBOL = '\"'; + public const char COMMENT_SYMBOL = '#'; + public const char ESCAPE_SYMBOL = '\\'; + public const char KEY_VALUE_SEPARATOR = '='; + public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; + public const char NEWLINE_CHARACTER = '\n'; + public const char SUBKEY_SEPARATOR = '.'; + public const char TABLE_END_SYMBOL = ']'; + public const char TABLE_START_SYMBOL = '['; + public const char INLINE_TABLE_START_SYMBOL = '{'; + public const char INLINE_TABLE_END_SYMBOL = '}'; + public const char LITERAL_STRING_SYMBOL = '\''; + public const char INT_NUMBER_SEPARATOR = '_'; + + public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; + + public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; + + public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; + + public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; + + public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; + + public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); + + public static bool IsBareKey(char c) => + c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; + + public static bool MustBeEscaped(char c, bool allowNewLines = false) + { + var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; + if (!allowNewLines) + result |= c is >= '\u000a' and <= '\u000e'; + return result; + } + + public static bool IsValueSeparator(char c) => + c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; + + #endregion + } + + internal static class StringUtils + { + public static string AsKey(this string key) + { + var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); + return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; + } + + public static string Join(this string self, IEnumerable subItems) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var subItem in subItems) + { + if (!first) sb.Append(self); + first = false; + sb.Append(subItem); + } + + return sb.ToString(); + } + + public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); + + public static bool TryParseDateTime(string s, + string[] formats, + DateTimeStyles styles, + TryDateParseDelegate parser, + out T dateTime, + out int parsedFormat) + { + parsedFormat = 0; + dateTime = default; + for (var i = 0; i < formats.Length; i++) + { + var format = formats[i]; + if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; + parsedFormat = i; + return true; + } + + return false; + } + + public static void AsComment(this string self, TextWriter tw) + { + foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) + tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); + } + + public static string RemoveAll(this string txt, char toRemove) + { + var sb = new StringBuilder(txt.Length); + foreach (var c in txt.Where(c => c != toRemove)) + sb.Append(c); + return sb.ToString(); + } + + public static string Escape(this string txt, bool escapeNewlines = true) + { + var stringBuilder = new StringBuilder(txt.Length + 2); + for (var i = 0; i < txt.Length; i++) + { + var c = txt[i]; + + static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) + ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" + : $"\\u{(ushort)c:X4}"; + + stringBuilder.Append(c switch + { + '\b' => @"\b", + '\t' => @"\t", + '\n' when escapeNewlines => @"\n", + '\f' => @"\f", + '\r' when escapeNewlines => @"\r", + '\\' => @"\\", + '\"' => @"\""", + var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => + CodePoint(txt, ref i, c), + var _ => c + }); + } + + return stringBuilder.ToString(); + } + + public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) + { + try + { + exception = null; + unescaped = txt.Unescape(); + return true; + } + catch (Exception e) + { + exception = e; + unescaped = null; + return false; + } + } + + public static string Unescape(this string txt) + { + if (string.IsNullOrEmpty(txt)) return txt; + var stringBuilder = new StringBuilder(txt.Length); + for (var i = 0; i < txt.Length;) + { + var num = txt.IndexOf('\\', i); + var next = num + 1; + if (num < 0 || num == txt.Length - 1) num = txt.Length; + stringBuilder.Append(txt, i, num - i); + if (num >= txt.Length) break; + var c = txt[next]; + + static string CodePoint(int next, string txt, ref int num, int size) + { + if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); + num += size; + return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); + } + + stringBuilder.Append(c switch + { + 'b' => "\b", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + '\'' => "\'", + '\"' => "\"", + '\\' => "\\", + 'u' => CodePoint(next, txt, ref num, 4), + 'U' => CodePoint(next, txt, ref num, 8), + var _ => throw new Exception("Undefined escape sequence!") + }); + i = num + 2; + } + + return stringBuilder.ToString(); + } + } + + #endregion +} diff --git a/Assets/MCPForUnity/Editor/External/Tommy.cs.meta b/Assets/MCPForUnity/Editor/External/Tommy.cs.meta new file mode 100644 index 0000000..59e9ece --- /dev/null +++ b/Assets/MCPForUnity/Editor/External/Tommy.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ea652131dcdaa44ca8cb35cd1191be3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/External/Tommy.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers.meta b/Assets/MCPForUnity/Editor/Helpers.meta new file mode 100644 index 0000000..c57a342 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 94cb070dc5e15024da86150b27699ca0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs new file mode 100644 index 0000000..69c017e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -0,0 +1,430 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity asset paths. + /// + public static class AssetPathUtility + { + /// + /// Normalizes path separators to forward slashes without modifying the path structure. + /// Use this for non-asset paths (e.g., file system paths, relative directories). + /// + public static string NormalizeSeparators(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + return path.Replace('\\', '/'); + } + + /// + /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// Also protects against path traversal attacks using "../" sequences. + /// + public static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + path = NormalizeSeparators(path); + + // Check for path traversal sequences + if (path.Contains("..")) + { + McpLog.Warn($"[AssetPathUtility] Path contains potential traversal sequence: '{path}'"); + return null; + } + + // Ensure path starts with Assets/ + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + + return path; + } + + /// + /// Checks if a given asset path is valid and safe (no traversal, within Assets folder). + /// + /// True if the path is valid, false otherwise. + public static bool IsValidAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + // Normalize for comparison + string normalized = NormalizeSeparators(path); + + // Must start with Assets/ + if (!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Must not contain traversal sequences + if (normalized.Contains("..")) + { + return false; + } + + // Must not contain invalid path characters + char[] invalidChars = { ':', '*', '?', '"', '<', '>', '|' }; + foreach (char c in invalidChars) + { + if (normalized.IndexOf(c) >= 0) + { + return false; + } + } + + return true; + } + + /// + /// Gets the MCP for Unity package root path. + /// Works for registry Package Manager, local Package Manager, and Asset Store installations. + /// + /// The package root path (virtual for PM, absolute for Asset Store), or null if not found + public static string GetMcpPackageRootPath() + { + try + { + // Try Package Manager first (registry and local installs) + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) + { + return packageInfo.assetPath; + } + + // Fallback to AssetDatabase for Asset Store installs (Assets/MCPForUnity) + string[] guids = AssetDatabase.FindAssets($"t:Script {nameof(AssetPathUtility)}"); + + if (guids.Length == 0) + { + McpLog.Warn("Could not find AssetPathUtility script in AssetDatabase"); + return null; + } + + string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]); + + // Script is at: {packageRoot}/Editor/Helpers/AssetPathUtility.cs + // Extract {packageRoot} + int editorIndex = scriptPath.IndexOf("/Editor/", StringComparison.Ordinal); + + if (editorIndex >= 0) + { + return scriptPath.Substring(0, editorIndex); + } + + McpLog.Warn($"Could not determine package root from script path: {scriptPath}"); + return null; + } + catch (Exception ex) + { + McpLog.Error($"Failed to get package root path: {ex.Message}"); + return null; + } + } + + /// + /// Reads and parses the package.json file for MCP for Unity. + /// Handles both Package Manager (registry/local) and Asset Store installations. + /// + /// JObject containing package.json data, or null if not found or parse failed + public static JObject GetPackageJson() + { + try + { + string packageRoot = GetMcpPackageRootPath(); + if (string.IsNullOrEmpty(packageRoot)) + { + return null; + } + + string packageJsonPath = Path.Combine(packageRoot, "package.json"); + + // Convert virtual asset path to file system path + if (packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + // Package Manager install - must use PackageInfo.resolvedPath + // Virtual paths like "Packages/..." don't work with File.Exists() + // Registry packages live in Library/PackageCache/package@version/ + var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) + { + packageJsonPath = Path.Combine(packageInfo.resolvedPath, "package.json"); + } + else + { + McpLog.Warn("Could not resolve Package Manager path for package.json"); + return null; + } + } + else if (packageRoot.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + // Asset Store install - convert to absolute file system path + // Application.dataPath is the absolute path to the Assets folder + string relativePath = packageRoot.Substring("Assets/".Length); + packageJsonPath = Path.Combine(Application.dataPath, relativePath, "package.json"); + } + + if (!File.Exists(packageJsonPath)) + { + McpLog.Warn($"package.json not found at: {packageJsonPath}"); + return null; + } + + string json = File.ReadAllText(packageJsonPath); + return JObject.Parse(json); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read or parse package.json: {ex.Message}"); + return null; + } + } + + /// + /// Gets the package source for the MCP server (used with uvx --from). + /// Checks for EditorPrefs override first (supports git URLs, file:// paths, etc.), + /// then falls back to PyPI package reference. + /// + /// Package source string for uvx --from argument + public static string GetMcpServerPackageSource() + { + // Check for override first (supports git URLs, file:// paths, local paths) + string sourceOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(sourceOverride)) + { + return sourceOverride; + } + + // Default to PyPI package (avoids Windows long path issues with git clone) + string version = GetPackageVersion(); + if (version == "unknown") + { + // Fall back to latest PyPI version so configs remain valid in test scenarios + return "mcpforunityserver"; + } + + return $"mcpforunityserver=={version}"; + } + + /// + /// Deprecated: Use GetMcpServerPackageSource() instead. + /// Kept for backwards compatibility. + /// + [System.Obsolete("Use GetMcpServerPackageSource() instead")] + public static string GetMcpServerGitUrl() => GetMcpServerPackageSource(); + + /// + /// Gets structured uvx command parts for different client configurations + /// + /// Tuple containing (uvxPath, fromUrl, packageName) + public static (string uvxPath, string fromUrl, string packageName) GetUvxCommandParts() + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string fromUrl = GetMcpServerPackageSource(); + string packageName = "mcp-for-unity"; + + return (uvxPath, fromUrl, packageName); + } + + /// + /// Builds the uvx package source arguments for the MCP server. + /// Handles beta server mode (prerelease from PyPI) vs standard mode (pinned version or override). + /// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports. + /// Priority: explicit fromUrl override > beta server mode > default package. + /// + /// Whether to quote the --from path (needed for command-line strings, not for arg lists) + /// The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0") + public static string GetBetaServerFromArgs(bool quoteFromPath = false) + { + // Explicit override (local path, git URL, etc.) always wins + string fromUrl = GetMcpServerPackageSource(); + string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(overrideUrl)) + { + return $"--from {fromUrl}"; + } + + // Beta server mode: use prerelease from PyPI + bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + if (useBetaServer) + { + // Use --prerelease explicit with version specifier to only get prereleases of our package, + // not of dependencies (which can be broken on PyPI). + string fromValue = quoteFromPath ? "\"mcpforunityserver>=0.0.0a0\"" : "mcpforunityserver>=0.0.0a0"; + return $"--prerelease explicit --from {fromValue}"; + } + + // Standard mode: use pinned version from package.json + if (!string.IsNullOrEmpty(fromUrl)) + { + return $"--from {fromUrl}"; + } + + return string.Empty; + } + + /// + /// Builds the uvx package source arguments as a list (for JSON config builders). + /// Priority: explicit fromUrl override > beta server mode > default package. + /// + /// List of arguments to add to uvx command + public static System.Collections.Generic.IList GetBetaServerFromArgsList() + { + var args = new System.Collections.Generic.List(); + + // Explicit override (local path, git URL, etc.) always wins + string fromUrl = GetMcpServerPackageSource(); + string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, ""); + if (!string.IsNullOrEmpty(overrideUrl)) + { + args.Add("--from"); + args.Add(fromUrl); + return args; + } + + // Beta server mode: use prerelease from PyPI + bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + if (useBetaServer) + { + args.Add("--prerelease"); + args.Add("explicit"); + args.Add("--from"); + args.Add("mcpforunityserver>=0.0.0a0"); + return args; + } + + // Standard mode: use pinned version from package.json + if (!string.IsNullOrEmpty(fromUrl)) + { + args.Add("--from"); + args.Add(fromUrl); + } + + return args; + } + + /// + /// Determines whether uvx should use --no-cache --refresh flags. + /// Returns true if DevModeForceServerRefresh is enabled OR if the server URL is a local path. + /// Local paths (file:// or absolute) always need fresh builds to avoid stale uvx cache. + /// Note: --reinstall is not supported by uvx and will cause a warning. + /// + public static bool ShouldForceUvxRefresh() + { + bool devForceRefresh = false; + try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { } + + if (devForceRefresh) + return true; + + // Auto-enable force refresh when using a local path override. + return IsLocalServerPath(); + } + + /// + /// Returns true if the server URL is a local path (file:// or absolute path). + /// + public static bool IsLocalServerPath() + { + string fromUrl = GetMcpServerPackageSource(); + if (string.IsNullOrEmpty(fromUrl)) + return false; + + // Check for file:// protocol or absolute local path + return fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase) || + System.IO.Path.IsPathRooted(fromUrl); + } + + /// + /// Gets the local server path if GitUrlOverride points to a local directory. + /// Returns null if not using a local path. + /// + public static string GetLocalServerPath() + { + if (!IsLocalServerPath()) + return null; + + string fromUrl = GetMcpServerPackageSource(); + if (fromUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + // Strip file:// prefix + fromUrl = fromUrl.Substring(7); + } + + return fromUrl; + } + + /// + /// Cleans stale Python build artifacts from the local server path. + /// This is necessary because Python's build system doesn't remove deleted files from build/, + /// and the auto-discovery mechanism will pick up old .py files causing ghost resources/tools. + /// + /// True if cleaning was performed, false if not applicable or failed. + public static bool CleanLocalServerBuildArtifacts() + { + string localPath = GetLocalServerPath(); + if (string.IsNullOrEmpty(localPath)) + return false; + + // Clean the build/ directory which can contain stale .py files + string buildPath = System.IO.Path.Combine(localPath, "build"); + if (System.IO.Directory.Exists(buildPath)) + { + try + { + System.IO.Directory.Delete(buildPath, recursive: true); + McpLog.Info($"Cleaned stale build artifacts from: {buildPath}"); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"Failed to clean build artifacts: {ex.Message}"); + return false; + } + } + + return false; + } + + /// + /// Gets the package version from package.json + /// + /// Version string, or "unknown" if not found + public static string GetPackageVersion() + { + try + { + var packageJson = GetPackageJson(); + if (packageJson == null) + { + return "unknown"; + } + + string version = packageJson["version"]?.ToString(); + return string.IsNullOrEmpty(version) ? "unknown" : version; + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get package version: {ex.Message}"); + return "unknown"; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta b/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta new file mode 100644 index 0000000..bce8a7f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/AssetPathUtility.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs new file mode 100644 index 0000000..a68d47e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using MCPForUnity.External.Tommy; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Codex CLI specific configuration helpers. Handles TOML snippet + /// generation and lightweight parsing so Codex can join the auto-setup + /// flow alongside JSON-based clients. + /// + public static class CodexConfigHelper + { + private static void AddDevModeArgs(TomlArray args) + { + if (args == null) return; + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead + if (!AssetPathUtility.ShouldForceUvxRefresh()) return; + args.Add(new TomlString { Value = "--no-cache" }); + args.Add(new TomlString { Value = "--refresh" }); + } + + public static string BuildCodexServerBlock(string uvPath) + { + var table = new TomlTable(); + var mcpServers = new TomlTable(); + var unityMCP = new TomlTable(); + + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + + // Enable Codex's Rust MCP client for HTTP/SSE transport + EnsureRmcpClientFeature(table); + } + else + { + // Stdio mode: Use command and args + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unityMCP["command"] = uvxPath; + + var args = new TomlArray(); + AddDevModeArgs(args); + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(new TomlString { Value = arg }); + } + args.Add(new TomlString { Value = packageName }); + args.Add(new TomlString { Value = "--transport" }); + args.Add(new TomlString { Value = "stdio" }); + + unityMCP["args"] = args; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } + + // Allow extra time for uvx to download packages on first run + unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 }; + } + + mcpServers["unityMCP"] = unityMCP; + table["mcp_servers"] = mcpServers; + + using var writer = new StringWriter(); + table.WriteTo(writer); + return writer.ToString(); + } + + public static string UpsertCodexServerBlock(string existingToml, string uvPath) + { + // Parse existing TOML or create new root table + var root = TryParseToml(existingToml) ?? new TomlTable(); + + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + // Ensure mcp_servers table exists + if (!root.TryGetNode("mcp_servers", out var mcpServersNode) || !(mcpServersNode is TomlTable)) + { + root["mcp_servers"] = new TomlTable(); + } + var mcpServers = root["mcp_servers"] as TomlTable; + + // Create or update unityMCP table + mcpServers["unityMCP"] = CreateUnityMcpTable(uvPath); + + if (useHttpTransport) + { + EnsureRmcpClientFeature(root); + } + + // Serialize back to TOML + using var writer = new StringWriter(); + root.WriteTo(writer); + return writer.ToString(); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + return TryParseCodexServer(toml, out command, out args, out _); + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args, out string url) + { + command = null; + args = null; + url = null; + + var root = TryParseToml(toml); + if (root == null) return false; + + if (!TryGetTable(root, "mcp_servers", out var servers) + && !TryGetTable(root, "mcpServers", out servers)) + { + return false; + } + + if (!TryGetTable(servers, "unityMCP", out var unity)) + { + return false; + } + + // Check for HTTP mode (url field) + url = GetTomlString(unity, "url"); + if (!string.IsNullOrEmpty(url)) + { + // HTTP mode detected - return true with url + return true; + } + + // Check for stdio mode (command + args) + command = GetTomlString(unity, "command"); + args = GetTomlStringArray(unity, "args"); + + return !string.IsNullOrEmpty(command) && args != null; + } + + /// + /// Safely parses TOML string, returning null on failure + /// + private static TomlTable TryParseToml(string toml) + { + if (string.IsNullOrWhiteSpace(toml)) return null; + + try + { + using var reader = new StringReader(toml); + return TOML.Parse(reader); + } + catch (TomlParseException) + { + return null; + } + catch (TomlSyntaxException) + { + return null; + } + catch (FormatException) + { + return null; + } + } + + /// + /// Creates a TomlTable for the unityMCP server configuration + /// + /// Path to uv executable (used as fallback if uvx is not available) + private static TomlTable CreateUnityMcpTable(string uvPath) + { + var unityMCP = new TomlTable(); + + // Check transport preference + bool useHttpTransport = EditorPrefs.GetBool(MCPForUnity.Editor.Constants.EditorPrefKeys.UseHttpTransport, true); + + if (useHttpTransport) + { + // HTTP mode: Use url field + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unityMCP["url"] = new TomlString { Value = httpUrl }; + } + else + { + // Stdio mode: Use command and args + var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts(); + + unityMCP["command"] = new TomlString { Value = uvxPath }; + + var argsArray = new TomlArray(); + AddDevModeArgs(argsArray); + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + argsArray.Add(new TomlString { Value = arg }); + } + argsArray.Add(new TomlString { Value = packageName }); + argsArray.Add(new TomlString { Value = "--transport" }); + argsArray.Add(new TomlString { Value = "stdio" }); + unityMCP["args"] = argsArray; + + // Add Windows-specific environment configuration for stdio mode + var platformService = MCPServiceLocator.Platform; + if (platformService.IsWindows()) + { + var envTable = new TomlTable { IsInline = true }; + envTable["SystemRoot"] = new TomlString { Value = platformService.GetSystemRoot() }; + unityMCP["env"] = envTable; + } + + // Allow extra time for uvx to download packages on first run + unityMCP["startup_timeout_sec"] = new TomlInteger { Value = 60 }; + } + + return unityMCP; + } + + /// + /// Ensures the features table contains the rmcp_client flag for HTTP/SSE transport. + /// + private static void EnsureRmcpClientFeature(TomlTable root) + { + if (root == null) return; + + if (!root.TryGetNode("features", out var featuresNode) || featuresNode is not TomlTable features) + { + features = new TomlTable(); + root["features"] = features; + } + + features["rmcp_client"] = new TomlBoolean { Value = true }; + } + + private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) + { + table = null; + if (parent == null) return false; + + if (parent.TryGetNode(key, out var node)) + { + if (node is TomlTable tbl) + { + table = tbl; + return true; + } + + if (node is TomlArray array) + { + var firstTable = array.Children.OfType().FirstOrDefault(); + if (firstTable != null) + { + table = firstTable; + return true; + } + } + } + + return false; + } + + private static string GetTomlString(TomlTable table, string key) + { + if (table != null && table.TryGetNode(key, out var node)) + { + if (node is TomlString str) return str.Value; + if (node.HasValue) return node.ToString(); + } + return null; + } + + private static string[] GetTomlStringArray(TomlTable table, string key) + { + if (table == null) return null; + if (!table.TryGetNode(key, out var node)) return null; + + if (node is TomlArray array) + { + List values = new List(); + foreach (TomlNode element in array.Children) + { + if (element is TomlString str) + { + values.Add(str.Value); + } + else if (element.HasValue) + { + values.Add(element.ToString()); + } + } + + return values.Count > 0 ? values.ToArray() : Array.Empty(); + } + + if (node is TomlString single) + { + return new[] { single.Value }; + } + + return null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta b/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta new file mode 100644 index 0000000..5409554 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b3e68082ffc0b4cd39d3747673a4cc22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs b/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs new file mode 100644 index 0000000..90f1e7a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Low-level component operations extracted from ManageGameObject and ManageComponents. + /// Provides pure C# operations without JSON parsing or response formatting. + /// + public static class ComponentOps + { + /// + /// Adds a component to a GameObject with Undo support. + /// + /// The target GameObject + /// The type of component to add + /// Error message if operation fails + /// The added component, or null if failed + public static Component AddComponent(GameObject target, Type componentType, out string error) + { + error = null; + + if (target == null) + { + error = "Target GameObject is null."; + return null; + } + + if (componentType == null || !typeof(Component).IsAssignableFrom(componentType)) + { + error = $"Type '{componentType?.Name ?? "null"}' is not a valid Component type."; + return null; + } + + // Prevent adding duplicate Transform + if (componentType == typeof(Transform)) + { + error = "Cannot add another Transform component."; + return null; + } + + // Check for 2D/3D physics conflicts + string conflictError = CheckPhysicsConflict(target, componentType); + if (conflictError != null) + { + error = conflictError; + return null; + } + + try + { + Component newComponent = Undo.AddComponent(target, componentType); + if (newComponent == null) + { + error = $"Failed to add component '{componentType.Name}' to '{target.name}'. It might be disallowed."; + return null; + } + + // Apply default values for specific component types + ApplyDefaultValues(newComponent); + + return newComponent; + } + catch (Exception ex) + { + error = $"Error adding component '{componentType.Name}': {ex.Message}"; + return null; + } + } + + /// + /// Removes a component from a GameObject with Undo support. + /// + /// The target GameObject + /// The type of component to remove + /// Error message if operation fails + /// True if component was removed successfully + public static bool RemoveComponent(GameObject target, Type componentType, out string error) + { + error = null; + + if (target == null) + { + error = "Target GameObject is null."; + return false; + } + + if (componentType == null) + { + error = "Component type is null."; + return false; + } + + // Prevent removing Transform + if (componentType == typeof(Transform)) + { + error = "Cannot remove Transform component."; + return false; + } + + Component component = target.GetComponent(componentType); + if (component == null) + { + error = $"Component '{componentType.Name}' not found on '{target.name}'."; + return false; + } + + try + { + Undo.DestroyObjectImmediate(component); + return true; + } + catch (Exception ex) + { + error = $"Error removing component '{componentType.Name}': {ex.Message}"; + return false; + } + } + + /// + /// Sets a property value on a component using reflection. + /// + /// The target component + /// The property or field name + /// The value to set (JToken) + /// Error message if operation fails + /// True if property was set successfully + public static bool SetProperty(Component component, string propertyName, JToken value, out string error) + { + error = null; + + if (component == null) + { + error = "Component is null."; + return false; + } + + if (string.IsNullOrEmpty(propertyName)) + { + error = "Property name is null or empty."; + return false; + } + + Type type = component.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + string normalizedName = ParamCoercion.NormalizePropertyName(propertyName); + + // Try property first - check both original and normalized names for backwards compatibility + PropertyInfo propInfo = type.GetProperty(propertyName, flags) + ?? type.GetProperty(normalizedName, flags); + if (propInfo != null && propInfo.CanWrite) + { + try + { + object convertedValue = PropertyConversion.ConvertToType(value, propInfo.PropertyType); + // Detect conversion failure: null result when input wasn't null + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for property '{propertyName}' to type '{propInfo.PropertyType.Name}'."; + return false; + } + propInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set property '{propertyName}': {ex.Message}"; + return false; + } + } + + // Try field - check both original and normalized names for backwards compatibility + FieldInfo fieldInfo = type.GetField(propertyName, flags) + ?? type.GetField(normalizedName, flags); + if (fieldInfo != null && !fieldInfo.IsInitOnly) + { + try + { + object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); + // Detect conversion failure: null result when input wasn't null + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; + return false; + } + fieldInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set field '{propertyName}': {ex.Message}"; + return false; + } + } + + // Try non-public serialized fields - traverse inheritance hierarchy + // Type.GetField() with NonPublic only finds fields declared directly on that type, + // so we need to walk up the inheritance chain manually + fieldInfo = FindSerializedFieldInHierarchy(type, propertyName) + ?? FindSerializedFieldInHierarchy(type, normalizedName); + if (fieldInfo != null) + { + try + { + object convertedValue = PropertyConversion.ConvertToType(value, fieldInfo.FieldType); + // Detect conversion failure: null result when input wasn't null + if (convertedValue == null && value.Type != JTokenType.Null) + { + error = $"Failed to convert value for serialized field '{propertyName}' to type '{fieldInfo.FieldType.Name}'."; + return false; + } + fieldInfo.SetValue(component, convertedValue); + return true; + } + catch (Exception ex) + { + error = $"Failed to set serialized field '{propertyName}': {ex.Message}"; + return false; + } + } + + error = $"Property or field '{propertyName}' not found on component '{type.Name}'."; + return false; + } + + /// + /// Gets all public properties and fields from a component type. + /// + public static List GetAccessibleMembers(Type componentType) + { + var members = new List(); + if (componentType == null) return members; + + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + + foreach (var prop in componentType.GetProperties(flags)) + { + if (prop.CanWrite && prop.GetSetMethod() != null) + { + members.Add(prop.Name); + } + } + + foreach (var field in componentType.GetFields(flags)) + { + if (!field.IsInitOnly) + { + members.Add(field.Name); + } + } + + // Include private [SerializeField] fields - traverse inheritance hierarchy + // Type.GetFields with NonPublic only returns fields declared directly on that type, + // so we need to walk up the chain to find inherited private serialized fields + var seenFieldNames = new HashSet(members); // Avoid duplicates with public fields + Type currentType = componentType; + while (currentType != null && currentType != typeof(object)) + { + foreach (var field in currentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (field.GetCustomAttribute() != null && !seenFieldNames.Contains(field.Name)) + { + members.Add(field.Name); + seenFieldNames.Add(field.Name); + } + } + currentType = currentType.BaseType; + } + + members.Sort(); + return members; + } + + // --- Private Helpers --- + + /// + /// Searches for a non-public [SerializeField] field through the entire inheritance hierarchy. + /// Type.GetField() with NonPublic only returns fields declared directly on that type, + /// so this method walks up the chain to find inherited private serialized fields. + /// + private static FieldInfo FindSerializedFieldInHierarchy(Type type, string fieldName) + { + if (type == null || string.IsNullOrEmpty(fieldName)) + return null; + + BindingFlags privateFlags = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + Type currentType = type; + + // Walk up the inheritance chain + while (currentType != null && currentType != typeof(object)) + { + // Search for the field on this specific type (case-insensitive) + foreach (var field in currentType.GetFields(privateFlags)) + { + if (string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase) && + field.GetCustomAttribute() != null) + { + return field; + } + } + currentType = currentType.BaseType; + } + + return null; + } + + private static string CheckPhysicsConflict(GameObject target, Type componentType) + { + bool isAdding2DPhysics = + typeof(Rigidbody2D).IsAssignableFrom(componentType) || + typeof(Collider2D).IsAssignableFrom(componentType); + + bool isAdding3DPhysics = + typeof(Rigidbody).IsAssignableFrom(componentType) || + typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + if (target.GetComponent() != null || target.GetComponent() != null) + { + return $"Cannot add 2D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 3D Rigidbody or Collider."; + } + } + else if (isAdding3DPhysics) + { + if (target.GetComponent() != null || target.GetComponent() != null) + { + return $"Cannot add 3D physics component '{componentType.Name}' because the GameObject '{target.name}' already has a 2D Rigidbody or Collider."; + } + } + + return null; + } + + private static void ApplyDefaultValues(Component component) + { + // Default newly added Lights to Directional + if (component is Light light) + { + light.type = LightType.Directional; + } + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta new file mode 100644 index 0000000..1cff988 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 13dead161bc4540eeb771961df437779 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ComponentOps.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs new file mode 100644 index 0000000..938d33c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ConfigJsonBuilder + { + public static string BuildManualConfigJson(string uvPath, McpClient client) + { + var root = new JObject(); + bool isVSCode = client?.IsVsCodeLayout == true; + JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); + + var unity = new JObject(); + PopulateUnityNode(unity, uvPath, client, isVSCode); + + container["unityMCP"] = unity; + + return root.ToString(Formatting.Indented); + } + + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, McpClient client) + { + if (root == null) root = new JObject(); + bool isVSCode = client?.IsVsCodeLayout == true; + JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); + JObject unity = container["unityMCP"] as JObject ?? new JObject(); + PopulateUnityNode(unity, uvPath, client, isVSCode); + + container["unityMCP"] = unity; + return root; + } + + /// + /// Centralized builder that applies all caveats consistently. + /// - Sets command/args with uvx and package version + /// - Ensures env exists + /// - Adds transport configuration (HTTP or stdio) + /// - Adds disabled:false for Windsurf/Kiro only when missing + /// + private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) + { + // Get transport preference (default to HTTP) + bool prefValue = EditorConfigurationCache.Instance.UseHttpTransport; + bool clientSupportsHttp = client?.SupportsHttpTransport != false; + bool useHttpTransport = clientSupportsHttp && prefValue; + string httpProperty = string.IsNullOrEmpty(client?.HttpUrlProperty) ? "url" : client.HttpUrlProperty; + var urlPropsToRemove = new HashSet(StringComparer.OrdinalIgnoreCase) { "url", "serverUrl" }; + urlPropsToRemove.Remove(httpProperty); + + if (useHttpTransport) + { + // HTTP mode: Use URL, no command + string httpUrl = HttpEndpointUtility.GetMcpRpcUrl(); + unity[httpProperty] = httpUrl; + + foreach (var prop in urlPropsToRemove) + { + if (unity[prop] != null) unity.Remove(prop); + } + + // Remove command/args if they exist from previous config + if (unity["command"] != null) unity.Remove("command"); + if (unity["args"] != null) unity.Remove("args"); + + // Only include API key header for remote-hosted mode + if (HttpEndpointUtility.IsRemoteScope()) + { + string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty); + if (!string.IsNullOrEmpty(apiKey)) + { + var headers = new JObject { [AuthConstants.ApiKeyHeader] = apiKey }; + unity["headers"] = headers; + } + else + { + if (unity["headers"] != null) unity.Remove("headers"); + } + } + else + { + // Local HTTP doesn't use API keys; remove any stale headers + if (unity["headers"] != null) unity.Remove("headers"); + } + + if (isVSCode) + { + unity["type"] = "http"; + } + // Also add type for Claude Code (uses mcpServers layout but needs type field) + else if (client?.name == "Claude Code") + { + unity["type"] = "http"; + } + } + else + { + // Stdio mode: Use uvx command + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + + var toolArgs = BuildUvxArgs(fromUrl, packageName); + + unity["command"] = uvxPath; + unity["args"] = JArray.FromObject(toolArgs.ToArray()); + + // Remove url/serverUrl if they exist from previous config + if (unity["url"] != null) unity.Remove("url"); + if (unity["serverUrl"] != null) unity.Remove("serverUrl"); + + if (isVSCode) + { + unity["type"] = "stdio"; + } + } + + // Remove type for non-VSCode clients (except Claude Code which needs it) + if (!isVSCode && client?.name != "Claude Code" && unity["type"] != null) + { + unity.Remove("type"); + } + + bool requiresEnv = client?.EnsureEnvObject == true; + bool stripEnv = client?.StripEnvWhenNotRequired == true; + + if (requiresEnv) + { + if (unity["env"] == null) + { + unity["env"] = new JObject(); + } + } + else if (stripEnv && unity["env"] != null) + { + unity.Remove("env"); + } + + if (client?.DefaultUnityFields != null) + { + foreach (var kvp in client.DefaultUnityFields) + { + if (unity[kvp.Key] == null) + { + unity[kvp.Key] = kvp.Value != null ? JToken.FromObject(kvp.Value) : JValue.CreateNull(); + } + } + } + } + + private static JObject EnsureObject(JObject parent, string name) + { + if (parent[name] is JObject o) return o; + var created = new JObject(); + parent[name] = created; + return created; + } + + private static IList BuildUvxArgs(string fromUrl, string packageName) + { + // Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating). + // `--no-cache` avoids reading from cache; `--refresh` ensures metadata is revalidated. + // Note: --reinstall is not supported by uvx and will cause a warning. + // Keep ordering consistent with other uvx builders: dev flags first, then --from , then package name. + var args = new List(); + + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + if (AssetPathUtility.ShouldForceUvxRefresh()) + { + args.Add("--no-cache"); + args.Add("--refresh"); + } + + // Use centralized helper for beta server / prerelease args + foreach (var arg in AssetPathUtility.GetBetaServerFromArgsList()) + { + args.Add(arg); + } + args.Add(packageName); + + args.Add("--transport"); + args.Add("stdio"); + + return args; + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta new file mode 100644 index 0000000..1e46e14 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5c07c3369f73943919d9e086a81d1dcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs b/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs new file mode 100644 index 0000000..3801a03 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -0,0 +1,324 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class ExecPath + { + private const string PrefClaude = EditorPrefKeys.ClaudeCliPathOverride; + + // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. + internal static string ResolveClaude() + { + try + { + string pref = EditorPrefs.GetString(PrefClaude, string.Empty); + if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; + } + catch { } + + string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + // Common npm global locations + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string[] candidates = + { + // Prefer .cmd (most reliable from non-interactive processes) + Path.Combine(appData, "npm", "claude.cmd"), + Path.Combine(localAppData, "npm", "claude.cmd"), + // Fall back to PowerShell shim if only .ps1 is present + Path.Combine(appData, "npm", "claude.ps1"), + Path.Combine(localAppData, "npm", "claude.ps1"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + string fromWhere = FindInPathWindows("claude.exe") ?? FindInPathWindows("claude.cmd") ?? FindInPathWindows("claude.ps1") ?? FindInPathWindows("claude"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + + // Linux + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/usr/local/bin/claude", + "/usr/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + } + + // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version + private static string ResolveClaudeFromNvm(string home) + { + try + { + if (string.IsNullOrEmpty(home)) return null; + string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); + if (!Directory.Exists(nvmNodeDir)) return null; + + string bestPath = null; + Version bestVersion = null; + foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) + { + string name = Path.GetFileName(versionDir); + if (string.IsNullOrEmpty(name)) continue; + if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 + string versionStr = name.Substring(1); + int dashIndex = versionStr.IndexOf('-'); + if (dashIndex > 0) + { + versionStr = versionStr.Substring(0, dashIndex); + } + if (Version.TryParse(versionStr, out Version parsed)) + { + string candidate = Path.Combine(versionDir, "bin", "claude"); + if (File.Exists(candidate)) + { + if (bestVersion == null || parsed > bestVersion) + { + bestVersion = parsed; + bestPath = candidate; + } + } + } + } + } + return bestPath; + } + catch { return null; } + } + + // Explicitly set the Claude CLI absolute path override in EditorPrefs + internal static void SetClaudeCliPath(string absolutePath) + { + try + { + if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) + { + EditorPrefs.SetString(PrefClaude, absolutePath); + } + } + catch { } + } + + // Clear any previously set Claude CLI override path + internal static void ClearClaudeCliPath() + { + try + { + if (EditorPrefs.HasKey(PrefClaude)) + { + EditorPrefs.DeleteKey(PrefClaude); + } + } + catch { } + } + + internal static bool TryRun( + string file, + string args, + string workingDir, + out string stdout, + out string stderr, + int timeoutMs = 15000, + string extraPathPrepend = null) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + // Handle PowerShell scripts on Windows by invoking through powershell.exe + bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + + var psi = new ProcessStartInfo + { + FileName = isPs1 ? "powershell.exe" : file, + Arguments = isPs1 + ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() + : args, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + } + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; + + var sb = new StringBuilder(); + var se = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) sb.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { } + return false; + } + + // Ensure async buffers are flushed + process.WaitForExit(); + + stdout = sb.ToString(); + stderr = se.ToString(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + /// + /// Cross-platform path lookup. Uses 'where' on Windows, 'which' on macOS/Linux. + /// Returns the full path if found, null otherwise. + /// + internal static string FindInPath(string executable, string extraPathPrepend = null) + { +#if UNITY_EDITOR_WIN + return FindInPathWindows(executable, extraPathPrepend); +#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which(executable, extraPathPrepend ?? string.Empty); +#else + return null; +#endif + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + private static string Which(string exe, string prependPath) + { + try + { + var psi = new ProcessStartInfo("/usr/bin/which", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + + using var p = Process.Start(psi); + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string output = so.ToString().Trim(); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + catch { return null; } + } +#endif + +#if UNITY_EDITOR_WIN + private static string FindInPathWindows(string exe, string extraPathPrepend = null) + { + try + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + string effectivePath = string.IsNullOrEmpty(extraPathPrepend) + ? currentPath + : (string.IsNullOrEmpty(currentPath) ? extraPathPrepend : extraPathPrepend + Path.PathSeparator + currentPath); + + var psi = new ProcessStartInfo("where", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(effectivePath)) + { + psi.EnvironmentVariables["PATH"] = effectivePath; + } + + using var p = Process.Start(psi); + if (p == null) return null; + + var so = new StringBuilder(); + p.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + p.BeginOutputReadLine(); + + if (!p.WaitForExit(1500)) + { + try { p.Kill(); } catch { } + return null; + } + + p.WaitForExit(); + string first = so.ToString() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + catch { return null; } + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs.meta new file mode 100644 index 0000000..ce47b41 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ExecPath.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ExecPath.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs b/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs new file mode 100644 index 0000000..bd23bbd --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for finding and looking up GameObjects in the scene. + /// Provides search functionality by name, tag, layer, component, path, and instance ID. + /// + public static class GameObjectLookup + { + /// + /// Supported search methods for finding GameObjects. + /// + public enum SearchMethod + { + ByName, + ByTag, + ByLayer, + ByComponent, + ByPath, + ById + } + + /// + /// Parses a search method string into the enum value. + /// + public static SearchMethod ParseSearchMethod(string method) + { + if (string.IsNullOrEmpty(method)) + return SearchMethod.ByName; + + return method.ToLowerInvariant() switch + { + "by_name" => SearchMethod.ByName, + "by_tag" => SearchMethod.ByTag, + "by_layer" => SearchMethod.ByLayer, + "by_component" => SearchMethod.ByComponent, + "by_path" => SearchMethod.ByPath, + "by_id" => SearchMethod.ById, + _ => SearchMethod.ByName + }; + } + + /// + /// Finds a single GameObject based on the target and search method. + /// + /// The target identifier (name, ID, path, etc.) + /// The search method to use + /// Whether to include inactive objects + /// The found GameObject or null + public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false) + { + if (target == null) + return null; + + var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1); + return results.Count > 0 ? FindById(results[0]) : null; + } + + /// + /// Finds a GameObject by its instance ID. + /// + public static GameObject FindById(int instanceId) + { +#pragma warning disable CS0618 // Type or member is obsolete + return EditorUtility.InstanceIDToObject(instanceId) as GameObject; +#pragma warning restore CS0618 + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method string (by_name, by_tag, etc.) + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var method = ParseSearchMethod(searchMethod); + return SearchGameObjects(method, searchTerm, includeInactive, maxResults); + } + + /// + /// Searches for GameObjects and returns their instance IDs. + /// + /// The search method + /// The term to search for + /// Whether to include inactive objects + /// Maximum number of results to return (0 = unlimited) + /// List of instance IDs + public static List SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0) + { + var results = new List(); + + switch (method) + { + case SearchMethod.ById: + if (int.TryParse(searchTerm, out int instanceId)) + { +#pragma warning disable CS0618 // Type or member is obsolete + var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; +#pragma warning restore CS0618 + if (obj != null && (includeInactive || obj.activeInHierarchy)) + { + results.Add(instanceId); + } + } + break; + + case SearchMethod.ByName: + results.AddRange(SearchByName(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByPath: + results.AddRange(SearchByPath(searchTerm, includeInactive)); + break; + + case SearchMethod.ByTag: + results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByLayer: + results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults)); + break; + + case SearchMethod.ByComponent: + results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults)); + break; + } + + return results; + } + + private static IEnumerable SearchByName(string name, bool includeInactive, int maxResults) + { + var allObjects = GetAllSceneObjects(includeInactive); + var matching = allObjects.Where(go => go.name == name); + + if (maxResults > 0) + matching = matching.Take(maxResults); + + return matching.Select(go => go.GetInstanceID()); + } + + private static IEnumerable SearchByPath(string path, bool includeInactive) + { + // Check Prefab Stage first - GameObject.Find() doesn't work in Prefab Stage + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + { + // Use GetAllSceneObjects which already handles Prefab Stage + var allObjects = GetAllSceneObjects(includeInactive); + foreach (var go in allObjects) + { + if (MatchesPath(go, path)) + { + yield return go.GetInstanceID(); + } + } + yield break; + } + + // Normal scene mode + // NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects. + // If includeInactive=true, we need to search manually to find inactive objects. + if (includeInactive) + { + // Search manually to support inactive objects + var allObjects = GetAllSceneObjects(true); + foreach (var go in allObjects) + { + if (MatchesPath(go, path)) + { + yield return go.GetInstanceID(); + } + } + } + else + { + // Use GameObject.Find for active objects only (Unity API limitation) + var found = GameObject.Find(path); + if (found != null) + { + yield return found.GetInstanceID(); + } + } + } + + private static IEnumerable SearchByTag(string tag, bool includeInactive, int maxResults) + { + GameObject[] taggedObjects; + try + { + if (includeInactive) + { + // FindGameObjectsWithTag doesn't find inactive, so we need to iterate all + var allObjects = GetAllSceneObjects(true); + taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray(); + } + else + { + taggedObjects = GameObject.FindGameObjectsWithTag(tag); + } + } + catch (UnityException) + { + // Tag doesn't exist + yield break; + } + + var results = taggedObjects.AsEnumerable(); + if (maxResults > 0) + results = results.Take(maxResults); + + foreach (var go in results) + { + yield return go.GetInstanceID(); + } + } + + private static IEnumerable SearchByLayer(string layerName, bool includeInactive, int maxResults) + { + int layer = LayerMask.NameToLayer(layerName); + if (layer == -1) + { + // Try parsing as layer number + if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31) + { + yield break; + } + } + + var allObjects = GetAllSceneObjects(includeInactive); + var matching = allObjects.Where(go => go.layer == layer); + + if (maxResults > 0) + matching = matching.Take(maxResults); + + foreach (var go in matching) + { + yield return go.GetInstanceID(); + } + } + + private static IEnumerable SearchByComponent(string componentTypeName, bool includeInactive, int maxResults) + { + Type componentType = FindComponentType(componentTypeName); + if (componentType == null) + { + McpLog.Warn($"[GameObjectLookup] Component type '{componentTypeName}' not found."); + yield break; + } + + var allObjects = GetAllSceneObjects(includeInactive); + var count = 0; + + foreach (var go in allObjects) + { + if (go.GetComponent(componentType) != null) + { + yield return go.GetInstanceID(); + count++; + + if (maxResults > 0 && count >= maxResults) + yield break; + } + } + } + + /// + /// Gets all GameObjects in the current scene. + /// + public static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // Check Prefab Stage first + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null && prefabStage.prefabContentsRoot != null) + { + // Use Prefab Stage's prefabContentsRoot + foreach (var go in GetObjectAndDescendants(prefabStage.prefabContentsRoot, includeInactive)) + { + yield return go; + } + yield break; + } + + // Normal scene mode + var scene = SceneManager.GetActiveScene(); + if (!scene.IsValid()) + yield break; + + var rootObjects = scene.GetRootGameObjects(); + foreach (var root in rootObjects) + { + foreach (var go in GetObjectAndDescendants(root, includeInactive)) + { + yield return go; + } + } + } + + private static IEnumerable GetObjectAndDescendants(GameObject obj, bool includeInactive) + { + if (!includeInactive && !obj.activeInHierarchy) + yield break; + + yield return obj; + + foreach (Transform child in obj.transform) + { + foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive)) + { + yield return descendant; + } + } + } + + /// + /// Finds a component type by name, searching loaded assemblies. + /// + /// + /// Delegates to UnityTypeResolver.ResolveComponent() for unified type resolution. + /// + public static Type FindComponentType(string typeName) + { + return UnityTypeResolver.ResolveComponent(typeName); + } + + /// + /// Checks whether a GameObject matches a path or trailing path segment. + /// + internal static bool MatchesPath(GameObject go, string path) + { + if (go == null || string.IsNullOrEmpty(path)) + return false; + + var goPath = GetGameObjectPath(go); + return goPath == path || goPath.EndsWith("/" + path); + } + + /// + /// Gets the hierarchical path of a GameObject. + /// + public static string GetGameObjectPath(GameObject obj) + { + if (obj == null) + return string.Empty; + + var path = obj.name; + var parent = obj.transform.parent; + + while (parent != null) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + + return path; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta b/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta new file mode 100644 index 0000000..9f5ce05 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4964205faa8dd4f8a960e58fd8c0d4f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/GameObjectLookup.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs new file mode 100644 index 0000000..688546b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -0,0 +1,666 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Runtime.Serialization; // For Converters +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Handles serialization of GameObjects and Components for MCP responses. + /// Includes reflection helpers and caching for performance. + /// + public static class GameObjectSerializer + { + // --- Data Serialization --- + + /// + /// Creates a serializable representation of a GameObject. + /// + public static object GetGameObjectData(GameObject go) + { + if (go == null) + return null; + return new + { + name = go.name, + instanceID = go.GetInstanceID(), + tag = go.tag, + layer = go.layer, + activeSelf = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + scenePath = go.scene.path, // Identify which scene it belongs to + transform = new // Serialize transform components carefully to avoid JSON issues + { + // Serialize Vector3 components individually to prevent self-referencing loops. + // The default serializer can struggle with properties like Vector3.normalized. + position = new + { + x = go.transform.position.x, + y = go.transform.position.y, + z = go.transform.position.z, + }, + localPosition = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.rotation.eulerAngles.x, + y = go.transform.rotation.eulerAngles.y, + z = go.transform.rotation.eulerAngles.z, + }, + localRotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + forward = new + { + x = go.transform.forward.x, + y = go.transform.forward.y, + z = go.transform.forward.z, + }, + up = new + { + x = go.transform.up.x, + y = go.transform.up.y, + z = go.transform.up.z, + }, + right = new + { + x = go.transform.right.x, + y = go.transform.right.y, + z = go.transform.right.z, + }, + }, + parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent + // Optionally include components, but can be large + // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() + // Or just component names: + componentNames = go.GetComponents() + .Select(c => c.GetType().FullName) + .ToList(), + }; + } + + // --- Metadata Caching for Reflection --- + private class CachedMetadata + { + public readonly List SerializableProperties; + public readonly List SerializableFields; + + public CachedMetadata(List properties, List fields) + { + SerializableProperties = properties; + SerializableFields = fields; + } + } + // Key becomes Tuple + private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); + // --- End Metadata Caching --- + + /// + /// Checks if a type is or derives from a type with the specified full name. + /// Used to detect special-case components including their subclasses. + /// + private static bool IsOrDerivedFrom(Type type, string baseTypeFullName) + { + Type current = type; + while (current != null) + { + if (current.FullName == baseTypeFullName) + return true; + current = current.BaseType; + } + return false; + } + + /// + /// Serializes a UnityEngine.Object reference to a dictionary with name, instanceID, and assetPath. + /// Used for consistent serialization of asset references in special-case component handlers. + /// + /// The Unity object to serialize + /// Whether to include the asset path (default true) + /// A dictionary with the object's reference info, or null if obj is null + private static Dictionary SerializeAssetReference(UnityEngine.Object obj, bool includeAssetPath = true) + { + if (obj == null) return null; + + var result = new Dictionary + { + { "name", obj.name }, + { "instanceID", obj.GetInstanceID() } + }; + + if (includeAssetPath) + { + var assetPath = AssetDatabase.GetAssetPath(obj); + result["assetPath"] = string.IsNullOrEmpty(assetPath) ? null : assetPath; + } + + return result; + } + + /// + /// Creates a serializable representation of a Component, attempting to serialize + /// public properties and fields using reflection, with caching and control over non-public fields. + /// + // Add the flag parameter here + public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) + { + // --- Add Early Logging --- + // McpLog.Info($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); + // --- End Early Logging --- + + if (c == null) return null; + Type componentType = c.GetType(); + + // --- Special handling for Transform to avoid reflection crashes and problematic properties --- + if (componentType == typeof(Transform)) + { + Transform tr = c as Transform; + // McpLog.Info($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", tr.GetInstanceID() }, + // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. + { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, // Use Euler angles + { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, + { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, + { "childCount", tr.childCount }, + // Include standard Object/Component properties + { "name", tr.name }, + { "tag", tr.tag }, + { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } + }; + } + // --- End Special handling for Transform --- + + // --- Special handling for Camera to avoid matrix-related crashes --- + if (componentType == typeof(Camera)) + { + Camera cam = c as Camera; + var cameraProperties = new Dictionary(); + + // List of safe properties to serialize + var safeProperties = new Dictionary> + { + { "nearClipPlane", () => cam.nearClipPlane }, + { "farClipPlane", () => cam.farClipPlane }, + { "fieldOfView", () => cam.fieldOfView }, + { "renderingPath", () => (int)cam.renderingPath }, + { "actualRenderingPath", () => (int)cam.actualRenderingPath }, + { "allowHDR", () => cam.allowHDR }, + { "allowMSAA", () => cam.allowMSAA }, + { "allowDynamicResolution", () => cam.allowDynamicResolution }, + { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, + { "orthographicSize", () => cam.orthographicSize }, + { "orthographic", () => cam.orthographic }, + { "opaqueSortMode", () => (int)cam.opaqueSortMode }, + { "transparencySortMode", () => (int)cam.transparencySortMode }, + { "depth", () => cam.depth }, + { "aspect", () => cam.aspect }, + { "cullingMask", () => cam.cullingMask }, + { "eventMask", () => cam.eventMask }, + { "backgroundColor", () => cam.backgroundColor }, + { "clearFlags", () => (int)cam.clearFlags }, + { "stereoEnabled", () => cam.stereoEnabled }, + { "stereoSeparation", () => cam.stereoSeparation }, + { "stereoConvergence", () => cam.stereoConvergence }, + { "enabled", () => cam.enabled }, + { "name", () => cam.name }, + { "tag", () => cam.tag }, + { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } + }; + + foreach (var prop in safeProperties) + { + try + { + var value = prop.Value(); + if (value != null) + { + AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); + } + } + catch (Exception) + { + // Silently skip any property that fails + continue; + } + } + + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", cam.GetInstanceID() }, + { "properties", cameraProperties } + }; + } + // --- End Special handling for Camera --- + + // --- Special handling for UIDocument to avoid infinite loops from VisualElement hierarchy (Issue #585) --- + // UIDocument.rootVisualElement contains circular parent/child references that cause infinite serialization loops. + // Use IsOrDerivedFrom to also catch subclasses of UIDocument. + if (IsOrDerivedFrom(componentType, "UnityEngine.UIElements.UIDocument")) + { + var uiDocProperties = new Dictionary(); + + try + { + // Get panelSettings reference safely + var panelSettingsProp = componentType.GetProperty("panelSettings"); + if (panelSettingsProp != null) + { + var panelSettings = panelSettingsProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["panelSettings"] = SerializeAssetReference(panelSettings); + } + + // Get visualTreeAsset reference safely (the UXML file) + var visualTreeAssetProp = componentType.GetProperty("visualTreeAsset"); + if (visualTreeAssetProp != null) + { + var visualTreeAsset = visualTreeAssetProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["visualTreeAsset"] = SerializeAssetReference(visualTreeAsset); + } + + // Get sortingOrder safely + var sortingOrderProp = componentType.GetProperty("sortingOrder"); + if (sortingOrderProp != null) + { + uiDocProperties["sortingOrder"] = sortingOrderProp.GetValue(c); + } + + // Get enabled state (from Behaviour base class) + var enabledProp = componentType.GetProperty("enabled"); + if (enabledProp != null) + { + uiDocProperties["enabled"] = enabledProp.GetValue(c); + } + + // Get parentUI reference safely (no asset path needed - it's a scene reference) + var parentUIProp = componentType.GetProperty("parentUI"); + if (parentUIProp != null) + { + var parentUI = parentUIProp.GetValue(c) as UnityEngine.Object; + uiDocProperties["parentUI"] = SerializeAssetReference(parentUI, includeAssetPath: false); + } + + // NOTE: rootVisualElement is intentionally skipped - it contains circular + // parent/child references that cause infinite serialization loops + uiDocProperties["_note"] = "rootVisualElement skipped to prevent circular reference loops"; + } + catch (Exception e) + { + McpLog.Warn($"[GetComponentData] Error reading UIDocument properties: {e.Message}"); + } + + // Return structure matches Camera special handling (typeName, instanceID, properties) + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceID() }, + { "properties", uiDocProperties } + }; + } + // --- End Special handling for UIDocument --- + + var data = new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceID() } + }; + + // --- Get Cached or Generate Metadata (using new cache key) --- + Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); + if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) + { + var propertiesToCache = new List(); + var fieldsToCache = new List(); + + // Traverse the hierarchy from the component type up to MonoBehaviour + Type currentType = componentType; + while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) + { + // Get properties declared only at the current type level + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var propInfo in currentType.GetProperties(propFlags)) + { + // Basic filtering (readable, not indexer, not transform which is handled elsewhere) + if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // Add if not already added (handles overrides - keep the most derived version) + if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) + { + propertiesToCache.Add(propInfo); + } + } + + // Get fields declared only at the current type level (both public and non-public) + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var declaredFields = currentType.GetFields(fieldFlags); + + // Process the declared Fields for caching + foreach (var fieldInfo in declaredFields) + { + if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields + + // Add if not already added (handles hiding - keep the most derived version) + if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; + + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR any NonPublic with [SerializeField] (private/protected/internal) + var hasSerializeField = fieldInfo.IsDefined(typeof(SerializeField), inherit: true); + shouldInclude = fieldInfo.IsPublic || (!fieldInfo.IsPublic && hasSerializeField); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } + + if (shouldInclude) + { + fieldsToCache.Add(fieldInfo); + } + } + + // Move to the base type + currentType = currentType.BaseType; + } + // --- End Hierarchy Traversal --- + + cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); + _metadataCache[cacheKey] = cachedData; // Add to cache with combined key + } + // --- End Get Cached or Generate Metadata --- + + // --- Use cached metadata --- + var serializablePropertiesOutput = new Dictionary(); + + // --- Add Logging Before Property Loop --- + // McpLog.Info($"[GetComponentData] Starting property loop for {componentType.Name}..."); + // --- End Logging Before Property Loop --- + + // Use cached properties + foreach (var propInfo in cachedData.SerializableProperties) + { + string propName = propInfo.Name; + + // --- Skip known obsolete/problematic Component shortcut properties --- + bool skipProperty = false; + if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || + propName == "light" || propName == "animation" || propName == "constantForce" || + propName == "renderer" || propName == "audio" || propName == "networkView" || + propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || + propName == "particleSystem" || + // Also skip potentially problematic Matrix properties prone to cycles/errors + propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") + { + // McpLog.Info($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log + skipProperty = true; + } + // --- End Skip Generic Properties --- + + // --- Skip specific potentially problematic Camera properties --- + if (componentType == typeof(Camera) && + (propName == "pixelRect" || + propName == "rect" || + propName == "cullingMatrix" || + propName == "useOcclusionCulling" || + propName == "worldToCameraMatrix" || + propName == "projectionMatrix" || + propName == "nonJitteredProjectionMatrix" || + propName == "previousViewProjectionMatrix" || + propName == "cameraToWorldMatrix")) + { + // McpLog.Info($"[GetComponentData] Explicitly skipping Camera property: {propName}"); + skipProperty = true; + } + // --- End Skip Camera Properties --- + + // --- Skip specific potentially problematic Transform properties --- + if (componentType == typeof(Transform) && + (propName == "lossyScale" || + propName == "rotation" || + propName == "worldToLocalMatrix" || + propName == "localToWorldMatrix")) + { + // McpLog.Info($"[GetComponentData] Explicitly skipping Transform property: {propName}"); + skipProperty = true; + } + // --- End Skip Transform Properties --- + + // Skip if flagged + if (skipProperty) + { + continue; + } + + try + { + // --- Add detailed logging --- + // McpLog.Info($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); + // --- End detailed logging --- + + // --- Special handling for material/mesh properties in edit mode --- + object value; + if (!Application.isPlaying && (propName == "material" || propName == "materials" || propName == "mesh")) + { + // In edit mode, use sharedMaterial/sharedMesh to avoid instantiation warnings + if ((propName == "material" || propName == "materials") && c is Renderer renderer) + { + if (propName == "material") + value = renderer.sharedMaterial; + else // materials + value = renderer.sharedMaterials; + } + else if (propName == "mesh" && c is MeshFilter meshFilter) + { + value = meshFilter.sharedMesh; + } + else + { + // Fallback to normal property access if type doesn't match + value = propInfo.GetValue(c); + } + } + else + { + value = propInfo.GetValue(c); + } + // --- End special handling --- + + Type propType = propInfo.PropertyType; + AddSerializableValue(serializablePropertiesOutput, propName, propType, value); + } + catch (Exception) + { + // McpLog.Warn($"Could not read property {propName} on {componentType.Name}"); + } + } + + // --- Add Logging Before Field Loop --- + // McpLog.Info($"[GetComponentData] Starting field loop for {componentType.Name}..."); + // --- End Logging Before Field Loop --- + + // Use cached fields + foreach (var fieldInfo in cachedData.SerializableFields) + { + try + { + // --- Add detailed logging for fields --- + // McpLog.Info($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); + // --- End detailed logging for fields --- + object value = fieldInfo.GetValue(c); + string fieldName = fieldInfo.Name; + Type fieldType = fieldInfo.FieldType; + AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); + } + catch (Exception) + { + // McpLog.Warn($"Could not read field {fieldInfo.Name} on {componentType.Name}"); + } + } + // --- End Use cached metadata --- + + if (serializablePropertiesOutput.Count > 0) + { + data["properties"] = serializablePropertiesOutput; + } + + return data; + } + + // Helper function to decide how to serialize different types + private static void AddSerializableValue(Dictionary dict, string name, Type type, object value) + { + // Simplified: Directly use CreateTokenFromValue which uses the serializer + if (value == null) + { + dict[name] = null; + return; + } + + try + { + // Use the helper that employs our custom serializer settings + JToken token = CreateTokenFromValue(value, type); + if (token != null) // Check if serialization succeeded in the helper + { + // Convert JToken back to a basic object structure for the dictionary + dict[name] = ConvertJTokenToPlainObject(token); + } + // If token is null, it means serialization failed and a warning was logged. + } + catch (Exception e) + { + // Catch potential errors during JToken conversion or addition to dictionary + McpLog.Warn($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); + } + } + + // Helper to convert JToken back to basic object structure + private static object ConvertJTokenToPlainObject(JToken token) + { + if (token == null) return null; + + switch (token.Type) + { + case JTokenType.Object: + var objDict = new Dictionary(); + foreach (var prop in ((JObject)token).Properties()) + { + objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); + } + return objDict; + + case JTokenType.Array: + var list = new List(); + foreach (var item in (JArray)token) + { + list.Add(ConvertJTokenToPlainObject(item)); + } + return list; + + case JTokenType.Integer: + return token.ToObject(); // Use long for safety + case JTokenType.Float: + return token.ToObject(); // Use double for safety + case JTokenType.String: + return token.ToObject(); + case JTokenType.Boolean: + return token.ToObject(); + case JTokenType.Date: + return token.ToObject(); + case JTokenType.Guid: + return token.ToObject(); + case JTokenType.Uri: + return token.ToObject(); + case JTokenType.TimeSpan: + return token.ToObject(); + case JTokenType.Bytes: + return token.ToObject(); + case JTokenType.Null: + return null; + case JTokenType.Undefined: + return null; // Treat undefined as null + + default: + // Fallback for simple value types not explicitly listed + if (token is JValue jValue && jValue.Value != null) + { + return jValue.Value; + } + // McpLog.Warn($"Unsupported JTokenType encountered: {token.Type}. Returning null."); + return null; + } + } + + // --- Define custom JsonSerializerSettings for OUTPUT --- + private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new Matrix4x4Converter(), // Fix #478: Safe Matrix4x4 serialization for Cinemachine + new UnityEngineObjectConverter() // Handles serialization of references + }, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed + }; + private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); + // --- End Define custom JsonSerializerSettings --- + + // Helper to create JToken using the output serializer + private static JToken CreateTokenFromValue(object value, Type type) + { + if (value == null) return JValue.CreateNull(); + + try + { + // Use the pre-configured OUTPUT serializer instance + return JToken.FromObject(value, _outputSerializer); + } + catch (JsonSerializationException e) + { + McpLog.Warn($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); + return null; // Indicate serialization failure + } + catch (Exception e) // Catch other unexpected errors + { + McpLog.Warn($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); + return null; // Indicate serialization failure + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta b/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta new file mode 100644 index 0000000..5a81e2e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 64b8ff807bc9a401c82015cbafccffac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs new file mode 100644 index 0000000..36d1680 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -0,0 +1,184 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper methods for managing HTTP endpoint URLs used by the MCP bridge. + /// Ensures the stored value is always the base URL (without trailing path), + /// and provides convenience accessors for specific endpoints. + /// + /// HTTP Local and HTTP Remote use separate EditorPrefs keys so that switching + /// between scopes does not overwrite the other scope's URL. + /// + public static class HttpEndpointUtility + { + private const string LocalPrefKey = EditorPrefKeys.HttpBaseUrl; + private const string RemotePrefKey = EditorPrefKeys.HttpRemoteBaseUrl; + private const string DefaultLocalBaseUrl = "http://localhost:8080"; + private const string DefaultRemoteBaseUrl = "https://mc-82a58513564647608b491c00acc383c8.ecs.us-east-2.on.aws"; + + /// + /// Returns the normalized base URL for the currently active HTTP scope. + /// If the scope is "remote", returns the remote URL; otherwise returns the local URL. + /// + public static string GetBaseUrl() + { + return IsRemoteScope() ? GetRemoteBaseUrl() : GetLocalBaseUrl(); + } + + /// + /// Saves a user-provided URL to the currently active HTTP scope's pref. + /// + public static void SaveBaseUrl(string userValue) + { + if (IsRemoteScope()) + { + SaveRemoteBaseUrl(userValue); + } + else + { + SaveLocalBaseUrl(userValue); + } + } + + /// + /// Returns the normalized local HTTP base URL (always reads local pref). + /// + public static string GetLocalBaseUrl() + { + string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl); + return NormalizeBaseUrl(stored, DefaultLocalBaseUrl); + } + + /// + /// Saves a user-provided URL to the local HTTP pref. + /// + public static void SaveLocalBaseUrl(string userValue) + { + string normalized = NormalizeBaseUrl(userValue, DefaultLocalBaseUrl); + EditorPrefs.SetString(LocalPrefKey, normalized); + } + + /// + /// Returns the normalized remote HTTP base URL (always reads remote pref). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteBaseUrl() + { + string stored = EditorPrefs.GetString(RemotePrefKey, DefaultRemoteBaseUrl); + if (string.IsNullOrWhiteSpace(stored)) + { + return DefaultRemoteBaseUrl; + } + return NormalizeBaseUrl(stored, DefaultRemoteBaseUrl); + } + + /// + /// Saves a user-provided URL to the remote HTTP pref. + /// + public static void SaveRemoteBaseUrl(string userValue) + { + if (string.IsNullOrWhiteSpace(userValue)) + { + EditorPrefs.SetString(RemotePrefKey, DefaultRemoteBaseUrl); + return; + } + string normalized = NormalizeBaseUrl(userValue, DefaultRemoteBaseUrl); + EditorPrefs.SetString(RemotePrefKey, normalized); + } + + /// + /// Builds the JSON-RPC endpoint for the currently active scope (base + /mcp). + /// + public static string GetMcpRpcUrl() + { + return AppendPathSegment(GetBaseUrl(), "mcp"); + } + + /// + /// Builds the local JSON-RPC endpoint (local base + /mcp). + /// + public static string GetLocalMcpRpcUrl() + { + return AppendPathSegment(GetLocalBaseUrl(), "mcp"); + } + + /// + /// Builds the remote JSON-RPC endpoint (remote base + /mcp). + /// Returns empty string if no remote URL is configured. + /// + public static string GetRemoteMcpRpcUrl() + { + string remoteBase = GetRemoteBaseUrl(); + return string.IsNullOrEmpty(remoteBase) ? string.Empty : AppendPathSegment(remoteBase, "mcp"); + } + + /// + /// Builds the endpoint used when POSTing custom-tool registration payloads. + /// + public static string GetRegisterToolsUrl() + { + return AppendPathSegment(GetBaseUrl(), "register-tools"); + } + + /// + /// Returns true if the active HTTP transport scope is "remote". + /// + public static bool IsRemoteScope() + { + string scope = EditorConfigurationCache.Instance.HttpTransportScope; + return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns the that matches the current server-side + /// transport selection (Stdio, Http, or HttpRemote). + /// Centralises the 3-way determination so callers avoid duplicated logic. + /// + public static ConfiguredTransport GetCurrentServerTransport() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + if (!useHttp) return ConfiguredTransport.Stdio; + return IsRemoteScope() ? ConfiguredTransport.HttpRemote : ConfiguredTransport.Http; + } + + /// + /// Normalizes a URL so that we consistently store just the base (no trailing slash/path). + /// + private static string NormalizeBaseUrl(string value, string defaultUrl) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultUrl; + } + + string trimmed = value.Trim(); + + // Ensure scheme exists; default to http:// if user omitted it. + if (!trimmed.Contains("://")) + { + trimmed = $"http://{trimmed}"; + } + + // Remove trailing slash segments. + trimmed = trimmed.TrimEnd('/'); + + // Strip trailing "/mcp" (case-insensitive) if provided. + if (trimmed.EndsWith("/mcp", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^4]; + } + + return trimmed; + } + + private static string AppendPathSegment(string baseUrl, string segment) + { + return $"{baseUrl.TrimEnd('/')}/{segment}"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta b/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta new file mode 100644 index 0000000..3574fc8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2051d90316ea345c09240c80c7138e3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs b/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs new file mode 100644 index 0000000..19f5028 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class MaterialOps + { + /// + /// Applies a set of properties (JObject) to a material, handling aliases and structured formats. + /// + public static bool ApplyProperties(Material mat, JObject properties, JsonSerializer serializer) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Helper for case-insensitive lookup + JToken GetValue(string key) + { + return properties.Properties() + .FirstOrDefault(p => string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))?.Value; + } + + // --- Structured / Legacy Format Handling --- + // Example: Set shader + var shaderToken = GetValue("shader"); + if (shaderToken?.Type == JTokenType.String) + { + string shaderRequest = shaderToken.ToString(); + // Set shader + Shader newShader = RenderPipelineUtility.ResolveShader(shaderRequest); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + + // Example: Set color property (structured) + var colorToken = GetValue("color"); + if (colorToken is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = ParseColor(colArr, serializer); + if (mat.HasProperty(propName)) + { + if (mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color for property '{propName}': {ex.Message}"); + } + } + } + else if (colorToken is JArray colorArr) // Structured shorthand + { + string propName = GetMainColorPropertyName(mat); + try + { + Color newColor = ParseColor(colorArr, serializer); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color array: {ex.Message}"); + } + } + + // Example: Set float property (structured) + var floatToken = GetValue("float"); + if (floatToken is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if (!string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer)) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to set float property '{propName}': {ex.Message}"); + } + } + } + + // Example: Set texture property (structured) + { + var texToken = GetValue("texture"); + if (texToken is JObject texProps) + { + string rawName = (texProps["name"] ?? texProps["Name"])?.ToString(); + string texPath = (texProps["path"] ?? texProps["Path"])?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(texPath); + var newTex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + // Use ResolvePropertyName to handle aliases even for structured texture names + string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName; + string targetProp = ResolvePropertyName(mat, candidateName); + + if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp)) + { + if (mat.GetTexture(targetProp) != newTex) + { + mat.SetTexture(targetProp, newTex); + modified = true; + } + } + } + } + } + + // --- Direct Property Assignment (Flexible) --- + var reservedKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" }; + + foreach (var prop in properties.Properties()) + { + if (reservedKeys.Contains(prop.Name)) continue; + string shaderProp = ResolvePropertyName(mat, prop.Name); + JToken v = prop.Value; + + if (TrySetShaderProperty(mat, shaderProp, v, serializer)) + { + modified = true; + } + } + + return modified; + } + + /// + /// Resolves common property aliases (e.g. "metallic" -> "_Metallic"). + /// + public static string ResolvePropertyName(Material mat, string name) + { + if (mat == null || string.IsNullOrEmpty(name)) return name; + string[] candidates; + var lower = name.ToLowerInvariant(); + switch (lower) + { + case "_color": candidates = new[] { "_Color", "_BaseColor" }; break; + case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break; + case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break; + case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break; + case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break; + case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + // Friendly names → shader property names + case "metallic": candidates = new[] { "_Metallic" }; break; + case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break; + case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break; + default: candidates = new[] { name }; break; // keep original as-is + } + foreach (var candidate in candidates) + { + if (mat.HasProperty(candidate)) return candidate; + } + return name; + } + + /// + /// Auto-detects the main color property name for a material's shader. + /// + public static string GetMainColorPropertyName(Material mat) + { + if (mat == null || mat.shader == null) + return "_Color"; + + string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" }; + foreach (var prop in commonColorProps) + { + if (mat.HasProperty(prop)) + return prop; + } + return "_Color"; + } + + /// + /// Tries to set a shader property on a material based on a JToken value. + /// Handles Colors, Vectors, Floats, Ints, Booleans, and Textures. + /// + public static bool TrySetShaderProperty(Material material, string propertyName, JToken value, JsonSerializer serializer) + { + if (material == null || string.IsNullOrEmpty(propertyName) || value == null) + return false; + + // Handle stringified JSON + if (value.Type == JTokenType.String) + { + string s = value.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + JToken parsed = JToken.Parse(s); + return TrySetShaderProperty(material, propertyName, parsed, serializer); + } + catch { } + } + } + + // Use the serializer to convert the JToken value first + if (value is JArray jArray) + { + if (jArray.Count == 4) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + // Log at Debug level since we'll try other conversions + McpLog.Info($"[MaterialOps] SetColor attempt for '{propertyName}' failed: {ex.Message}"); + } + + try { Vector4 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetVector (Vec4) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 3) + { + if (material.HasProperty(propertyName)) + { + try { material.SetColor(propertyName, ParseColor(value, serializer)); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetColor (Vec3) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + else if (jArray.Count == 2) + { + if (material.HasProperty(propertyName)) + { + try { Vector2 vec = value.ToObject(serializer); material.SetVector(propertyName, vec); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetVector (Vec2) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer)); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetFloat attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.Boolean) + { + if (!material.HasProperty(propertyName)) + return false; + + try { material.SetFloat(propertyName, value.ToObject(serializer) ? 1f : 0f); return true; } + catch (Exception ex) + { + McpLog.Info($"[MaterialOps] SetFloat (bool) attempt for '{propertyName}' failed: {ex.Message}"); + } + } + else if (value.Type == JTokenType.String) + { + try + { + // Try loading as asset path first (most common case for strings in this context) + string path = value.ToString(); + if (!string.IsNullOrEmpty(path) && path.Contains("/")) // Heuristic: paths usually have slashes + { + // We need to handle texture assignment here. + // Since we don't have easy access to AssetDatabase here directly without using UnityEditor namespace (which is imported), + // we can try to load it. + var sanitizedPath = AssetPathUtility.SanitizeAssetPath(path); + Texture tex = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (tex != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, tex); + return true; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (string path) for '{propertyName}' failed: {ex.Message}"); + } + } + + if (value.Type == JTokenType.Object) + { + try + { + Texture texture = value.ToObject(serializer); + if (texture != null && material.HasProperty(propertyName)) + { + material.SetTexture(propertyName, texture); + return true; + } + } + catch (Exception ex) + { + McpLog.Warn($"SetTexture (object) for '{propertyName}' failed: {ex.Message}"); + } + } + + McpLog.Warn( + $"[MaterialOps] Unsupported or failed conversion for material property '{propertyName}' from value: {value.ToString(Formatting.None)}" + ); + return false; + } + + /// + /// Helper to parse color from JToken (array or object). + /// + public static Color ParseColor(JToken token, JsonSerializer serializer) + { + if (token.Type == JTokenType.String) + { + string s = token.ToString(); + if (s.TrimStart().StartsWith("[") || s.TrimStart().StartsWith("{")) + { + try + { + return ParseColor(JToken.Parse(s), serializer); + } + catch { } + } + } + + if (token is JArray jArray) + { + if (jArray.Count == 4) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + (float)jArray[3] + ); + } + else if (jArray.Count == 3) + { + return new Color( + (float)jArray[0], + (float)jArray[1], + (float)jArray[2], + 1f + ); + } + else + { + throw new ArgumentException("Color array must have 3 or 4 elements."); + } + } + + try + { + return token.ToObject(serializer); + } + catch (Exception ex) + { + McpLog.Warn($"[MaterialOps] Failed to parse color from token: {ex.Message}"); + throw; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta b/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta new file mode 100644 index 0000000..5c52b49 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: a59e8545e32664dae9a696d449f82c3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/MaterialOps.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs new file mode 100644 index 0000000..61eccb1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -0,0 +1,283 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for MCP client configuration management with sophisticated + /// logic for preserving existing configs and handling different client types + /// + public static class McpConfigurationHelper + { + private const string LOCK_CONFIG_KEY = EditorPrefKeys.LockCursorConfig; + + /// + /// Writes MCP configuration to the specified path using sophisticated logic + /// that preserves existing configuration and only writes when necessary + /// + public static string WriteMcpConfiguration(string configPath, McpClient mcpClient = null) + { + // 0) Respect explicit lock (hidden pref or UI toggle) + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; + + // Read existing config if it exists + string existingJson = "{}"; + if (File.Exists(configPath)) + { + try + { + existingJson = File.ReadAllText(configPath); + } + catch (Exception e) + { + McpLog.Warn($"Error reading existing config: {e.Message}."); + } + } + + // Parse the existing JSON while preserving all properties + dynamic existingConfig; + try + { + if (string.IsNullOrWhiteSpace(existingJson)) + { + existingConfig = new JObject(); + } + else + { + existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); + } + } + catch + { + // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object + if (!string.IsNullOrWhiteSpace(existingJson)) + { + McpLog.Warn("UnityMCP: Configuration file could not be parsed; rewriting server block."); + } + existingConfig = new JObject(); + } + + // Determine existing entry references (command/args) + string existingCommand = null; + string[] existingArgs = null; + bool isVSCode = (mcpClient?.IsVsCodeLayout == true); + try + { + if (isVSCode) + { + existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); + } + else + { + existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); + } + } + catch { } + + // 1) Start from existing, only fill gaps (prefer trusted resolver) + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) return "uv package manager not found. Please install uv first."; + + // Ensure containers exist and write back configuration + JObject existingRoot; + if (existingConfig is JObject eo) + existingRoot = eo; + else + existingRoot = JObject.FromObject(existingConfig); + + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvxPath, mcpClient); + + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + EnsureConfigDirectoryExists(configPath); + WriteAtomicFile(configPath, mergedJson); + + return "Configured successfully"; + } + + /// + /// Configures a Codex client with sophisticated TOML handling + /// + public static string ConfigureCodexClient(string configPath, McpClient mcpClient) + { + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + string existingToml = string.Empty; + if (File.Exists(configPath)) + { + try + { + existingToml = File.ReadAllText(configPath); + } + catch (Exception e) + { + McpLog.Warn($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); + existingToml = string.Empty; + } + } + + string existingCommand = null; + string[] existingArgs = null; + if (!string.IsNullOrWhiteSpace(existingToml)) + { + CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); + } + + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + if (uvxPath == null) + { + return "uv package manager not found. Please install uv first."; + } + + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvxPath); + + EnsureConfigDirectoryExists(configPath); + WriteAtomicFile(configPath, updatedToml); + + return "Configured successfully"; + } + + /// + /// Gets the appropriate config file path for the given MCP client based on OS + /// + public static string GetClientConfigPath(McpClient mcpClient) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return mcpClient.windowsConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return mcpClient.linuxConfigPath; + } + else + { + return mcpClient.linuxConfigPath; // fallback + } + } + + /// + /// Creates the directory for the config file if it doesn't exist + /// + public static void EnsureConfigDirectoryExists(string configPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + } + + public static string ExtractUvxUrl(string[] args) + { + if (args == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--from", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + public static bool PathsEqual(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + try + { + string na = Path.GetFullPath(a.Trim()); + string nb = Path.GetFullPath(b.Trim()); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch + { + return false; + } + } + + public static void WriteAtomicFile(string path, string contents) + { + string tmp = path + ".tmp"; + string backup = path + ".backup"; + bool writeDone = false; + try + { + File.WriteAllText(tmp, contents, new UTF8Encoding(false)); + try + { + File.Replace(tmp, path, backup); + writeDone = true; + } + catch (FileNotFoundException) + { + File.Move(tmp, path); + writeDone = true; + } + catch (PlatformNotSupportedException) + { + if (File.Exists(path)) + { + try + { + if (File.Exists(backup)) File.Delete(backup); + } + catch { } + File.Move(path, backup); + } + File.Move(tmp, path); + writeDone = true; + } + } + catch (Exception ex) + { + try + { + if (!writeDone && File.Exists(backup)) + { + try { File.Copy(backup, path, true); } catch { } + } + } + catch { } + throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta b/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta new file mode 100644 index 0000000..0a08ff9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: e45ac2a13b4c1ba468b8e3aa67b292ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs b/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs new file mode 100644 index 0000000..5db093b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility for persisting tool state across domain reloads. State is stored in + /// Library so it stays local to the project and is cleared by Unity as needed. + /// + public static class McpJobStateStore + { + private static string GetStatePath(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + { + throw new ArgumentException("toolName cannot be null or empty", nameof(toolName)); + } + + var libraryPath = Path.Combine(Application.dataPath, "..", "Library"); + var fileName = $"McpState_{toolName}.json"; + return Path.GetFullPath(Path.Combine(libraryPath, fileName)); + } + + public static void SaveState(string toolName, T state) + { + var path = GetStatePath(toolName); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonConvert.SerializeObject(state ?? Activator.CreateInstance()); + File.WriteAllText(path, json); + } + + public static T LoadState(string toolName) + { + var path = GetStatePath(toolName); + if (!File.Exists(path)) + { + return default; + } + + try + { + var json = File.ReadAllText(path); + return JsonConvert.DeserializeObject(json); + } + catch (Exception) + { + return default; + } + } + + public static void ClearState(string toolName) + { + var path = GetStatePath(toolName); + if (File.Exists(path)) + { + File.Delete(path); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta b/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta new file mode 100644 index 0000000..a40d481 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 28912085dd68342f8a9fda8a43c83a59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/McpJobStateStore.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/McpLog.cs b/Assets/MCPForUnity/Editor/Helpers/McpLog.cs new file mode 100644 index 0000000..ca9f771 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpLog.cs @@ -0,0 +1,53 @@ +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string InfoPrefix = "MCP-FOR-UNITY:"; + private const string DebugPrefix = "MCP-FOR-UNITY:"; + private const string WarnPrefix = "MCP-FOR-UNITY:"; + private const string ErrorPrefix = "MCP-FOR-UNITY:"; + + private static volatile bool _debugEnabled = ReadDebugPreference(); + + private static bool IsDebugEnabled() => _debugEnabled; + + private static bool ReadDebugPreference() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + public static void SetDebugLoggingEnabled(bool enabled) + { + _debugEnabled = enabled; + try { EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, enabled); } + catch { } + } + + public static void Debug(string message) + { + if (!IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{DebugPrefix} {message}"); + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + UnityEngine.Debug.Log($"{InfoPrefix} {message}"); + } + + public static void Warn(string message) + { + UnityEngine.Debug.LogWarning($"{WarnPrefix} {message}"); + } + + public static void Error(string message) + { + UnityEngine.Debug.LogError($"{ErrorPrefix} {message}"); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/McpLog.cs.meta b/Assets/MCPForUnity/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 0000000..4d088a8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/McpLog.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs b/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs new file mode 100644 index 0000000..ee35170 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs @@ -0,0 +1,202 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Resolves Unity Objects by instruction (handles GameObjects, Components, Assets). + /// Extracted from ManageGameObject to eliminate cross-tool dependencies. + /// + public static class ObjectResolver + { + /// + /// Resolves any Unity Object by instruction. + /// + /// The type of Unity Object to resolve + /// JObject with "find" (required), "method" (optional), "component" (optional) + /// The resolved object, or null if not found + public static T Resolve(JObject instruction) where T : UnityEngine.Object + { + return Resolve(instruction, typeof(T)) as T; + } + + /// + /// Resolves any Unity Object by instruction. + /// + /// JObject with "find" (required), "method" (optional), "component" (optional) + /// The type of Unity Object to resolve + /// The resolved object, or null if not found + public static UnityEngine.Object Resolve(JObject instruction, Type targetType) + { + if (instruction == null) + return null; + + string findTerm = instruction["find"]?.ToString(); + string method = instruction["method"]?.ToString()?.ToLower(); + string componentName = instruction["component"]?.ToString(); + + if (string.IsNullOrEmpty(findTerm)) + { + McpLog.Warn("[ObjectResolver] Find instruction missing 'find' term."); + return null; + } + + // Use a flexible default search method if none provided + string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; + + // --- Asset Search --- + // Normalize path separators before checking asset paths + string normalizedPath = AssetPathUtility.NormalizeSeparators(findTerm); + + // If the target is an asset type, try AssetDatabase first + if (IsAssetType(targetType) || + (typeof(GameObject).IsAssignableFrom(targetType) && normalizedPath.StartsWith("Assets/"))) + { + UnityEngine.Object asset = TryLoadAsset(normalizedPath, targetType); + if (asset != null) + return asset; + // If still not found, fall through to scene search + } + + // --- Scene Object Search --- + GameObject foundGo = GameObjectLookup.FindByTarget(new JValue(findTerm), searchMethodToUse, includeInactive: false); + + if (foundGo == null) + { + return null; + } + + // Get the target object/component from the found GameObject + if (targetType == typeof(GameObject)) + { + return foundGo; + } + else if (typeof(Component).IsAssignableFrom(targetType)) + { + Type componentToGetType = targetType; + if (!string.IsNullOrEmpty(componentName)) + { + Type specificCompType = GameObjectLookup.FindComponentType(componentName); + if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) + { + componentToGetType = specificCompType; + } + else + { + McpLog.Warn($"[ObjectResolver] Could not find component type '{componentName}'. Falling back to target type '{targetType.Name}'."); + } + } + + Component foundComp = foundGo.GetComponent(componentToGetType); + if (foundComp == null) + { + McpLog.Warn($"[ObjectResolver] Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); + } + return foundComp; + } + else + { + McpLog.Warn($"[ObjectResolver] Find instruction handling not implemented for target type: {targetType.Name}"); + return null; + } + } + + /// + /// Convenience method to resolve a GameObject. + /// + public static GameObject ResolveGameObject(JToken target, string searchMethod = null) + { + if (target == null) + return null; + + // If target is a simple value, use GameObjectLookup directly + if (target.Type != JTokenType.Object) + { + return GameObjectLookup.FindByTarget(target, searchMethod ?? "by_id_or_name_or_path"); + } + + // If target is an instruction object + var instruction = target as JObject; + if (instruction != null) + { + return Resolve(instruction); + } + + return null; + } + + /// + /// Convenience method to resolve a Material. + /// + public static Material ResolveMaterial(string pathOrName) + { + if (string.IsNullOrEmpty(pathOrName)) + return null; + + var instruction = new JObject { ["find"] = pathOrName }; + return Resolve(instruction); + } + + /// + /// Convenience method to resolve a Texture. + /// + public static Texture ResolveTexture(string pathOrName) + { + if (string.IsNullOrEmpty(pathOrName)) + return null; + + var instruction = new JObject { ["find"] = pathOrName }; + return Resolve(instruction); + } + + // --- Private Helpers --- + + private static bool IsAssetType(Type type) + { + return typeof(Material).IsAssignableFrom(type) || + typeof(Texture).IsAssignableFrom(type) || + typeof(ScriptableObject).IsAssignableFrom(type) || + type.FullName?.StartsWith("UnityEngine.U2D") == true || + typeof(AudioClip).IsAssignableFrom(type) || + typeof(AnimationClip).IsAssignableFrom(type) || + typeof(Font).IsAssignableFrom(type) || + typeof(Shader).IsAssignableFrom(type) || + typeof(ComputeShader).IsAssignableFrom(type); + } + + private static UnityEngine.Object TryLoadAsset(string findTerm, Type targetType) + { + // Try loading directly by path first + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); + if (asset != null) + return asset; + + // Try generic load if type-specific failed + asset = AssetDatabase.LoadAssetAtPath(findTerm); + if (asset != null && targetType.IsAssignableFrom(asset.GetType())) + return asset; + + // Try finding by name/type using FindAssets + string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; + string[] guids = AssetDatabase.FindAssets(searchFilter); + + if (guids.Length == 1) + { + asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); + if (asset != null) + return asset; + } + else if (guids.Length > 1) + { + McpLog.Warn($"[ObjectResolver] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); + return null; + } + + return null; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta new file mode 100644 index 0000000..a5767f5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ad678f7b0a2e6458bbdb38a15d857acf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ObjectResolver.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/Pagination.cs b/Assets/MCPForUnity/Editor/Helpers/Pagination.cs new file mode 100644 index 0000000..e1d1387 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/Pagination.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Standard pagination request for all paginated tool operations. + /// Provides consistent handling of page_size/pageSize and cursor/page_number parameters. + /// + public class PaginationRequest + { + /// + /// Number of items per page. Default is 50. + /// + public int PageSize { get; set; } = 50; + + /// + /// 0-based cursor position for the current page. + /// + public int Cursor { get; set; } = 0; + + /// + /// Creates a PaginationRequest from JObject parameters. + /// Accepts both snake_case and camelCase parameter names for flexibility. + /// Converts 1-based page_number to 0-based cursor if needed. + /// + public static PaginationRequest FromParams(JObject @params, int defaultPageSize = 50) + { + if (@params == null) + return new PaginationRequest { PageSize = defaultPageSize }; + + // Accept both page_size and pageSize + int pageSize = ParamCoercion.CoerceInt( + @params["page_size"] ?? @params["pageSize"], + defaultPageSize + ); + + // Accept both cursor (0-based) and page_number (convert 1-based to 0-based) + var cursorToken = @params["cursor"]; + var pageNumberToken = @params["page_number"] ?? @params["pageNumber"]; + + int cursor; + if (cursorToken != null) + { + cursor = ParamCoercion.CoerceInt(cursorToken, 0); + } + else if (pageNumberToken != null) + { + // Convert 1-based page_number to 0-based cursor + int pageNumber = ParamCoercion.CoerceInt(pageNumberToken, 1); + cursor = (pageNumber - 1) * pageSize; + if (cursor < 0) cursor = 0; + } + else + { + cursor = 0; + } + + return new PaginationRequest + { + PageSize = pageSize > 0 ? pageSize : defaultPageSize, + Cursor = cursor + }; + } + } + + /// + /// Standard pagination response for all paginated tool operations. + /// Provides consistent response structure across all tools. + /// + /// The type of items in the paginated list + public class PaginationResponse + { + /// + /// The items on the current page. + /// + [JsonProperty("items")] + public List Items { get; set; } = new List(); + + /// + /// The cursor position for the current page (0-based). + /// + [JsonProperty("cursor")] + public int Cursor { get; set; } + + /// + /// The cursor for the next page, or null if this is the last page. + /// + [JsonProperty("nextCursor")] + public int? NextCursor { get; set; } + + /// + /// Total number of items across all pages. + /// + [JsonProperty("totalCount")] + public int TotalCount { get; set; } + + /// + /// Number of items per page. + /// + [JsonProperty("pageSize")] + public int PageSize { get; set; } + + /// + /// Whether there are more items after this page. + /// + [JsonProperty("hasMore")] + public bool HasMore => NextCursor.HasValue; + + /// + /// Creates a PaginationResponse from a full list of items and pagination parameters. + /// + /// The full list of items to paginate + /// The pagination request parameters + /// A paginated response with the appropriate slice of items + public static PaginationResponse Create(IList allItems, PaginationRequest request) + { + int totalCount = allItems.Count; + int cursor = request.Cursor; + int pageSize = request.PageSize; + + // Clamp cursor to valid range + if (cursor < 0) cursor = 0; + if (cursor > totalCount) cursor = totalCount; + + // Get the page of items + var items = new List(); + int endIndex = System.Math.Min(cursor + pageSize, totalCount); + for (int i = cursor; i < endIndex; i++) + { + items.Add(allItems[i]); + } + + // Calculate next cursor + int? nextCursor = endIndex < totalCount ? endIndex : (int?)null; + + return new PaginationResponse + { + Items = items, + Cursor = cursor, + NextCursor = nextCursor, + TotalCount = totalCount, + PageSize = pageSize + }; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/Pagination.cs.meta b/Assets/MCPForUnity/Editor/Helpers/Pagination.cs.meta new file mode 100644 index 0000000..855a8b3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/Pagination.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 745564d5894d74c0ca24db39c77bab2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/Pagination.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs new file mode 100644 index 0000000..d19d7bf --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -0,0 +1,363 @@ +using System; +using System.Globalization; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for coercing JSON parameter values to strongly-typed values. + /// Handles various input formats (strings, numbers, booleans) gracefully. + /// + public static class ParamCoercion + { + /// + /// Coerces a JToken to an integer value, handling strings and floats. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced integer value or default + public static int CoerceInt(JToken token, int defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) + return i; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (int)d; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable integer value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced integer value or null + public static int? CoerceIntNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i)) + return i; + + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return (int)d; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a boolean value, handling strings like "true", "1", etc. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced boolean value or default + public static bool CoerceBool(JToken token, bool defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Boolean) + return token.Value(); + + var s = token.ToString().Trim().ToLowerInvariant(); + if (s.Length == 0) + return defaultValue; + + if (bool.TryParse(s, out var b)) + return b; + + if (s == "1" || s == "yes" || s == "on") + return true; + + if (s == "0" || s == "no" || s == "off") + return false; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable boolean value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced boolean value or null + public static bool? CoerceBoolNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Boolean) + return token.Value(); + + var s = token.ToString().Trim().ToLowerInvariant(); + if (s.Length == 0) + return null; + + if (bool.TryParse(s, out var b)) + return b; + + if (s == "1" || s == "yes" || s == "on") + return true; + + if (s == "0" || s == "no" || s == "off") + return false; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a float value, handling strings and integers. + /// + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced float value or default + public static float CoerceFloat(JToken token, float defaultValue) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f)) + return f; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Coerces a JToken to a nullable float value. + /// Returns null if token is null, empty, or cannot be parsed. + /// + /// The JSON token to coerce + /// The coerced float value or null + public static float? CoerceFloatNullable(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + return token.Value(); + + var s = token.ToString().Trim(); + if (s.Length == 0) + return null; + + if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f)) + return f; + } + catch + { + // Swallow and return null + } + + return null; + } + + /// + /// Coerces a JToken to a string value, with null handling. + /// + /// The JSON token to coerce + /// Default value if null or empty + /// The string value or default + public static string CoerceString(JToken token, string defaultValue = null) + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + var s = token.ToString(); + return string.IsNullOrEmpty(s) ? defaultValue : s; + } + + /// + /// Coerces a JToken to an enum value, handling strings. + /// + /// The enum type + /// The JSON token to coerce + /// Default value if coercion fails + /// The coerced enum value or default + public static T CoerceEnum(JToken token, T defaultValue) where T : struct, Enum + { + if (token == null || token.Type == JTokenType.Null) + return defaultValue; + + try + { + var s = token.ToString().Trim(); + if (s.Length == 0) + return defaultValue; + + if (Enum.TryParse(s, ignoreCase: true, out var result)) + return result; + } + catch + { + // Swallow and return default + } + + return defaultValue; + } + + /// + /// Checks if a JToken represents a numeric value (integer or float). + /// Useful for validating JSON values before parsing. + /// + /// The JSON token to check + /// True if the token is an integer or float, false otherwise + public static bool IsNumericToken(JToken token) + { + return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float); + } + + /// + /// Validates that an optional field in a JObject is numeric if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or numeric; false if present but non-numeric + public static bool ValidateNumericField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid (will use default) + } + if (!IsNumericToken(token)) + { + error = $"must be a number, got {token.Type}"; + return false; + } + return true; + } + + /// + /// Validates that an optional field in a JObject is an integer if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or integer; false if present but non-integer + public static bool ValidateIntegerField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid + } + if (token.Type != JTokenType.Integer) + { + error = $"must be an integer, got {token.Type}"; + return false; + } + return true; + } + + /// + /// Normalizes a property name by removing separators and converting to camelCase. + /// Handles common naming variations from LLMs and humans. + /// Examples: + /// "Use Gravity" → "useGravity" + /// "is_kinematic" → "isKinematic" + /// "max-angular-velocity" → "maxAngularVelocity" + /// "Angular Drag" → "angularDrag" + /// + /// The property name to normalize + /// The normalized camelCase property name + public static string NormalizePropertyName(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Split on common separators: space, underscore, dash + var parts = input.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return input; + + // First word is lowercase, subsequent words are Title case (camelCase) + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < parts.Length; i++) + { + string part = parts[i]; + if (i == 0) + { + // First word: all lowercase + sb.Append(part.ToLowerInvariant()); + } + else + { + // Subsequent words: capitalize first letter, lowercase rest + sb.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + sb.Append(part.Substring(1).ToLowerInvariant()); + } + } + return sb.ToString(); + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta new file mode 100644 index 0000000..288ef17 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: db54fbbe3ac7f429fbf808f72831374a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ParamCoercion.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/PortManager.cs b/Assets/MCPForUnity/Editor/Helpers/PortManager.cs new file mode 100644 index 0000000..de46fd8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PortManager.cs @@ -0,0 +1,345 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using MCPForUnity.Editor.Constants; +using Newtonsoft.Json; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Manages dynamic port allocation and persistent storage for MCP for Unity + /// + public static class PortManager + { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } + catch { return false; } + } + + private const int DefaultPort = 6400; + private const int MaxPortAttempts = 100; + private const string RegistryFileName = "unity-mcp-port.json"; + + [Serializable] + public class PortConfig + { + public int unity_port; + public string created_date; + public string project_path; + } + + /// + /// Get the port to use from storage, or return the default if none has been saved yet. + /// + /// Port number to use + public static int GetPortWithFallback() + { + var storedConfig = GetStoredPortConfig(); + if (storedConfig != null && + storedConfig.unity_port > 0 && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return storedConfig.unity_port; + } + + return DefaultPort; + } + + /// + /// Discover and save a new available port (used by Auto-Connect button) + /// + /// New available port + public static int DiscoverNewPort() + { + int newPort = FindAvailablePort(); + SavePort(newPort); + if (IsDebugEnabled()) McpLog.Info($"Discovered and saved new port: {newPort}"); + return newPort; + } + + /// + /// Persist a user-selected port and return the value actually stored. + /// If is unavailable, the next available port is chosen instead. + /// + public static int SetPreferredPort(int port) + { + if (port <= 0) + { + throw new ArgumentOutOfRangeException(nameof(port), "Port must be positive."); + } + + if (!IsPortAvailable(port)) + { + throw new InvalidOperationException($"Port {port} is already in use."); + } + + SavePort(port); + return port; + } + + /// + /// Find an available port starting from the default port + /// + /// Available port number + private static int FindAvailablePort() + { + // Always try default port first + if (IsPortAvailable(DefaultPort)) + { + if (IsDebugEnabled()) McpLog.Info($"Using default port {DefaultPort}"); + return DefaultPort; + } + + if (IsDebugEnabled()) McpLog.Info($"Default port {DefaultPort} is in use, searching for alternative..."); + + // Search for alternatives + for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) + { + if (IsPortAvailable(port)) + { + if (IsDebugEnabled()) McpLog.Info($"Found available port {port}"); + return port; + } + } + + throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); + } + + /// + /// Check if a specific port is available for binding + /// + /// Port to check + /// True if port is available + public static bool IsPortAvailable(int port) + { + // Start with quick loopback check + try + { + var testListener = new TcpListener(IPAddress.Loopback, port); + testListener.Start(); + testListener.Stop(); + } + catch (SocketException) + { + return false; + } + +#if UNITY_EDITOR_OSX + // On macOS, the OS might report the port as available (SO_REUSEADDR) even if another process + // is using it, unless we also check active connections or try a stricter bind. + // Double check by trying to Connect to it. If we CAN connect, it's NOT available. + try + { + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + // If we connect successfully, someone is listening -> Not available + if (connectTask.Wait(50) && client.Connected) + { + if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} bind succeeded but connection also succeeded -> Not available (Conflict)."); + return false; + } + } + catch + { + // Connection failed -> likely available (or firewall blocked, but we assume available) + if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} connection failed -> likely available."); + } +#endif + + return true; + } + + /// + /// Check if a port is currently being used by MCP for Unity + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by MCP for Unity + public static bool IsPortUsedByMCPForUnity(int port) + { + try + { + // Try to make a quick connection to see if it's an MCP for Unity server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the MCP for Unity server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by an MCP instance, continue waiting briefly + if (!IsPortUsedByMCPForUnity(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + + /// + /// Save port to persistent storage + /// + /// Port to save + private static void SavePort(int port) + { + try + { + var portConfig = new PortConfig + { + unity_port = port, + created_date = DateTime.UtcNow.ToString("O"), + project_path = Application.dataPath + }; + + string registryDir = GetRegistryDirectory(); + Directory.CreateDirectory(registryDir); + + string registryFile = GetRegistryFilePath(); + string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to hashed, project-scoped file + File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + // Also write to legacy stable filename to avoid hash/case drift across reloads + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); + + if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); + } + catch (Exception ex) + { + McpLog.Warn($"Could not save port to storage: {ex.Message}"); + } + } + + /// + /// Load port from persistent storage + /// + /// Stored port number, or 0 if not found + private static int LoadStoredPort() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file name + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return 0; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + var portConfig = JsonConvert.DeserializeObject(json); + + return portConfig?.unity_port ?? 0; + } + catch (Exception ex) + { + McpLog.Warn($"Could not load port from storage: {ex.Message}"); + return 0; + } + } + + /// + /// Get the current stored port configuration + /// + /// Port configuration if exists, null otherwise + public static PortConfig GetStoredPortConfig() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return null; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + McpLog.Warn($"Could not load port config: {ex.Message}"); + return null; + } + } + + private static string GetRegistryDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + string hash = ComputeProjectHash(Application.dataPath); + string fileName = $"unity-mcp-port-{hash}.json"; + return Path.Combine(dir, fileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/PortManager.cs.meta b/Assets/MCPForUnity/Editor/Helpers/PortManager.cs.meta new file mode 100644 index 0000000..5d51d8d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PortManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 28c39813a10b4331afc764a04089cbef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/PortManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs b/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs new file mode 100644 index 0000000..88397fb --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity Prefab assets. + /// + public static class PrefabUtilityHelper + { + /// + /// Gets the GUID for a prefab asset path. + /// + /// The Unity asset path (e.g., "Assets/Prefabs/MyPrefab.prefab") + /// The GUID string, or null if the path is invalid. + public static string GetPrefabGUID(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + { + return null; + } + + try + { + return AssetDatabase.AssetPathToGUID(assetPath); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get GUID for asset path '{assetPath}': {ex.Message}"); + return null; + } + } + + /// + /// Gets variant information if the prefab is a variant. + /// + /// The prefab GameObject to check. + /// A tuple containing (isVariant, parentPath, parentGuid). + public static (bool isVariant, string parentPath, string parentGuid) GetVariantInfo(GameObject prefabAsset) + { + if (prefabAsset == null) + { + return (false, null, null); + } + + try + { + PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); + if (assetType != PrefabAssetType.Variant) + { + return (false, null, null); + } + + GameObject parentAsset = PrefabUtility.GetCorrespondingObjectFromSource(prefabAsset); + if (parentAsset == null) + { + return (true, null, null); + } + + string parentPath = AssetDatabase.GetAssetPath(parentAsset); + string parentGuid = GetPrefabGUID(parentPath); + + return (true, parentPath, parentGuid); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get variant info for '{prefabAsset.name}': {ex.Message}"); + return (false, null, null); + } + } + + /// + /// Gets the list of component type names on a GameObject. + /// + /// The GameObject to inspect. + /// A list of component type full names. + public static List GetComponentTypeNames(GameObject obj) + { + var typeNames = new List(); + + if (obj == null) + { + return typeNames; + } + + try + { + var components = obj.GetComponents(); + foreach (var component in components) + { + if (component != null) + { + typeNames.Add(component.GetType().FullName); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get component types for '{obj.name}': {ex.Message}"); + } + + return typeNames; + } + + /// + /// Recursively counts all children in the hierarchy. + /// + /// The root transform to count from. + /// Total number of children in the hierarchy. + public static int CountChildrenRecursive(Transform transform) + { + if (transform == null) + { + return 0; + } + + int count = transform.childCount; + for (int i = 0; i < transform.childCount; i++) + { + count += CountChildrenRecursive(transform.GetChild(i)); + } + return count; + } + + /// + /// Gets the source prefab path for a nested prefab instance. + /// + /// The GameObject to check. + /// The asset path of the source prefab, or null if not a nested prefab. + public static string GetNestedPrefabPath(GameObject gameObject) + { + if (gameObject == null || !PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + { + return null; + } + + try + { + var sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(gameObject); + if (sourcePrefab != null) + { + return AssetDatabase.GetAssetPath(sourcePrefab); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to get nested prefab path for '{gameObject.name}': {ex.Message}"); + } + + return null; + } + + /// + /// Gets the nesting depth of a prefab instance within the prefab hierarchy. + /// Returns 0 for main prefab root, 1 for first-level nested, 2 for second-level, etc. + /// Returns -1 for non-prefab-root objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// Nesting depth (0=main root, 1+=nested), or -1 if not a prefab root. + public static int GetPrefabNestingDepth(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null) + return -1; + + // Main prefab root + if (gameObject.transform == mainPrefabRoot) + return 0; + + // Not a prefab instance root + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return -1; + + // Calculate depth by walking up the hierarchy + int depth = 0; + Transform current = gameObject.transform; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + depth++; + } + current = current.parent; + } + + return depth; + } + + /// + /// Gets the parent prefab path for a nested prefab instance. + /// Returns null for main prefab root or non-prefab objects. + /// + /// The GameObject to analyze. + /// The root transform of the main prefab asset. + /// The asset path of the parent prefab, or null if none. + public static string GetParentPrefabPath(GameObject gameObject, Transform mainPrefabRoot) + { + if (gameObject == null || gameObject.transform == mainPrefabRoot) + return null; + + if (!PrefabUtility.IsAnyPrefabInstanceRoot(gameObject)) + return null; + + // Walk up the hierarchy to find the parent prefab instance + Transform current = gameObject.transform.parent; + + while (current != null && current != mainPrefabRoot) + { + if (PrefabUtility.IsAnyPrefabInstanceRoot(current.gameObject)) + { + return GetNestedPrefabPath(current.gameObject); + } + current = current.parent; + } + + // Parent is the main prefab root - get its asset path + if (mainPrefabRoot != null) + { + return AssetDatabase.GetAssetPath(mainPrefabRoot.gameObject); + } + + return null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta b/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta new file mode 100644 index 0000000..38be4c2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ebe2be77e64f4d4f811614b198210017 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/PrefabUtilityHelper.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs b/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs new file mode 100644 index 0000000..34a5391 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides shared utilities for deriving deterministic project identity information + /// used by transport clients (hash, name, persistent session id). + /// + [InitializeOnLoad] + internal static class ProjectIdentityUtility + { + private const string SessionPrefKey = EditorPrefKeys.SessionId; + private static bool _legacyKeyCleared; + private static string _cachedProjectName = "Unknown"; + private static string _cachedProjectHash = "default"; + private static string _fallbackSessionId; + private static bool _cacheScheduled; + + static ProjectIdentityUtility() + { + ScheduleCacheRefresh(); + EditorApplication.projectChanged += ScheduleCacheRefresh; + } + + private static void ScheduleCacheRefresh() + { + if (_cacheScheduled) + { + return; + } + + _cacheScheduled = true; + EditorApplication.delayCall += CacheIdentityOnMainThread; + } + + private static void CacheIdentityOnMainThread() + { + EditorApplication.delayCall -= CacheIdentityOnMainThread; + _cacheScheduled = false; + UpdateIdentityCache(); + } + + private static void UpdateIdentityCache() + { + try + { + string dataPath = Application.dataPath; + if (string.IsNullOrEmpty(dataPath)) + { + return; + } + + _cachedProjectHash = ComputeProjectHash(dataPath); + _cachedProjectName = ComputeProjectName(dataPath); + } + catch + { + // Ignore and keep defaults + } + } + + /// + /// Returns the SHA1 hash of the current project path (truncated to 16 characters). + /// Matches the legacy hash used by the stdio bridge and server registry. + /// + public static string GetProjectHash() + { + EnsureIdentityCache(); + return _cachedProjectHash; + } + + /// + /// Returns a human friendly project name derived from the Assets directory path, + /// or "Unknown" if the name cannot be determined. + /// + public static string GetProjectName() + { + EnsureIdentityCache(); + return _cachedProjectName; + } + + private static string ComputeProjectHash(string dataPath) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(dataPath); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(0, Math.Min(16, sb.Length)).ToLowerInvariant(); + } + catch + { + return "default"; + } + } + + private static string ComputeProjectName(string dataPath) + { + try + { + string projectPath = dataPath; + projectPath = projectPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + projectPath = projectPath[..^6].TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + string name = Path.GetFileName(projectPath); + return string.IsNullOrEmpty(name) ? "Unknown" : name; + } + catch + { + return "Unknown"; + } + } + + /// + /// Persists a server-assigned session id. + /// Safe to call from background threads. + /// + public static void SetSessionId(string sessionId) + { + if (string.IsNullOrEmpty(sessionId)) + { + return; + } + + EditorApplication.delayCall += () => + { + try + { + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to persist session ID: {ex.Message}"); + } + }; + } + + /// + /// Retrieves a persistent session id for the plugin, creating one if absent. + /// The session id is unique per project (scoped by project hash). + /// + public static string GetOrCreateSessionId() + { + try + { + // Make the session ID project-specific by including the project hash in the key + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + string sessionId = EditorPrefs.GetString(projectSpecificKey, string.Empty); + if (string.IsNullOrEmpty(sessionId)) + { + sessionId = Guid.NewGuid().ToString(); + EditorPrefs.SetString(projectSpecificKey, sessionId); + } + return sessionId; + } + catch + { + // If prefs are unavailable (e.g. during batch tests) fall back to runtime guid. + if (string.IsNullOrEmpty(_fallbackSessionId)) + { + _fallbackSessionId = Guid.NewGuid().ToString(); + } + + return _fallbackSessionId; + } + } + + /// + /// Clears the persisted session id (mainly for tests). + /// + public static void ResetSessionId() + { + try + { + // Clear the project-specific session ID + string projectHash = GetProjectHash(); + string projectSpecificKey = $"{SessionPrefKey}_{projectHash}"; + + if (EditorPrefs.HasKey(projectSpecificKey)) + { + EditorPrefs.DeleteKey(projectSpecificKey); + } + + if (!_legacyKeyCleared && EditorPrefs.HasKey(SessionPrefKey)) + { + EditorPrefs.DeleteKey(SessionPrefKey); + _legacyKeyCleared = true; + } + + _fallbackSessionId = null; + } + catch + { + // Ignore + } + } + + private static void EnsureIdentityCache() + { + // When Application.dataPath is unavailable (e.g., batch mode) we fall back to + // hashing the current working directory/Assets path so each project still + // derives a deterministic, per-project session id rather than sharing "default". + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + UpdateIdentityCache(); + + if (!string.IsNullOrEmpty(_cachedProjectHash) && _cachedProjectHash != "default") + { + return; + } + + string fallback = TryComputeFallbackProjectHash(); + if (!string.IsNullOrEmpty(fallback)) + { + _cachedProjectHash = fallback; + } + } + + private static string TryComputeFallbackProjectHash() + { + try + { + string workingDirectory = Directory.GetCurrentDirectory(); + if (string.IsNullOrEmpty(workingDirectory)) + { + return "default"; + } + + // Normalise trailing separators so hashes remain stable + workingDirectory = workingDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return ComputeProjectHash(Path.Combine(workingDirectory, "Assets")); + } + catch + { + return "default"; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta new file mode 100644 index 0000000..a3f95b9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 936e878ce1275453bae5e0cf03bd9d30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ProjectIdentityUtility.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs b/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs new file mode 100644 index 0000000..0e3af0a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs @@ -0,0 +1,93 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified property conversion from JSON to Unity types. + /// Uses UnityJsonSerializer for consistent type handling. + /// + public static class PropertyConversion + { + /// + /// Converts a JToken to the specified target type using Unity type converters. + /// + /// The JSON token to convert + /// The target type to convert to + /// The converted object, or null if conversion fails + public static object ConvertToType(JToken token, Type targetType) + { + if (token == null || token.Type == JTokenType.Null) + { + if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) + { + McpLog.Warn($"[PropertyConversion] Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); + return Activator.CreateInstance(targetType); + } + return null; + } + + try + { + // Use the shared Unity serializer with custom converters + return token.ToObject(targetType, UnityJsonSerializer.Instance); + } + catch (Exception ex) + { + McpLog.Error($"Error converting token to {targetType.FullName}: {ex.Message}\nToken: {token.ToString(Formatting.None)}"); + throw; + } + } + + /// + /// Tries to convert a JToken to the specified target type. + /// Returns null and logs warning on failure (does not throw). + /// + public static object TryConvertToType(JToken token, Type targetType) + { + try + { + return ConvertToType(token, targetType); + } + catch + { + return null; + } + } + + /// + /// Generic version of ConvertToType. + /// + public static T ConvertTo(JToken token) + { + return (T)ConvertToType(token, typeof(T)); + } + + /// + /// Converts a JToken to a Unity asset by loading from path. + /// + /// JToken containing asset path + /// Expected asset type + /// The loaded asset, or null if not found + public static UnityEngine.Object LoadAssetFromToken(JToken token, Type targetType) + { + if (token == null || token.Type != JTokenType.String) + return null; + + string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(assetPath, targetType); + + if (loadedAsset == null) + { + McpLog.Warn($"[PropertyConversion] Could not load asset of type {targetType.Name} from path: {assetPath}"); + } + + return loadedAsset; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta b/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta new file mode 100644 index 0000000..111af3f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4b4187d5b338a453fbe0baceaeea6bcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/PropertyConversion.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs b/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs new file mode 100644 index 0000000..2065d17 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class RenderPipelineUtility + { + internal enum PipelineKind + { + BuiltIn, + Universal, + HighDefinition, + Custom + } + + internal enum VFXComponentType + { + ParticleSystem, + LineRenderer, + TrailRenderer + } + + private static Dictionary s_DefaultVFXMaterials = new Dictionary(); + + private static readonly string[] BuiltInLitShaders = { "Standard", "Legacy Shaders/Diffuse" }; + private static readonly string[] BuiltInUnlitShaders = { "Unlit/Color", "Unlit/Texture" }; + private static readonly string[] UrpLitShaders = { "Universal Render Pipeline/Lit", "Universal Render Pipeline/Simple Lit" }; + private static readonly string[] UrpUnlitShaders = { "Universal Render Pipeline/Unlit" }; + private static readonly string[] HdrpLitShaders = { "HDRP/Lit", "High Definition Render Pipeline/Lit" }; + private static readonly string[] HdrpUnlitShaders = { "HDRP/Unlit", "High Definition Render Pipeline/Unlit" }; + + internal static PipelineKind GetActivePipeline() + { + var asset = GraphicsSettings.currentRenderPipeline; + if (asset == null) + { + return PipelineKind.BuiltIn; + } + + var typeName = asset.GetType().FullName ?? string.Empty; + if (typeName.IndexOf("HighDefinition", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("HDRP", StringComparison.OrdinalIgnoreCase) >= 0) + { + return PipelineKind.HighDefinition; + } + + if (typeName.IndexOf("Universal", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("URP", StringComparison.OrdinalIgnoreCase) >= 0) + { + return PipelineKind.Universal; + } + + return PipelineKind.Custom; + } + + internal static Shader ResolveShader(string requestedNameOrAlias) + { + var pipeline = GetActivePipeline(); + + if (!string.IsNullOrWhiteSpace(requestedNameOrAlias)) + { + var alias = requestedNameOrAlias.Trim(); + var aliasMatch = ResolveAlias(alias, pipeline); + if (aliasMatch != null) + { + WarnIfPipelineMismatch(aliasMatch.name, pipeline); + return aliasMatch; + } + + var direct = Shader.Find(alias); + if (direct != null) + { + WarnIfPipelineMismatch(direct.name, pipeline); + return direct; + } + + McpLog.Warn($"Shader '{alias}' not found. Falling back to {pipeline} defaults."); + } + + var fallback = ResolveDefaultLitShader(pipeline) + ?? ResolveDefaultLitShader(PipelineKind.BuiltIn) + ?? Shader.Find("Unlit/Color"); + + if (fallback != null) + { + WarnIfPipelineMismatch(fallback.name, pipeline); + } + + return fallback; + } + + internal static Shader ResolveDefaultLitShader(PipelineKind pipeline) + { + return pipeline switch + { + PipelineKind.HighDefinition => TryFindShader(HdrpLitShaders) ?? TryFindShader(UrpLitShaders), + PipelineKind.Universal => TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders), + PipelineKind.Custom => TryFindShader(BuiltInLitShaders) ?? TryFindShader(UrpLitShaders) ?? TryFindShader(HdrpLitShaders), + _ => TryFindShader(BuiltInLitShaders) ?? Shader.Find("Unlit/Color") + }; + } + + internal static Shader ResolveDefaultUnlitShader(PipelineKind pipeline) + { + return pipeline switch + { + PipelineKind.HighDefinition => TryFindShader(HdrpUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders), + PipelineKind.Universal => TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders) ?? TryFindShader(BuiltInUnlitShaders), + PipelineKind.Custom => TryFindShader(BuiltInUnlitShaders) ?? TryFindShader(UrpUnlitShaders) ?? TryFindShader(HdrpUnlitShaders), + _ => TryFindShader(BuiltInUnlitShaders) + }; + } + + private static Shader ResolveAlias(string alias, PipelineKind pipeline) + { + if (string.Equals(alias, "lit", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "default", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "default_lit", StringComparison.OrdinalIgnoreCase) || + string.Equals(alias, "standard", StringComparison.OrdinalIgnoreCase)) + { + return ResolveDefaultLitShader(pipeline); + } + + if (string.Equals(alias, "unlit", StringComparison.OrdinalIgnoreCase)) + { + return ResolveDefaultUnlitShader(pipeline); + } + + if (string.Equals(alias, "urp_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(UrpLitShaders); + } + + if (string.Equals(alias, "hdrp_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(HdrpLitShaders); + } + + if (string.Equals(alias, "built_in_lit", StringComparison.OrdinalIgnoreCase)) + { + return TryFindShader(BuiltInLitShaders); + } + + return null; + } + + private static Shader TryFindShader(params string[] shaderNames) + { + foreach (var shaderName in shaderNames) + { + var shader = Shader.Find(shaderName); + if (shader != null) + { + return shader; + } + } + return null; + } + + private static void WarnIfPipelineMismatch(string shaderName, PipelineKind activePipeline) + { + if (string.IsNullOrEmpty(shaderName)) + { + return; + } + + var lowerName = shaderName.ToLowerInvariant(); + bool shaderLooksUrp = lowerName.Contains("universal render pipeline") || lowerName.Contains("urp/"); + bool shaderLooksHdrp = lowerName.Contains("high definition render pipeline") || lowerName.Contains("hdrp/"); + bool shaderLooksBuiltin = lowerName.Contains("standard") || lowerName.Contains("legacy shaders/"); + bool shaderLooksSrp = shaderLooksUrp || shaderLooksHdrp; + + switch (activePipeline) + { + case PipelineKind.HighDefinition: + if (shaderLooksUrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks URP-based. Asset may appear incorrect."); + } + else if (shaderLooksBuiltin && !shaderLooksHdrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is HDRP but shader '{shaderName}' looks Built-in. Consider using an HDRP shader for correct results."); + } + break; + case PipelineKind.Universal: + if (shaderLooksHdrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks HDRP-based. Asset may appear incorrect."); + } + else if (shaderLooksBuiltin && !shaderLooksUrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is URP but shader '{shaderName}' looks Built-in. Consider using a URP shader for correct results."); + } + break; + case PipelineKind.BuiltIn: + if (shaderLooksSrp) + { + McpLog.Warn($"[RenderPipelineUtility] Active pipeline is Built-in but shader '{shaderName}' targets URP/HDRP. Asset may not render as expected."); + } + break; + } + } + + internal static Material GetOrCreateDefaultVFXMaterial(VFXComponentType componentType) + { + var pipeline = GetActivePipeline(); + string cacheKey = $"{pipeline}_{componentType}"; + + if (s_DefaultVFXMaterials.TryGetValue(cacheKey, out Material cachedMaterial) && cachedMaterial != null) + { + return cachedMaterial; + } + + Material material = null; + + if (pipeline == PipelineKind.BuiltIn) + { + string builtinPath = componentType == VFXComponentType.ParticleSystem + ? "Default-Particle.mat" + : "Default-Line.mat"; + + material = AssetDatabase.GetBuiltinExtraResource(builtinPath); + } + + if (material == null) + { + Shader shader = ResolveDefaultUnlitShader(pipeline); + if (shader == null) + { + shader = Shader.Find("Unlit/Color"); + } + + if (shader != null) + { + material = new Material(shader); + material.name = $"Auto_Default_{componentType}_{pipeline}"; + + // Set default color (white is standard for VFX) + if (material.HasProperty("_Color")) + { + material.SetColor("_Color", Color.white); + } + if (material.HasProperty("_BaseColor")) + { + material.SetColor("_BaseColor", Color.white); + } + + if (componentType == VFXComponentType.ParticleSystem) + { + material.renderQueue = 3000; + if (material.HasProperty("_Mode")) + { + material.SetFloat("_Mode", 2); + } + if (material.HasProperty("_SrcBlend")) + { + material.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + } + if (material.HasProperty("_DstBlend")) + { + material.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + } + if (material.HasProperty("_ZWrite")) + { + material.SetFloat("_ZWrite", 0); + } + } + + McpLog.Info($"[RenderPipelineUtility] Created default VFX material for {componentType} using {shader.name}"); + } + } + + if (material != null) + { + s_DefaultVFXMaterials[cacheKey] = material; + } + + return material; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta b/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta new file mode 100644 index 0000000..8405344 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5a0a1cfd55ab4bc99c74c52854f6bdf3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/RenderPipelineUtility.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs b/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs new file mode 100644 index 0000000..07e39a5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for common Renderer property operations. + /// Used by ManageVFX for ParticleSystem, LineRenderer, and TrailRenderer components. + /// + public static class RendererHelpers + { + /// + /// Ensures a renderer has a material assigned. If not, auto-assigns a default material + /// based on the render pipeline and component type. + /// + /// The renderer to check + public static void EnsureMaterial(Renderer renderer) + { + if (renderer == null || renderer.sharedMaterial != null) + { + return; + } + + RenderPipelineUtility.VFXComponentType? componentType = null; + if (renderer is ParticleSystemRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.ParticleSystem; + } + else if (renderer is LineRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.LineRenderer; + } + else if (renderer is TrailRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.TrailRenderer; + } + + if (componentType.HasValue) + { + Material defaultMat = RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(componentType.Value); + if (defaultMat != null) + { + Undo.RecordObject(renderer, "Assign default VFX material"); + EditorUtility.SetDirty(renderer); + renderer.sharedMaterial = defaultMat; + } + } + } + + /// + /// Applies common Renderer properties (shadows, lighting, probes, sorting, rendering layer). + /// Used by ParticleSetRenderer, LineSetProperties, TrailSetProperties. + /// + public static void ApplyCommonRendererProperties(Renderer renderer, JObject @params, List changes) + { + // Shadows + if (@params["shadowCastingMode"] != null && Enum.TryParse(@params["shadowCastingMode"].ToString(), true, out var shadowMode)) + { renderer.shadowCastingMode = shadowMode; changes.Add("shadowCastingMode"); } + if (@params["receiveShadows"] != null) { renderer.receiveShadows = @params["receiveShadows"].ToObject(); changes.Add("receiveShadows"); } + // Note: shadowBias is only available on specific renderer types (e.g., ParticleSystemRenderer), not base Renderer + + // Lighting and probes + if (@params["lightProbeUsage"] != null && Enum.TryParse(@params["lightProbeUsage"].ToString(), true, out var probeUsage)) + { renderer.lightProbeUsage = probeUsage; changes.Add("lightProbeUsage"); } + if (@params["reflectionProbeUsage"] != null && Enum.TryParse(@params["reflectionProbeUsage"].ToString(), true, out var reflectionUsage)) + { renderer.reflectionProbeUsage = reflectionUsage; changes.Add("reflectionProbeUsage"); } + + // Motion vectors + if (@params["motionVectorGenerationMode"] != null && Enum.TryParse(@params["motionVectorGenerationMode"].ToString(), true, out var motionMode)) + { renderer.motionVectorGenerationMode = motionMode; changes.Add("motionVectorGenerationMode"); } + + // Sorting + if (@params["sortingOrder"] != null) { renderer.sortingOrder = @params["sortingOrder"].ToObject(); changes.Add("sortingOrder"); } + if (@params["sortingLayerName"] != null) { renderer.sortingLayerName = @params["sortingLayerName"].ToString(); changes.Add("sortingLayerName"); } + if (@params["sortingLayerID"] != null) { renderer.sortingLayerID = @params["sortingLayerID"].ToObject(); changes.Add("sortingLayerID"); } + + // Rendering layer mask (for SRP) + if (@params["renderingLayerMask"] != null) { renderer.renderingLayerMask = @params["renderingLayerMask"].ToObject(); changes.Add("renderingLayerMask"); } + } + + /// + /// Gets common Renderer properties for GetInfo methods. + /// + public static object GetCommonRendererInfo(Renderer renderer) + { + return new + { + shadowCastingMode = renderer.shadowCastingMode.ToString(), + receiveShadows = renderer.receiveShadows, + lightProbeUsage = renderer.lightProbeUsage.ToString(), + reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(), + sortingOrder = renderer.sortingOrder, + sortingLayerName = renderer.sortingLayerName, + renderingLayerMask = renderer.renderingLayerMask + }; + } + + + /// + /// Sets width properties for LineRenderer or TrailRenderer. + /// + /// JSON parameters containing width, startWidth, endWidth, widthCurve, widthMultiplier + /// List to track changed properties + /// Action to set start width + /// Action to set end width + /// Action to set width curve + /// Action to set width multiplier + /// Function to parse animation curve from JToken + public static void ApplyWidthProperties(JObject @params, List changes, + Action setStartWidth, Action setEndWidth, + Action setWidthCurve, Action setWidthMultiplier, + Func parseAnimationCurve) + { + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + setStartWidth(w); + setEndWidth(w); + changes.Add("width"); + } + if (@params["startWidth"] != null) { setStartWidth(@params["startWidth"].ToObject()); changes.Add("startWidth"); } + if (@params["endWidth"] != null) { setEndWidth(@params["endWidth"].ToObject()); changes.Add("endWidth"); } + if (@params["widthCurve"] != null) { setWidthCurve(parseAnimationCurve(@params["widthCurve"], 1f)); changes.Add("widthCurve"); } + if (@params["widthMultiplier"] != null) { setWidthMultiplier(@params["widthMultiplier"].ToObject()); changes.Add("widthMultiplier"); } + } + + /// + /// Sets color properties for LineRenderer or TrailRenderer. + /// + /// JSON parameters containing color, startColor, endColor, gradient + /// List to track changed properties + /// Action to set start color + /// Action to set end color + /// Action to set gradient + /// Function to parse color from JToken + /// Function to parse gradient from JToken + /// If true, sets end color alpha to 0 when using single color + public static void ApplyColorProperties(JObject @params, List changes, + Action setStartColor, Action setEndColor, + Action setGradient, + Func parseColor, Func parseGradient, + bool fadeEndAlpha = false) + { + if (@params["color"] != null) + { + Color c = parseColor(@params["color"]); + setStartColor(c); + setEndColor(fadeEndAlpha ? new Color(c.r, c.g, c.b, 0f) : c); + changes.Add("color"); + } + if (@params["startColor"] != null) { setStartColor(parseColor(@params["startColor"])); changes.Add("startColor"); } + if (@params["endColor"] != null) { setEndColor(parseColor(@params["endColor"])); changes.Add("endColor"); } + if (@params["gradient"] != null) { setGradient(parseGradient(@params["gradient"])); changes.Add("gradient"); } + } + + + /// + /// Sets material for a Renderer. + /// + /// The renderer to set material on + /// JSON parameters containing materialPath + /// Name for the undo operation + /// Function to find material by path + /// If true, auto-assigns default material when materialPath is not provided + public static object SetRendererMaterial(Renderer renderer, JObject @params, string undoName, Func findMaterial, bool autoAssignDefault = true) + { + if (renderer == null) return new { success = false, message = "Renderer not found" }; + + string path = @params["materialPath"]?.ToString(); + + if (string.IsNullOrEmpty(path)) + { + if (!autoAssignDefault) + { + return new { success = false, message = "materialPath required" }; + } + + RenderPipelineUtility.VFXComponentType? componentType = null; + if (renderer is ParticleSystemRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.ParticleSystem; + } + else if (renderer is LineRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.LineRenderer; + } + else if (renderer is TrailRenderer) + { + componentType = RenderPipelineUtility.VFXComponentType.TrailRenderer; + } + + if (componentType.HasValue) + { + Material defaultMat = RenderPipelineUtility.GetOrCreateDefaultVFXMaterial(componentType.Value); + if (defaultMat != null) + { + Undo.RecordObject(renderer, undoName); + renderer.sharedMaterial = defaultMat; + EditorUtility.SetDirty(renderer); + return new { success = true, message = $"Auto-assigned default material: {defaultMat.name}" }; + } + } + + return new { success = false, message = "materialPath required" }; + } + + Material mat = findMaterial(path); + if (mat == null) return new { success = false, message = $"Material not found: {path}" }; + + Undo.RecordObject(renderer, undoName); + renderer.sharedMaterial = mat; + EditorUtility.SetDirty(renderer); + + return new { success = true, message = $"Set material to {mat.name}" }; + } + + + /// + /// Applies Line/Trail specific properties (loop, alignment, textureMode, etc.). + /// + public static void ApplyLineTrailProperties(JObject @params, List changes, + Action setLoop, Action setUseWorldSpace, + Action setNumCornerVertices, Action setNumCapVertices, + Action setAlignment, Action setTextureMode, + Action setGenerateLightingData) + { + if (@params["loop"] != null && setLoop != null) { setLoop(@params["loop"].ToObject()); changes.Add("loop"); } + if (@params["useWorldSpace"] != null && setUseWorldSpace != null) { setUseWorldSpace(@params["useWorldSpace"].ToObject()); changes.Add("useWorldSpace"); } + if (@params["numCornerVertices"] != null && setNumCornerVertices != null) { setNumCornerVertices(@params["numCornerVertices"].ToObject()); changes.Add("numCornerVertices"); } + if (@params["numCapVertices"] != null && setNumCapVertices != null) { setNumCapVertices(@params["numCapVertices"].ToObject()); changes.Add("numCapVertices"); } + if (@params["alignment"] != null && setAlignment != null && Enum.TryParse(@params["alignment"].ToString(), true, out var align)) { setAlignment(align); changes.Add("alignment"); } + if (@params["textureMode"] != null && setTextureMode != null && Enum.TryParse(@params["textureMode"].ToString(), true, out var texMode)) { setTextureMode(texMode); changes.Add("textureMode"); } + if (@params["generateLightingData"] != null && setGenerateLightingData != null) { setGenerateLightingData(@params["generateLightingData"].ToObject()); changes.Add("generateLightingData"); } + } + + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta b/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta new file mode 100644 index 0000000..a403ee9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8f3a7e2d5c1b4a9e6d0f8c3b2a1e5d7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/RendererHelpers.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/Response.cs b/Assets/MCPForUnity/Editor/Helpers/Response.cs new file mode 100644 index 0000000..6c6c299 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/Response.cs @@ -0,0 +1,108 @@ +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Helpers +{ + public interface IMcpResponse + { + [JsonProperty("success")] + bool Success { get; } + } + + public sealed class SuccessResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("message")] + public string Message { get; } + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public SuccessResponse(string message, object data = null) + { + Message = message; + Data = data; + } + } + + public sealed class ErrorResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => false; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] + public string Code { get; } + + [JsonIgnore] + public string code => Code; + + [JsonProperty("error")] + public string Error { get; } + + [JsonIgnore] + public string error => Error; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public ErrorResponse(string messageOrCode, object data = null) + { + Code = messageOrCode; + Error = messageOrCode; + Data = data; + } + } + + public sealed class PendingResponse : IMcpResponse + { + [JsonProperty("success")] + public bool Success => true; + + [JsonIgnore] + public bool success => Success; // Backward-compatible casing for reflection-based tests + + [JsonProperty("_mcp_status")] + public string Status => "pending"; + + [JsonIgnore] + public string _mcp_status => Status; + + [JsonProperty("_mcp_poll_interval")] + public double PollIntervalSeconds { get; } + + [JsonIgnore] + public double _mcp_poll_interval => PollIntervalSeconds; + + [JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; } + + [JsonIgnore] + public string message => Message; + + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public object Data { get; } + + [JsonIgnore] + public object data => Data; + + public PendingResponse(string message = "", double pollIntervalSeconds = 1.0, object data = null) + { + Message = string.IsNullOrEmpty(message) ? null : message; + PollIntervalSeconds = pollIntervalSeconds; + Data = data; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/Response.cs.meta b/Assets/MCPForUnity/Editor/Helpers/Response.cs.meta new file mode 100644 index 0000000..4f5c61d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/Response.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 80c09a76b944f8c4691e06c4d76c4be8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/Response.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs b/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs new file mode 100644 index 0000000..04b498a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for converting between naming conventions (snake_case, camelCase). + /// Consolidates previously duplicated implementations from ToolParams, ManageVFX, + /// BatchExecute, CommandRegistry, and ToolDiscoveryService. + /// + public static class StringCaseUtility + { + /// + /// Checks whether a type belongs to the built-in MCP for Unity package. + /// Returns true when the type's namespace starts with + /// or its assembly is MCPForUnity.Editor. + /// + public static bool IsBuiltInMcpType(Type type, string assemblyName, string builtInNamespacePrefix) + { + if (type != null && !string.IsNullOrEmpty(type.Namespace) + && type.Namespace.StartsWith(builtInNamespacePrefix, StringComparison.Ordinal)) + { + return true; + } + + if (!string.IsNullOrEmpty(assemblyName) + && assemblyName.Equals("MCPForUnity.Editor", StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + /// + /// Converts a camelCase string to snake_case. + /// Example: "searchMethod" -> "search_method", "param1Value" -> "param1_value" + /// + /// The camelCase string to convert + /// The snake_case equivalent, or original string if null/empty + public static string ToSnakeCase(string str) + { + if (string.IsNullOrEmpty(str)) + return str; + + return Regex.Replace(str, "([a-z0-9])([A-Z])", "$1_$2").ToLowerInvariant(); + } + + /// + /// Converts a snake_case string to camelCase. + /// Example: "search_method" -> "searchMethod" + /// + /// The snake_case string to convert + /// The camelCase equivalent, or original string if null/empty or no underscores + public static string ToCamelCase(string str) + { + if (string.IsNullOrEmpty(str) || !str.Contains("_")) + return str; + + var parts = str.Split('_'); + if (parts.Length == 0) + return str; + + // First part stays lowercase, rest get capitalized + var first = parts[0]; + var rest = string.Concat(parts.Skip(1).Select(part => + string.IsNullOrEmpty(part) ? "" : char.ToUpperInvariant(part[0]) + part.Substring(1))); + + return first + rest; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta b/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta new file mode 100644 index 0000000..25b1b30 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f22b312318ade42c4bb6b5dfddacecfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/StringCaseUtility.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs b/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs new file mode 100644 index 0000000..6e092d4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unity Bridge telemetry helper for collecting usage analytics + /// Following privacy-first approach with easy opt-out mechanisms + /// + public static class TelemetryHelper + { + private const string TELEMETRY_DISABLED_KEY = EditorPrefKeys.TelemetryDisabled; + private const string CUSTOMER_UUID_KEY = EditorPrefKeys.CustomerUuid; + private static Action> s_sender; + + /// + /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) + /// + public static bool IsEnabled + { + get + { + // Check environment variables first + var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(envDisable) && + (envDisable.ToLower() == "true" || envDisable == "1")) + { + return false; + } + + var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(unityMcpDisable) && + (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) + { + return false; + } + + // Honor protocol-wide opt-out as well + var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(mcpDisable) && + (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) + { + return false; + } + + // Check EditorPrefs + return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); + } + } + + /// + /// Get or generate customer UUID for anonymous tracking + /// + public static string GetCustomerUUID() + { + var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); + if (string.IsNullOrEmpty(uuid)) + { + uuid = System.Guid.NewGuid().ToString(); + UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); + } + return uuid; + } + + /// + /// Disable telemetry (stored in EditorPrefs) + /// + public static void DisableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); + } + + /// + /// Enable telemetry (stored in EditorPrefs) + /// + public static void EnableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); + } + + /// + /// Send telemetry data to MCP server for processing + /// This is a lightweight bridge - the actual telemetry logic is in the MCP server + /// + public static void RecordEvent(string eventType, Dictionary data = null) + { + if (!IsEnabled) + return; + + try + { + var telemetryData = new Dictionary + { + ["event_type"] = eventType, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ["customer_uuid"] = GetCustomerUUID(), + ["unity_version"] = Application.unityVersion, + ["platform"] = Application.platform.ToString(), + ["source"] = "unity_bridge" + }; + + if (data != null) + { + telemetryData["data"] = data; + } + + // Send to MCP server via existing bridge communication + // The MCP server will handle actual telemetry transmission + SendTelemetryToMcpServer(telemetryData); + } + catch (Exception e) + { + // Never let telemetry errors interfere with functionality + if (IsDebugEnabled()) + { + McpLog.Warn($"Telemetry error (non-blocking): {e.Message}"); + } + } + } + + /// + /// Allows the bridge to register a concrete sender for telemetry payloads. + /// + public static void RegisterTelemetrySender(Action> sender) + { + Interlocked.Exchange(ref s_sender, sender); + } + + public static void UnregisterTelemetrySender() + { + Interlocked.Exchange(ref s_sender, null); + } + + /// + /// Record bridge startup event + /// + public static void RecordBridgeStartup() + { + RecordEvent("bridge_startup", new Dictionary + { + ["bridge_version"] = AssetPathUtility.GetPackageVersion(), + ["auto_connect"] = StdioBridgeHost.IsAutoConnectMode() + }); + } + + /// + /// Record bridge connection event + /// + public static void RecordBridgeConnection(bool success, string error = null) + { + var data = new Dictionary + { + ["success"] = success + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("bridge_connection", data); + } + + /// + /// Record tool execution from Unity side + /// + public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) + { + var data = new Dictionary + { + ["tool_name"] = toolName, + ["success"] = success, + ["duration_ms"] = Math.Round(durationMs, 2) + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("tool_execution_unity", data); + } + + private static void SendTelemetryToMcpServer(Dictionary telemetryData) + { + var sender = Volatile.Read(ref s_sender); + if (sender != null) + { + try + { + sender(telemetryData); + return; + } + catch (Exception e) + { + if (IsDebugEnabled()) + { + McpLog.Warn($"Telemetry sender error (non-blocking): {e.Message}"); + } + } + } + + // Fallback: log when debug is enabled + if (IsDebugEnabled()) + { + McpLog.Info($"Telemetry: {telemetryData["event_type"]}"); + } + } + + private static bool IsDebugEnabled() + { + try + { + return UnityEditor.EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + } + catch + { + return false; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta b/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta new file mode 100644 index 0000000..6789274 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/TelemetryHelper.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs b/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs new file mode 100644 index 0000000..b062b47 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class TextureOps + { + public static byte[] EncodeTexture(Texture2D texture, string assetPath) + { + if (texture == null) + return null; + + string extension = Path.GetExtension(assetPath); + if (string.IsNullOrEmpty(extension)) + { + McpLog.Warn($"[TextureOps] No file extension for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + + switch (extension.ToLowerInvariant()) + { + case ".png": + return texture.EncodeToPNG(); + case ".jpg": + case ".jpeg": + return texture.EncodeToJPG(); + default: + McpLog.Warn($"[TextureOps] Unsupported extension '{extension}' for '{assetPath}', defaulting to PNG."); + return texture.EncodeToPNG(); + } + } + + public static void FillTexture(Texture2D texture, Color32 color) + { + if (texture == null) + return; + + Color32[] pixels = new Color32[texture.width * texture.height]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = color; + } + texture.SetPixels32(pixels); + } + + public static Color32 ParseColor32(JArray colorArray) + { + if (colorArray == null || colorArray.Count < 3) + return new Color32(255, 255, 255, 255); + + byte r = (byte)Mathf.Clamp(colorArray[0].ToObject(), 0, 255); + byte g = (byte)Mathf.Clamp(colorArray[1].ToObject(), 0, 255); + byte b = (byte)Mathf.Clamp(colorArray[2].ToObject(), 0, 255); + byte a = colorArray.Count > 3 ? (byte)Mathf.Clamp(colorArray[3].ToObject(), 0, 255) : (byte)255; + + return new Color32(r, g, b, a); + } + + public static List ParsePalette(JArray paletteArray) + { + if (paletteArray == null) + return null; + + List palette = new List(); + foreach (var item in paletteArray) + { + if (item is JArray colorArray) + { + palette.Add(ParseColor32(colorArray)); + } + } + return palette.Count > 0 ? palette : null; + } + + public static void ApplyPixelData(Texture2D texture, JToken pixelsToken, int width, int height) + { + ApplyPixelDataToRegion(texture, pixelsToken, 0, 0, width, height); + } + + public static void ApplyPixelDataToRegion(Texture2D texture, JToken pixelsToken, int offsetX, int offsetY, int regionWidth, int regionHeight) + { + if (texture == null || pixelsToken == null) + return; + + int textureWidth = texture.width; + int textureHeight = texture.height; + + if (pixelsToken is JArray pixelArray) + { + int index = 0; + for (int y = 0; y < regionHeight && index < pixelArray.Count; y++) + { + for (int x = 0; x < regionWidth && index < pixelArray.Count; x++) + { + var pixelColor = pixelArray[index] as JArray; + if (pixelColor != null) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + texture.SetPixel(px, py, ParseColor32(pixelColor)); + } + } + index++; + } + } + + int expectedCount = regionWidth * regionHeight; + if (pixelArray.Count != expectedCount) + { + McpLog.Warn($"[TextureOps] Pixel array size mismatch: expected {expectedCount} entries, got {pixelArray.Count}"); + } + } + else if (pixelsToken.Type == JTokenType.String) + { + string pixelString = pixelsToken.ToString(); + string base64 = pixelString.StartsWith("base64:") ? pixelString.Substring(7) : pixelString; + if (!pixelString.StartsWith("base64:")) + { + McpLog.Warn("[TextureOps] Base64 pixel data missing 'base64:' prefix; attempting to decode."); + } + + byte[] rawData = Convert.FromBase64String(base64); + + // Assume RGBA32 format: 4 bytes per pixel + int expectedBytes = regionWidth * regionHeight * 4; + if (rawData.Length == expectedBytes) + { + int pixelIndex = 0; + for (int y = 0; y < regionHeight; y++) + { + for (int x = 0; x < regionWidth; x++) + { + int px = offsetX + x; + int py = offsetY + y; + if (px >= 0 && px < textureWidth && py >= 0 && py < textureHeight) + { + int byteIndex = pixelIndex * 4; + Color32 color = new Color32( + rawData[byteIndex], + rawData[byteIndex + 1], + rawData[byteIndex + 2], + rawData[byteIndex + 3] + ); + texture.SetPixel(px, py, color); + } + pixelIndex++; + } + } + } + else + { + McpLog.Warn($"[TextureOps] Base64 data size mismatch: expected {expectedBytes} bytes, got {rawData.Length}"); + } + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs.meta b/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs.meta new file mode 100644 index 0000000..b5269a6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/TextureOps.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 864ea682d797466a84b6b951f6c4e4ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/TextureOps.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs b/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs new file mode 100644 index 0000000..681ae48 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs @@ -0,0 +1,179 @@ +using Newtonsoft.Json.Linq; +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified parameter validation and extraction wrapper for MCP tools. + /// Eliminates repetitive IsNullOrEmpty checks and provides consistent error messages. + /// + public class ToolParams + { + private readonly JObject _params; + + public ToolParams(JObject @params) + { + _params = @params ?? throw new ArgumentNullException(nameof(@params)); + } + + /// + /// Get required string parameter. Returns ErrorResponse if missing or empty. + /// + public Result GetRequired(string key, string errorMessage = null) + { + var value = GetString(key); + if (string.IsNullOrEmpty(value)) + { + return Result.Error( + errorMessage ?? $"'{key}' parameter is required." + ); + } + return Result.Success(value); + } + + /// + /// Get optional string parameter with default value. + /// Supports both snake_case and camelCase automatically. + /// + public string Get(string key, string defaultValue = null) + { + return GetString(key) ?? defaultValue; + } + + /// + /// Get optional int parameter. + /// + public int? GetInt(string key, int? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return int.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Get optional bool parameter. + /// Supports both snake_case and camelCase automatically. + /// + public bool GetBool(string key, bool defaultValue = false) + { + return ParamCoercion.CoerceBool(GetToken(key), defaultValue); + } + + /// + /// Get optional float parameter. + /// + public float? GetFloat(string key, float? defaultValue = null) + { + var str = GetString(key); + if (string.IsNullOrEmpty(str)) return defaultValue; + return float.TryParse(str, out var result) ? result : defaultValue; + } + + /// + /// Check if parameter exists (even if null). + /// Supports both snake_case and camelCase automatically. + /// + public bool Has(string key) + { + return GetToken(key) != null; + } + + /// + /// Get raw JToken for complex types. + /// Supports both snake_case and camelCase automatically. + /// + public JToken GetRaw(string key) + { + return GetToken(key); + } + + /// + /// Get raw JToken with snake_case/camelCase fallback. + /// + private JToken GetToken(string key) + { + // Try exact match first + var token = _params[key]; + if (token != null) return token; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + token = _params[snakeKey]; + if (token != null) return token; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + token = _params[camelKey]; + } + + return token; + } + + private string GetString(string key) + { + // Try exact match first + var value = _params[key]?.ToString(); + if (value != null) return value; + + // Try snake_case if camelCase was provided + var snakeKey = ToSnakeCase(key); + if (snakeKey != key) + { + value = _params[snakeKey]?.ToString(); + if (value != null) return value; + } + + // Try camelCase if snake_case was provided + var camelKey = ToCamelCase(key); + if (camelKey != key) + { + value = _params[camelKey]?.ToString(); + } + + return value; + } + + private static string ToSnakeCase(string str) => StringCaseUtility.ToSnakeCase(str); + + private static string ToCamelCase(string str) => StringCaseUtility.ToCamelCase(str); + } + + /// + /// Result type for operations that can fail with an error message. + /// + public class Result + { + public bool IsSuccess { get; } + public T Value { get; } + public string ErrorMessage { get; } + + private Result(bool isSuccess, T value, string errorMessage) + { + IsSuccess = isSuccess; + Value = value; + ErrorMessage = errorMessage; + } + + public static Result Success(T value) => new Result(true, value, null); + public static Result Error(string errorMessage) => new Result(false, default, errorMessage); + + /// + /// Get value or return ErrorResponse. + /// + public object GetOrError(out T value) + { + if (IsSuccess) + { + value = Value; + return null; + } + value = default; + return new ErrorResponse(ErrorMessage); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs.meta b/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs.meta new file mode 100644 index 0000000..4a13d15 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/ToolParams.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 404b09ea3e2714e1babd16f5705ac788 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/ToolParams.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs b/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs new file mode 100644 index 0000000..83b650f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using MCPForUnity.Runtime.Serialization; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared JsonSerializer with Unity type converters. + /// Extracted from ManageGameObject to eliminate cross-tool dependencies. + /// + public static class UnityJsonSerializer + { + /// + /// Shared JsonSerializer instance with converters for Unity types. + /// Use this for all JToken-to-Unity-type conversions. + /// + public static readonly JsonSerializer Instance = JsonSerializer.Create(new JsonSerializerSettings + { + Converters = new List + { + new Vector2Converter(), + new Vector3Converter(), + new Vector4Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() + } + }); + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta b/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta new file mode 100644 index 0000000..48c23f4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 24d94c9c030bd4ff1ab208c748f26b01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/UnityJsonSerializer.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs b/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs new file mode 100644 index 0000000..feb0b5c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +using UnityEditor.Compilation; +#endif + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unified type resolution for Unity types (Components, ScriptableObjects, etc.). + /// Extracted from ComponentResolver in ManageGameObject and ResolveType in ManageScriptableObject. + /// Features: caching, prioritizes Player assemblies over Editor assemblies, uses TypeCache. + /// + public static class UnityTypeResolver + { + private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); + private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); + + /// + /// Resolves a type by name, with optional base type constraint. + /// Caches results for performance. Prefers runtime assemblies over Editor assemblies. + /// + /// The short name or fully-qualified name of the type + /// The resolved type, or null if not found + /// Error message if resolution failed + /// Optional base type constraint (e.g., typeof(Component)) + /// True if type was resolved successfully + public static bool TryResolve(string typeName, out Type type, out string error, Type requiredBaseType = null) + { + error = string.Empty; + type = null; + + if (string.IsNullOrWhiteSpace(typeName)) + { + error = "Type name cannot be null or empty"; + return false; + } + + // Check caches + if (CacheByFqn.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType)) + return true; + if (!typeName.Contains(".") && CacheByName.TryGetValue(typeName, out type) && PassesConstraint(type, requiredBaseType)) + return true; + + // Try direct Type.GetType + type = Type.GetType(typeName, throwOnError: false); + if (type != null && PassesConstraint(type, requiredBaseType)) + { + Cache(type); + return true; + } + + // Search loaded assemblies (prefer Player assemblies) + var candidates = FindCandidates(typeName, requiredBaseType); + if (candidates.Count == 1) + { + type = candidates[0]; + Cache(type); + return true; + } + if (candidates.Count > 1) + { + error = FormatAmbiguityError(typeName, candidates); + type = null; + return false; + } + +#if UNITY_EDITOR + // Last resort: TypeCache (fast index) + if (requiredBaseType != null) + { + var tc = TypeCache.GetTypesDerivedFrom(requiredBaseType) + .Where(t => NamesMatch(t, typeName)); + candidates = PreferPlayer(tc).ToList(); + if (candidates.Count == 1) + { + type = candidates[0]; + Cache(type); + return true; + } + if (candidates.Count > 1) + { + error = FormatAmbiguityError(typeName, candidates); + type = null; + return false; + } + } +#endif + + error = $"Type '{typeName}' not found in loaded runtime assemblies. " + + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; + type = null; + return false; + } + + /// + /// Convenience method to resolve a Component type. + /// + public static Type ResolveComponent(string typeName) + { + if (TryResolve(typeName, out Type type, out _, typeof(Component))) + return type; + return null; + } + + /// + /// Convenience method to resolve a ScriptableObject type. + /// + public static Type ResolveScriptableObject(string typeName) + { + if (TryResolve(typeName, out Type type, out _, typeof(ScriptableObject))) + return type; + return null; + } + + /// + /// Convenience method to resolve any type without constraints. + /// + public static Type ResolveAny(string typeName) + { + if (TryResolve(typeName, out Type type, out _, null)) + return type; + return null; + } + + // --- Private Helpers --- + + private static bool PassesConstraint(Type type, Type requiredBaseType) + { + if (type == null) return false; + if (requiredBaseType == null) return true; + return requiredBaseType.IsAssignableFrom(type); + } + + private static bool NamesMatch(Type t, string query) => + t.Name.Equals(query, StringComparison.Ordinal) || + (t.FullName?.Equals(query, StringComparison.Ordinal) ?? false); + + private static void Cache(Type t) + { + if (t == null) return; + if (t.FullName != null) CacheByFqn[t.FullName] = t; + CacheByName[t.Name] = t; + } + + private static List FindCandidates(string query, Type requiredBaseType) + { + bool isShort = !query.Contains('.'); + var loaded = AppDomain.CurrentDomain.GetAssemblies(); + +#if UNITY_EDITOR + // Names of Player (runtime) script assemblies + var playerAsmNames = new HashSet( + CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + var playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); + var editorAsms = loaded.Except(playerAsms); +#else + var playerAsms = loaded; + var editorAsms = Array.Empty(); +#endif + + Func match = isShort + ? (t => t.Name.Equals(query, StringComparison.Ordinal)) + : (t => t.FullName?.Equals(query, StringComparison.Ordinal) ?? false); + + var fromPlayer = playerAsms.SelectMany(SafeGetTypes) + .Where(t => PassesConstraint(t, requiredBaseType)) + .Where(match); + var fromEditor = editorAsms.SelectMany(SafeGetTypes) + .Where(t => PassesConstraint(t, requiredBaseType)) + .Where(match); + + // Prefer Player over Editor + var candidates = fromPlayer.ToList(); + if (candidates.Count == 0) + candidates = fromEditor.ToList(); + + return candidates; + } + + private static IEnumerable SafeGetTypes(System.Reflection.Assembly assembly) + { + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null); } + catch { return Enumerable.Empty(); } + } + + private static IEnumerable PreferPlayer(IEnumerable types) + { +#if UNITY_EDITOR + var playerAsmNames = new HashSet( + CompilationPipeline.GetAssemblies(AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + var list = types.ToList(); + var fromPlayer = list.Where(t => playerAsmNames.Contains(t.Assembly.GetName().Name)).ToList(); + return fromPlayer.Count > 0 ? fromPlayer : list; +#else + return types; +#endif + } + + private static string FormatAmbiguityError(string query, List candidates) + { + var names = string.Join(", ", candidates.Take(5).Select(t => t.FullName)); + if (candidates.Count > 5) names += $" ... ({candidates.Count - 5} more)"; + return $"Ambiguous type reference '{query}'. Found {candidates.Count} matches: [{names}]. Use a fully-qualified name."; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta b/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta new file mode 100644 index 0000000..87ca45e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2cdf06f869b124741af31f27b25742db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/UnityTypeResolver.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs b/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs new file mode 100644 index 0000000..0e81cca --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs @@ -0,0 +1,731 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Utility class for parsing JSON tokens into Unity vector, math, and animation types. + /// Supports both array format [x, y, z] and object format {x: 1, y: 2, z: 3}. + /// + public static class VectorParsing + { + /// + /// Parses a JToken (array or object) into a Vector3. + /// + /// The JSON token to parse + /// The parsed Vector3 or null if parsing fails + public static Vector3? ParseVector3(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y, z] + if (token is JArray array && array.Count >= 3) + { + return new Vector3( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + + // Object format: {x: 1, y: 2, z: 3} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) + { + return new Vector3( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Vector3, returning a default value if parsing fails. + /// + public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default) + { + return ParseVector3(token) ?? defaultValue; + } + + /// + /// Parses a JToken (array or object) into a Vector2. + /// + /// The JSON token to parse + /// The parsed Vector2 or null if parsing fails + public static Vector2? ParseVector2(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y] + if (token is JArray array && array.Count >= 2) + { + return new Vector2( + array[0].ToObject(), + array[1].ToObject() + ); + } + + // Object format: {x: 1, y: 2} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) + { + return new Vector2( + obj["x"].ToObject(), + obj["y"].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Vector2 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Vector4. + /// + /// The JSON token to parse + /// The parsed Vector4 or null if parsing fails + public static Vector4? ParseVector4(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [x, y, z, w] + if (token is JArray array && array.Count >= 4) + { + return new Vector4( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + + // Object format: {x: 1, y: 2, z: 3, w: 4} + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && + obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Vector4( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject(), + obj["w"].ToObject() + ); + } + } + catch (Exception ex) + { + Debug.LogWarning($"[VectorParsing] Failed to parse Vector4 from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Quaternion. + /// Supports both euler angles [x, y, z] and quaternion components [x, y, z, w]. + /// Note: Raw quaternion components are NOT normalized. Callers should normalize if needed + /// for operations like interpolation where non-unit quaternions cause issues. + /// + /// The JSON token to parse + /// If true, treats 3-element arrays as euler angles + /// The parsed Quaternion or null if parsing fails + public static Quaternion? ParseQuaternion(JToken token, bool asEulerAngles = true) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JArray array) + { + // Quaternion components: [x, y, z, w] + if (array.Count >= 4) + { + return new Quaternion( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + + // Euler angles: [x, y, z] + if (array.Count >= 3 && asEulerAngles) + { + return Quaternion.Euler( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + } + + // Object format: {x: 0, y: 0, z: 0, w: 1} + if (token is JObject obj) + { + if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Quaternion( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject(), + obj["w"].ToObject() + ); + } + + // Euler format in object: {x: 45, y: 90, z: 0} (as euler angles) + if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && asEulerAngles) + { + return Quaternion.Euler( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["z"].ToObject() + ); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken (array or object) into a Color. + /// Supports both [r, g, b, a] and {r: 1, g: 1, b: 1, a: 1} formats. + /// + /// The JSON token to parse + /// The parsed Color or null if parsing fails + public static Color? ParseColor(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Array format: [r, g, b, a] or [r, g, b] + if (token is JArray array) + { + if (array.Count >= 4) + { + return new Color( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + if (array.Count >= 3) + { + return new Color( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + 1f // Default alpha + ); + } + } + + // Object format: {r: 1, g: 1, b: 1, a: 1} + if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b")) + { + float a = obj.ContainsKey("a") ? obj["a"].ToObject() : 1f; + return new Color( + obj["r"].ToObject(), + obj["g"].ToObject(), + obj["b"].ToObject(), + a + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Color, returning Color.white if parsing fails and no default is specified. + /// + public static Color ParseColorOrDefault(JToken token) => ParseColor(token) ?? Color.white; + + /// + /// Parses a JToken into a Color, returning the specified default if parsing fails. + /// + public static Color ParseColorOrDefault(JToken token, Color defaultValue) => ParseColor(token) ?? defaultValue; + + /// + /// Parses a JToken into a Vector4, returning a default value if parsing fails. + /// Added for ManageVFX refactoring. + /// + public static Vector4 ParseVector4OrDefault(JToken token, Vector4 defaultValue = default) + { + return ParseVector4(token) ?? defaultValue; + } + + /// + /// Parses a JToken into a Gradient. + /// Supports formats: + /// - Simple: {startColor: [r,g,b,a], endColor: [r,g,b,a]} + /// - Full: {colorKeys: [{color: [r,g,b,a], time: 0.0}, ...], alphaKeys: [{alpha: 1.0, time: 0.0}, ...]} + /// Added for ManageVFX refactoring. + /// + /// The JSON token to parse + /// The parsed Gradient or null if parsing fails + public static Gradient ParseGradient(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + Gradient gradient = new Gradient(); + + if (token is JObject obj) + { + // Simple format: {startColor: ..., endColor: ...} + if (obj.ContainsKey("startColor")) + { + Color startColor = ParseColorOrDefault(obj["startColor"]); + Color endColor = ParseColorOrDefault(obj["endColor"] ?? obj["startColor"]); + float startAlpha = obj["startAlpha"]?.ToObject() ?? startColor.a; + float endAlpha = obj["endAlpha"]?.ToObject() ?? endColor.a; + + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(startColor, 0f), new GradientColorKey(endColor, 1f) }, + new GradientAlphaKey[] { new GradientAlphaKey(startAlpha, 0f), new GradientAlphaKey(endAlpha, 1f) } + ); + return gradient; + } + + // Full format: {colorKeys: [...], alphaKeys: [...]} + var colorKeys = new List(); + var alphaKeys = new List(); + + if (obj["colorKeys"] is JArray colorKeysArr) + { + foreach (var key in colorKeysArr) + { + Color color = ParseColorOrDefault(key["color"]); + float time = key["time"]?.ToObject() ?? 0f; + colorKeys.Add(new GradientColorKey(color, time)); + } + } + + if (obj["alphaKeys"] is JArray alphaKeysArr) + { + foreach (var key in alphaKeysArr) + { + float alpha = key["alpha"]?.ToObject() ?? 1f; + float time = key["time"]?.ToObject() ?? 0f; + alphaKeys.Add(new GradientAlphaKey(alpha, time)); + } + } + + // Ensure at least 2 keys + if (colorKeys.Count == 0) + { + colorKeys.Add(new GradientColorKey(Color.white, 0f)); + colorKeys.Add(new GradientColorKey(Color.white, 1f)); + } + + if (alphaKeys.Count == 0) + { + alphaKeys.Add(new GradientAlphaKey(1f, 0f)); + alphaKeys.Add(new GradientAlphaKey(1f, 1f)); + } + + gradient.SetKeys(colorKeys.ToArray(), alphaKeys.ToArray()); + return gradient; + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Gradient from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Gradient, returning a default gradient if parsing fails. + /// Added for ManageVFX refactoring. + /// + public static Gradient ParseGradientOrDefault(JToken token) + { + var result = ParseGradient(token); + if (result != null) return result; + + // Return default white gradient + var gradient = new Gradient(); + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) }, + new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) } + ); + return gradient; + } + + /// + /// Parses a JToken into an AnimationCurve. + /// + /// Supported formats: + /// + /// Constant: 1.0 (number) - Creates constant curve at that value + /// Simple: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0} + /// Full: {keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]} + /// + /// + /// Keyframe field defaults (for Full format): + /// + /// time (float): Default: 0 + /// value (float): Default: 1 (note: differs from ManageScriptableObject which uses 0) + /// inTangent (float): Default: 0 + /// outTangent (float): Default: 0 + /// + /// + /// Note: This method is used by ManageVFX. For ScriptableObject patching, + /// see which has slightly different defaults. + /// + /// The JSON token to parse + /// The parsed AnimationCurve or null if parsing fails + public static AnimationCurve ParseAnimationCurve(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + // Constant value: just a number + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + { + return AnimationCurve.Constant(0f, 1f, token.ToObject()); + } + + if (token is JObject obj) + { + // Full format: {keys: [...]} + if (obj["keys"] is JArray keys) + { + AnimationCurve curve = new AnimationCurve(); + foreach (var key in keys) + { + float time = key["time"]?.ToObject() ?? 0f; + float value = key["value"]?.ToObject() ?? 1f; + float inTangent = key["inTangent"]?.ToObject() ?? 0f; + float outTangent = key["outTangent"]?.ToObject() ?? 0f; + curve.AddKey(new Keyframe(time, value, inTangent, outTangent)); + } + return curve; + } + + // Simple format: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0} + if (obj.ContainsKey("start") || obj.ContainsKey("startValue") || obj.ContainsKey("end") || obj.ContainsKey("endValue")) + { + float startValue = obj["start"]?.ToObject() ?? obj["startValue"]?.ToObject() ?? 1f; + float endValue = obj["end"]?.ToObject() ?? obj["endValue"]?.ToObject() ?? 1f; + AnimationCurve curve = new AnimationCurve(); + curve.AddKey(0f, startValue); + curve.AddKey(1f, endValue); + return curve; + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse AnimationCurve from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into an AnimationCurve, returning a constant curve if parsing fails. + /// Added for ManageVFX refactoring. + /// + /// The JSON token to parse + /// The constant value for the default curve + public static AnimationCurve ParseAnimationCurveOrDefault(JToken token, float defaultValue = 1f) + { + return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue); + } + + /// + /// Validates AnimationCurve JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Wrapped: { "keys": [ { "time": 0, "value": 1.0 }, ... ] } + /// Direct array: [ { "time": 0, "value": 1.0 }, ... ] + /// Null/empty: Valid (will set empty curve) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateAnimationCurveFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set empty curve)."; + return true; + } + + JArray keysArray = null; + + if (valueToken is JObject curveObj) + { + keysArray = curveObj["keys"] as JArray; + if (keysArray == null) + { + message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }"; + return false; + } + } + else if (valueToken is JArray directArray) + { + keysArray = directArray; + } + else + { + message = "AnimationCurve requires object with 'keys' or array of keyframes. " + + "Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }"; + return false; + } + + // Validate each keyframe + for (int i = 0; i < keysArray.Count; i++) + { + var keyToken = keysArray[i]; + if (keyToken is not JObject keyObj) + { + message = $"Keyframe at index {i} must be an object with 'time' and 'value'."; + return false; + } + + // Validate numeric fields if present + string[] numericFields = { "time", "value", "inSlope", "outSlope", "inTangent", "outTangent", "inWeight", "outWeight" }; + foreach (var field in numericFields) + { + if (!ParamCoercion.ValidateNumericField(keyObj, field, out var fieldError)) + { + message = $"Keyframe[{i}].{field}: {fieldError}"; + return false; + } + } + + if (!ParamCoercion.ValidateIntegerField(keyObj, "weightedMode", out var weightedModeError)) + { + message = $"Keyframe[{i}].weightedMode: {weightedModeError}"; + return false; + } + } + + message = $"Value format valid (AnimationCurve with {keysArray.Count} keyframes). " + + "Note: Missing keyframe fields default to 0 (time, value, inSlope, outSlope, inWeight, outWeight)."; + return true; + } + + /// + /// Validates Quaternion JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Euler array: [x, y, z] - 3 numeric elements + /// Raw quaternion: [x, y, z, w] - 4 numeric elements + /// Object: { "x": 0, "y": 0, "z": 0, "w": 1 } + /// Explicit euler: { "euler": [x, y, z] } + /// Null/empty: Valid (will set identity) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateQuaternionFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set identity quaternion)."; + return true; + } + + if (valueToken is JArray arr) + { + if (arr.Count == 3) + { + // Validate Euler angles [x, y, z] + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from Euler angles [x, y, z])."; + return true; + } + else if (arr.Count == 4) + { + // Validate raw quaternion [x, y, z, w] + for (int i = 0; i < 4; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Quaternion component at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from [x, y, z, w])."; + return true; + } + else + { + message = "Quaternion array must have 3 elements (Euler angles) or 4 elements (x, y, z, w)."; + return false; + } + } + else if (valueToken is JObject obj) + { + // Check for explicit euler property + if (obj["euler"] is JArray eulerArr) + { + if (eulerArr.Count != 3) + { + message = "Quaternion euler array must have exactly 3 elements [x, y, z]."; + return false; + } + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(eulerArr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from { euler: [x, y, z] })."; + return true; + } + + // Object format { x, y, z, w } + if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null) + { + if (!ParamCoercion.IsNumericToken(obj["x"]) || !ParamCoercion.IsNumericToken(obj["y"]) || + !ParamCoercion.IsNumericToken(obj["z"]) || !ParamCoercion.IsNumericToken(obj["w"])) + { + message = "Quaternion { x, y, z, w } fields must all be numbers."; + return false; + } + message = "Value format valid (Quaternion from { x, y, z, w })."; + return true; + } + + message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }."; + return false; + } + else + { + message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }."; + return false; + } + } + + /// + /// Parses a JToken into a Rect. + /// Supports {x, y, width, height} format. + /// + public static Rect? ParseRect(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JObject obj && + obj.ContainsKey("x") && obj.ContainsKey("y") && + obj.ContainsKey("width") && obj.ContainsKey("height")) + { + return new Rect( + obj["x"].ToObject(), + obj["y"].ToObject(), + obj["width"].ToObject(), + obj["height"].ToObject() + ); + } + + // Array format: [x, y, width, height] + if (token is JArray array && array.Count >= 4) + { + return new Rect( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject(), + array[3].ToObject() + ); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}"); + } + + return null; + } + + /// + /// Parses a JToken into a Bounds. + /// Supports {center: {x,y,z}, size: {x,y,z}} format. + /// + public static Bounds? ParseBounds(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + try + { + if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) + { + var center = ParseVector3(obj["center"]) ?? Vector3.zero; + var size = ParseVector3(obj["size"]) ?? Vector3.zero; + return new Bounds(center, size); + } + } + catch (Exception ex) + { + McpLog.Warn($"[VectorParsing] Failed to parse Bounds from '{token}': {ex.Message}"); + } + + return null; + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta b/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta new file mode 100644 index 0000000..cd89f27 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ca2205caede3744aebda9f6da2fa2c22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Helpers/VectorParsing.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef new file mode 100644 index 0000000..9685029 --- /dev/null +++ b/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -0,0 +1,20 @@ +{ + "name": "MCPForUnity.Editor", + "rootNamespace": "MCPForUnity.Editor", + "references": [ + "MCPForUnity.Runtime", + "Newtonsoft.Json" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "overrideReferences": false, + "precompiledReferences": [ + "Newtonsoft.Json.dll" + ], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta b/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta new file mode 100644 index 0000000..4004b88 --- /dev/null +++ b/Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta @@ -0,0 +1,14 @@ +fileFormatVersion: 2 +guid: 98f702da6ca044be59a864a9419c4eab +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/MCPForUnity.Editor.asmdef + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/McpCiBoot.cs b/Assets/MCPForUnity/Editor/McpCiBoot.cs new file mode 100644 index 0000000..c8e8c19 --- /dev/null +++ b/Assets/MCPForUnity/Editor/McpCiBoot.cs @@ -0,0 +1,21 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor +{ + public static class McpCiBoot + { + public static void StartStdioForCi() + { + try + { + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false); + } + catch { /* ignore */ } + + StdioBridgeHost.StartAutoConnect(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/McpCiBoot.cs.meta b/Assets/MCPForUnity/Editor/McpCiBoot.cs.meta new file mode 100644 index 0000000..c1353f3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/McpCiBoot.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ef9dca277ab34ba1b136d8dcd45de948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/McpCiBoot.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/MenuItems.meta b/Assets/MCPForUnity/Editor/MenuItems.meta new file mode 100644 index 0000000..ad5fb5e --- /dev/null +++ b/Assets/MCPForUnity/Editor/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9e7f37616736f4d3cbd8bdbc626f5ab9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs new file mode 100644 index 0000000..c280d95 --- /dev/null +++ b/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -0,0 +1,36 @@ +using MCPForUnity.Editor.Setup; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.MenuItems +{ + public static class MCPForUnityMenu + { + [MenuItem("Window/MCP For Unity/Toggle MCP Window %#m", priority = 1)] + public static void ToggleMCPWindow() + { + if (MCPForUnityEditorWindow.HasAnyOpenWindow()) + { + MCPForUnityEditorWindow.CloseAllOpenWindows(); + } + else + { + MCPForUnityEditorWindow.ShowWindow(); + } + } + + [MenuItem("Window/MCP For Unity/Local Setup Window", priority = 2)] + public static void ShowSetupWindow() + { + SetupWindowService.ShowSetupWindow(); + } + + + [MenuItem("Window/MCP For Unity/Edit EditorPrefs", priority = 3)] + public static void ShowEditorPrefsWindow() + { + EditorPrefsWindow.ShowWindow(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta b/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta new file mode 100644 index 0000000..7a8a06a --- /dev/null +++ b/Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 42b27c415aa084fe6a9cc6cf03979d36 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Migrations.meta b/Assets/MCPForUnity/Editor/Migrations.meta new file mode 100644 index 0000000..62d67f0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Migrations.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8bb6a578d4df4e2daa0bd1aa1fa492d5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs b/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs new file mode 100644 index 0000000..4fbeac8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs @@ -0,0 +1,80 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Detects legacy embedded-server preferences and migrates configs to the new uvx/stdio path once. + /// + [InitializeOnLoad] + internal static class LegacyServerSrcMigration + { + private const string ServerSrcKey = EditorPrefKeys.ServerSrc; + private const string UseEmbeddedKey = EditorPrefKeys.UseEmbeddedServer; + + static LegacyServerSrcMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + bool hasServerSrc = EditorPrefs.HasKey(ServerSrcKey); + bool hasUseEmbedded = EditorPrefs.HasKey(UseEmbeddedKey); + + if (!hasServerSrc && !hasUseEmbedded) + { + return; + } + + try + { + McpLog.Info("Detected legacy embedded MCP server configuration. Updating all client configs..."); + + var summary = MCPServiceLocator.Client.ConfigureAllDetectedClients(); + + if (summary.FailureCount > 0) + { + McpLog.Warn($"Legacy configuration migration finished with errors ({summary.GetSummaryMessage()}). details:"); + if (summary.Messages != null) + { + foreach (var message in summary.Messages) + { + McpLog.Warn($" {message}"); + } + } + McpLog.Warn("Legacy keys will be removed to prevent migration loop. Please configure failing clients manually."); + } + else + { + McpLog.Info($"Legacy configuration migration complete ({summary.GetSummaryMessage()})"); + } + + if (hasServerSrc) + { + EditorPrefs.DeleteKey(ServerSrcKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.ServerSrc"); + } + + if (hasUseEmbedded) + { + EditorPrefs.DeleteKey(UseEmbeddedKey); + McpLog.Info(" ✓ Removed legacy key: MCPForUnity.UseEmbeddedServer"); + } + } + catch (Exception ex) + { + McpLog.Error($"Legacy MCP server migration failed: {ex.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta b/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta new file mode 100644 index 0000000..fcbd496 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4436b2149abf4b0d8014f81cd29a2bd0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Migrations/LegacyServerSrcMigration.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs b/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs new file mode 100644 index 0000000..850e273 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Migrations +{ + /// + /// Keeps stdio MCP clients in sync with the current package version by rewriting their configs when the package updates. + /// + [InitializeOnLoad] + internal static class StdIoVersionMigration + { + private const string LastUpgradeKey = EditorPrefKeys.LastStdIoUpgradeVersion; + + static StdIoVersionMigration() + { + if (Application.isBatchMode) + return; + + EditorApplication.delayCall += RunMigrationIfNeeded; + } + + private static void RunMigrationIfNeeded() + { + EditorApplication.delayCall -= RunMigrationIfNeeded; + + string currentVersion = AssetPathUtility.GetPackageVersion(); + if (string.IsNullOrEmpty(currentVersion) || string.Equals(currentVersion, "unknown", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + string lastUpgradeVersion = string.Empty; + try { lastUpgradeVersion = EditorPrefs.GetString(LastUpgradeKey, string.Empty); } catch { } + + if (string.Equals(lastUpgradeVersion, currentVersion, StringComparison.OrdinalIgnoreCase)) + { + return; // Already refreshed for this package version + } + + bool hadFailures = false; + bool touchedAny = false; + + var configurators = McpClientRegistry.All.OfType().ToList(); + foreach (var configurator in configurators) + { + try + { + if (!configurator.SupportsAutoConfigure) + continue; + + // Handle CLI-based configurators (e.g., Claude Code CLI) + // CheckStatus with attemptAutoRewrite=true will auto-reregister if version mismatch + if (configurator is ClaudeCliMcpConfigurator cliConfigurator) + { + var previousStatus = configurator.Status; + configurator.CheckStatus(attemptAutoRewrite: true); + if (configurator.Status != previousStatus) + { + touchedAny = true; + } + continue; + } + + // Handle JSON file-based configurators + if (!ConfigUsesStdIo(configurator.Client)) + continue; + + MCPServiceLocator.Client.ConfigureClient(configurator); + touchedAny = true; + } + catch (Exception ex) + { + hadFailures = true; + McpLog.Warn($"Failed to refresh stdio config for {configurator.DisplayName}: {ex.Message}"); + } + } + + if (!touchedAny) + { + // Nothing needed refreshing; still record version so we don't rerun every launch + try { EditorPrefs.SetString(LastUpgradeKey, currentVersion); } catch { } + return; + } + + if (hadFailures) + { + McpLog.Warn("Stdio MCP upgrade encountered errors; will retry next session."); + return; + } + + try + { + EditorPrefs.SetString(LastUpgradeKey, currentVersion); + } + catch { } + + McpLog.Info($"Updated stdio MCP configs to package version {currentVersion}."); + } + + private static bool ConfigUsesStdIo(McpClient client) + { + return JsonConfigUsesStdIo(client); + } + + private static bool JsonConfigUsesStdIo(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (string.IsNullOrEmpty(configPath) || !File.Exists(configPath)) + { + return false; + } + + try + { + var root = JObject.Parse(File.ReadAllText(configPath)); + + JToken unityNode = null; + if (client.IsVsCodeLayout) + { + unityNode = root.SelectToken("servers.unityMCP") + ?? root.SelectToken("mcp.servers.unityMCP"); + } + else + { + unityNode = root.SelectToken("mcpServers.unityMCP"); + } + + if (unityNode == null) return false; + + return unityNode["command"] != null; + } + catch + { + return false; + } + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta b/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta new file mode 100644 index 0000000..f857648 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f1d589c8c8684e6f919ffb393c4b4db5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Migrations/StdIoVersionMigration.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models.meta b/Assets/MCPForUnity/Editor/Models.meta new file mode 100644 index 0000000..8540456 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 16d3ab36890b6c14f9afeabee30e03e3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Models/Command.cs b/Assets/MCPForUnity/Editor/Models/Command.cs new file mode 100644 index 0000000..02a89d8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/Command.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Models +{ + /// + /// Represents a command received from the MCP client + /// + public class Command + { + /// + /// The type of command to execute + /// + public string type { get; set; } + + /// + /// The parameters for the command + /// + public JObject @params { get; set; } + } +} + diff --git a/Assets/MCPForUnity/Editor/Models/Command.cs.meta b/Assets/MCPForUnity/Editor/Models/Command.cs.meta new file mode 100644 index 0000000..84ccdfa --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/Command.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6754c84e5deb74749bc3a19e0c9aa280 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/Command.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs b/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs new file mode 100644 index 0000000..25f1163 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs @@ -0,0 +1,23 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServer + { + [JsonProperty("command")] + public string command; + + [JsonProperty("args")] + public string[] args; + + // VSCode expects a transport type; include only when explicitly set + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string type; + + // URL for HTTP transport mode + [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] + public string url; + } +} diff --git a/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta b/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta new file mode 100644 index 0000000..e95749d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5fae9d995f514e9498e9613e2cdbeca9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/MCPConfigServer.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs b/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs new file mode 100644 index 0000000..d5065a1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServers + { + [JsonProperty("unityMCP")] + public McpConfigServer unityMCP; + } +} diff --git a/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta b/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta new file mode 100644 index 0000000..baa574d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: bcb583553e8173b49be71a5c43bd9502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/MCPConfigServers.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models/McpClient.cs b/Assets/MCPForUnity/Editor/Models/McpClient.cs new file mode 100644 index 0000000..832bb8a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpClient.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Models +{ + public class McpClient + { + public string name; + public string windowsConfigPath; + public string macConfigPath; + public string linuxConfigPath; + public string configStatus; + public McpStatus status = McpStatus.NotConfigured; + public ConfiguredTransport configuredTransport = ConfiguredTransport.Unknown; + + // Capability flags/config for JSON-based configurators + public bool IsVsCodeLayout; // Whether the config file follows VS Code layout (env object at root) + public bool SupportsHttpTransport = true; // Whether the MCP server supports HTTP transport + public bool EnsureEnvObject; // Whether to ensure the env object is present in the config + public bool StripEnvWhenNotRequired; // Whether to strip the env object when not required + public string HttpUrlProperty = "url"; // The property name for the HTTP URL in the config + public Dictionary DefaultUnityFields = new(); + + // Helper method to convert the enum to a display string + public string GetStatusDisplayString() + { + return status switch + { + McpStatus.NotConfigured => "Not Configured", + McpStatus.Configured => "Configured", + McpStatus.Running => "Running", + McpStatus.Connected => "Connected", + McpStatus.IncorrectPath => "Incorrect Path", + McpStatus.CommunicationError => "Communication Error", + McpStatus.NoResponse => "No Response", + McpStatus.UnsupportedOS => "Unsupported OS", + McpStatus.MissingConfig => "Missing MCPForUnity Config", + McpStatus.Error => configStatus?.StartsWith("Error:") == true ? configStatus : "Error", + _ => "Unknown", + }; + } + + // Helper method to set both status enum and string for backward compatibility + public void SetStatus(McpStatus newStatus, string errorDetails = null) + { + status = newStatus; + + if (newStatus == McpStatus.Error && !string.IsNullOrEmpty(errorDetails)) + { + configStatus = $"Error: {errorDetails}"; + } + else + { + configStatus = GetStatusDisplayString(); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Models/McpClient.cs.meta b/Assets/MCPForUnity/Editor/Models/McpClient.cs.meta new file mode 100644 index 0000000..46ab969 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpClient.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b1afa56984aec0d41808edcebf805e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/McpClient.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models/McpConfig.cs b/Assets/MCPForUnity/Editor/Models/McpConfig.cs new file mode 100644 index 0000000..9ddf9d0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpConfig.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfig + { + [JsonProperty("mcpServers")] + public McpConfigServers mcpServers; + } +} diff --git a/Assets/MCPForUnity/Editor/Models/McpConfig.cs.meta b/Assets/MCPForUnity/Editor/Models/McpConfig.cs.meta new file mode 100644 index 0000000..28c3991 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpConfig.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c17c09908f0c1524daa8b6957ce1f7f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/McpConfig.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Models/McpStatus.cs b/Assets/MCPForUnity/Editor/Models/McpStatus.cs new file mode 100644 index 0000000..c23bc81 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpStatus.cs @@ -0,0 +1,30 @@ +namespace MCPForUnity.Editor.Models +{ + // Enum representing the various status states for MCP clients + public enum McpStatus + { + NotConfigured, // Not set up yet + Configured, // Successfully configured + Running, // Service is running + Connected, // Successfully connected + IncorrectPath, // Configuration has incorrect paths + CommunicationError, // Connected but communication issues + NoResponse, // Connected but not responding + MissingConfig, // Config file exists but missing required elements + UnsupportedOS, // OS is not supported + Error, // General error state + } + + /// + /// Represents the transport type a client is configured to use. + /// Used to detect mismatches between server and client transport settings. + /// + public enum ConfiguredTransport + { + Unknown, // Could not determine transport type + Stdio, // Client configured for stdio transport + Http, // Client configured for HTTP local transport + HttpRemote // Client configured for HTTP remote-hosted transport + } +} + diff --git a/Assets/MCPForUnity/Editor/Models/McpStatus.cs.meta b/Assets/MCPForUnity/Editor/Models/McpStatus.cs.meta new file mode 100644 index 0000000..3215a61 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Models/McpStatus.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: aa63057c9e5282d4887352578bf49971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Models/McpStatus.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources.meta b/Assets/MCPForUnity/Editor/Resources.meta new file mode 100644 index 0000000..8d921df --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a6f5bafffbb0f48c2a33ad9470bb1e2d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/Editor.meta b/Assets/MCPForUnity/Editor/Resources/Editor.meta new file mode 100644 index 0000000..5c252d1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 266967ec2e1df44209bf46ec6037d61d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs b/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs new file mode 100644 index 0000000..13a5564 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs @@ -0,0 +1,64 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides information about the currently active editor tool. + /// + [McpForUnityResource("get_active_tool")] + public static class ActiveTool + { + public static object HandleCommand(JObject @params) + { + try + { + Tool currentTool = UnityEditor.Tools.current; + string toolName = currentTool.ToString(); + bool customToolActive = UnityEditor.Tools.current == Tool.Custom; + string activeToolName = customToolActive ? EditorTools.GetActiveToolName() : toolName; + + var toolInfo = new + { + activeTool = activeToolName, + isCustom = customToolActive, + pivotMode = UnityEditor.Tools.pivotMode.ToString(), + pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), + handleRotation = new + { + x = UnityEditor.Tools.handleRotation.eulerAngles.x, + y = UnityEditor.Tools.handleRotation.eulerAngles.y, + z = UnityEditor.Tools.handleRotation.eulerAngles.z + }, + handlePosition = new + { + x = UnityEditor.Tools.handlePosition.x, + y = UnityEditor.Tools.handlePosition.y, + z = UnityEditor.Tools.handlePosition.z + } + }; + + return new SuccessResponse("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting active tool: {e.Message}"); + } + } + } + + // Helper class for custom tool names + internal static class EditorTools + { + public static string GetActiveToolName() + { + if (UnityEditor.Tools.current == Tool.Custom) + { + return "Unknown Custom Tool"; + } + return UnityEditor.Tools.current.ToString(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta b/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta new file mode 100644 index 0000000..9f1ccd2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6e78b6227ab7742a8a4f679ee6a8a212 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Editor/ActiveTool.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs new file mode 100644 index 0000000..b58c888 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -0,0 +1,27 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides dynamic editor state information that changes frequently. + /// + [McpForUnityResource("get_editor_state")] + public static class EditorState + { + public static object HandleCommand(JObject @params) + { + try + { + var snapshot = EditorStateCache.GetSnapshot(); + return new SuccessResponse("Retrieved editor state.", snapshot); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting editor state: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta b/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta new file mode 100644 index 0000000..f3a0a21 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f7c6df54e014c44fdb0cd3f65a479e37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Editor/EditorState.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs b/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs new file mode 100644 index 0000000..022d9c4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides detailed information about the current editor selection. + /// + [McpForUnityResource("get_selection")] + public static class Selection + { + public static object HandleCommand(JObject @params) + { + try + { + var selectionInfo = new + { + activeObject = UnityEditor.Selection.activeObject?.name, + activeGameObject = UnityEditor.Selection.activeGameObject?.name, + activeTransform = UnityEditor.Selection.activeTransform?.name, + activeInstanceID = UnityEditor.Selection.activeInstanceID, + count = UnityEditor.Selection.count, + objects = UnityEditor.Selection.objects + .Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceID() + }) + .ToList(), + gameObjects = UnityEditor.Selection.gameObjects + .Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceID() + }) + .ToList(), + assetGUIDs = UnityEditor.Selection.assetGUIDs + }; + + return new SuccessResponse("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting selection: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta b/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta new file mode 100644 index 0000000..ea9b555 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c7ea869623e094599a70be086ab4fc0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Editor/Selection.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs b/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs new file mode 100644 index 0000000..b69a9d3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Editor +{ + /// + /// Provides list of all open editor windows. + /// + [McpForUnityResource("get_windows")] + public static class Windows + { + public static object HandleCommand(JObject @params) + { + try + { + EditorWindow[] allWindows = UnityEngine.Resources.FindObjectsOfTypeAll(); + var openWindows = new List(); + + foreach (EditorWindow window in allWindows) + { + if (window == null) + continue; + + try + { + openWindows.Add(new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height + }, + instanceID = window.GetInstanceID() + }); + } + catch (Exception ex) + { + McpLog.Warn($"Could not get info for window {window.GetType().Name}: {ex.Message}"); + } + } + + return new SuccessResponse("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting editor windows: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta b/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta new file mode 100644 index 0000000..fdd8845 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 58a341e64bea440b29deaf859aaea552 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Editor/Windows.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs b/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs new file mode 100644 index 0000000..f86cd5f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs @@ -0,0 +1,42 @@ +using System; + +namespace MCPForUnity.Editor.Resources +{ + /// + /// Marks a class as an MCP resource handler for auto-discovery. + /// The class must have a public static HandleCommand(JObject) method. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class McpForUnityResourceAttribute : Attribute + { + /// + /// The resource name used to route requests to this resource. + /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// + public string ResourceName { get; } + + /// + /// Human-readable description of what this resource provides. + /// + public string Description { get; set; } + + /// + /// Create an MCP resource attribute with auto-generated resource name. + /// The resource name will be derived from the class name (PascalCase → snake_case). + /// Example: ManageAsset → manage_asset + /// + public McpForUnityResourceAttribute() + { + ResourceName = null; // Will be auto-generated + } + + /// + /// Create an MCP resource attribute with explicit resource name. + /// + /// The resource name (e.g., "manage_asset") + public McpForUnityResourceAttribute(string resourceName) + { + ResourceName = resourceName; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta b/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta new file mode 100644 index 0000000..1209739 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4c2d60f570f3d4bd2a6a2c1293094be3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/McpForUnityResourceAttribute.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/MenuItems.meta b/Assets/MCPForUnity/Editor/Resources/MenuItems.meta new file mode 100644 index 0000000..df20ed6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bca79cd3ef8ed466f9e50e2dc7850e46 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs b/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs new file mode 100644 index 0000000..f6a8428 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Resources.MenuItems +{ + /// + /// Provides a simple read-only resource that returns Unity menu items. + /// + [McpForUnityResource("get_menu_items")] + public static class GetMenuItems + { + private static List _cached; + + [InitializeOnLoadMethod] + private static void BuildCache() => Refresh(); + + public static object HandleCommand(JObject @params) + { + bool forceRefresh = @params?["refresh"]?.ToObject() ?? false; + string search = @params?["search"]?.ToString(); + + var items = GetMenuItemsInternal(forceRefresh); + + if (!string.IsNullOrEmpty(search)) + { + items = items + .Where(item => item.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + } + + string message = $"Retrieved {items.Count} menu items"; + return new SuccessResponse(message, items); + } + + internal static List GetMenuItemsInternal(bool forceRefresh) + { + if (forceRefresh || _cached == null) + { + Refresh(); + } + + return (_cached ?? new List()).ToList(); + } + + private static void Refresh() + { + try + { + var methods = TypeCache.GetMethodsWithAttribute(); + _cached = methods + .SelectMany(m => m + .GetCustomAttributes(typeof(MenuItem), false) + .OfType() + .Select(attr => attr.menuItem)) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + } + catch (Exception ex) + { + McpLog.Error($"[GetMenuItems] Failed to scan menu items: {ex}"); + _cached ??= new List(); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta b/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta new file mode 100644 index 0000000..9b5655d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 04eeea61eb5c24033a88013845d25f23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/MenuItems/GetMenuItems.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Project.meta b/Assets/MCPForUnity/Editor/Resources/Project.meta new file mode 100644 index 0000000..1adf044 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 538489f13d7914c4eba9a67e29001b43 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs b/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs new file mode 100644 index 0000000..9e9ef7d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides dictionary of layer indices to layer names. + /// + [McpForUnityResource("get_layers")] + public static class Layers + { + private const int TotalLayerCount = 32; + + public static object HandleCommand(JObject @params) + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) + { + layers.Add(i, layerName); + } + } + + return new SuccessResponse("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to retrieve layers: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs.meta b/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs.meta new file mode 100644 index 0000000..cacffbb --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/Layers.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 959ee428299454ac19a636275208ca00 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Project/Layers.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs b/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs new file mode 100644 index 0000000..6e6d12f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides static project configuration information. + /// + [McpForUnityResource("get_project_info")] + public static class ProjectInfo + { + public static object HandleCommand(JObject @params) + { + try + { + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + string projectName = Path.GetFileName(projectRoot); + + var info = new + { + projectRoot = projectRoot ?? "", + projectName = projectName ?? "", + unityVersion = Application.unityVersion, + platform = EditorUserBuildSettings.activeBuildTarget.ToString(), + assetsPath = assetsPath + }; + + return new SuccessResponse("Retrieved project info.", info); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting project info: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta b/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta new file mode 100644 index 0000000..a98914c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 81b03415fcf93466e9ed667d19b58d43 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Project/ProjectInfo.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs b/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs new file mode 100644 index 0000000..756f00d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs @@ -0,0 +1,27 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditorInternal; + +namespace MCPForUnity.Editor.Resources.Project +{ + /// + /// Provides list of all tags in the project. + /// + [McpForUnityResource("get_tags")] + public static class Tags + { + public static object HandleCommand(JObject @params) + { + try + { + string[] tags = InternalEditorUtility.tags; + return new SuccessResponse("Retrieved current tags.", tags); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to retrieve tags: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs.meta b/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs.meta new file mode 100644 index 0000000..f412694 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Project/Tags.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2179ac5d98f264d1681e7d5c0d0ed341 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Project/Tags.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Scene.meta b/Assets/MCPForUnity/Editor/Resources/Scene.meta new file mode 100644 index 0000000..20d08db --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Scene.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 563f6050485b445449a1db200bfba51c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs b/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs new file mode 100644 index 0000000..2588349 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Scene +{ + /// + /// Resource handler for reading GameObject data. + /// Provides read-only access to GameObject information without component serialization. + /// + /// URI: unity://scene/gameobject/{instanceID} + /// + [McpForUnityResource("get_gameobject")] + public static class GameObjectResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + // Get instance ID from params + int? instanceID = null; + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + if (idToken != null) + { + instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + instanceID = null; + } + } + + if (!instanceID.HasValue) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + try + { + var go = EditorUtility.InstanceIDToObject(instanceID.Value) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + return new + { + success = true, + data = SerializeGameObject(go) + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectResource] Error getting GameObject: {e}"); + return new ErrorResponse($"Error getting GameObject: {e.Message}"); + } + } + + /// + /// Serializes a GameObject without component details. + /// For component data, use GetComponents or GetComponent resources. + /// + public static object SerializeGameObject(GameObject go) + { + if (go == null) + return null; + + var transform = go.transform; + + // Get component type names (not full serialization) + var componentTypes = go.GetComponents() + .Where(c => c != null) + .Select(c => c.GetType().Name) + .ToList(); + + // Get children instance IDs (not full serialization) + var childrenIds = new List(); + foreach (Transform child in transform) + { + childrenIds.Add(child.gameObject.GetInstanceID()); + } + + return new + { + instanceID = go.GetInstanceID(), + name = go.name, + tag = go.tag, + layer = go.layer, + layerName = LayerMask.LayerToName(go.layer), + active = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + transform = new + { + position = SerializeVector3(transform.position), + localPosition = SerializeVector3(transform.localPosition), + rotation = SerializeVector3(transform.eulerAngles), + localRotation = SerializeVector3(transform.localEulerAngles), + scale = SerializeVector3(transform.localScale), + lossyScale = SerializeVector3(transform.lossyScale) + }, + parent = transform.parent != null ? transform.parent.gameObject.GetInstanceID() : (int?)null, + children = childrenIds, + componentTypes = componentTypes, + path = GameObjectLookup.GetGameObjectPath(go) + }; + } + + private static object SerializeVector3(Vector3 v) + { + return new { x = v.x, y = v.y, z = v.z }; + } + } + + /// + /// Resource handler for reading all components on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/components + /// + [McpForUnityResource("get_gameobject_components")] + public static class GameObjectComponentsResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + int instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + // Pagination parameters + int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 25); + int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0); + bool includeProperties = ParamCoercion.CoerceBool(@params["includeProperties"] ?? @params["include_properties"], true); + + pageSize = Mathf.Clamp(pageSize, 1, 100); + + try + { + var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + var allComponents = go.GetComponents().Where(c => c != null).ToList(); + int total = allComponents.Count; + + var pagedComponents = allComponents.Skip(cursor).Take(pageSize).ToList(); + + var componentData = new List(); + foreach (var component in pagedComponents) + { + if (includeProperties) + { + componentData.Add(GameObjectSerializer.GetComponentData(component)); + } + else + { + componentData.Add(new + { + typeName = component.GetType().FullName, + instanceID = component.GetInstanceID() + }); + } + } + + int? nextCursor = cursor + pagedComponents.Count < total ? cursor + pagedComponents.Count : (int?)null; + + return new + { + success = true, + data = new + { + gameObjectID = instanceID, + gameObjectName = go.name, + components = componentData, + cursor = cursor, + pageSize = pageSize, + nextCursor = nextCursor, + totalCount = total, + hasMore = nextCursor.HasValue, + includeProperties = includeProperties + } + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectComponentsResource] Error getting components: {e}"); + return new ErrorResponse($"Error getting components: {e.Message}"); + } + } + } + + /// + /// Resource handler for reading a single component on a GameObject. + /// + /// URI: unity://scene/gameobject/{instanceID}/component/{componentName} + /// + [McpForUnityResource("get_gameobject_component")] + public static class GameObjectComponentResource + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"]; + int instanceID = ParamCoercion.CoerceInt(idToken, -1); + if (instanceID == -1) + { + return new ErrorResponse("'instanceID' parameter is required."); + } + + string componentName = ParamCoercion.CoerceString(@params["componentName"] ?? @params["component_name"] ?? @params["component"], null); + if (string.IsNullOrEmpty(componentName)) + { + return new ErrorResponse("'componentName' parameter is required."); + } + + try + { + var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject; + if (go == null) + { + return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); + } + + // Find the component by type name + Component targetComponent = null; + foreach (var component in go.GetComponents()) + { + if (component == null) continue; + + var typeName = component.GetType().Name; + var fullTypeName = component.GetType().FullName; + + if (string.Equals(typeName, componentName, StringComparison.OrdinalIgnoreCase) || + string.Equals(fullTypeName, componentName, StringComparison.OrdinalIgnoreCase)) + { + targetComponent = component; + break; + } + } + + if (targetComponent == null) + { + return new ErrorResponse($"Component '{componentName}' not found on GameObject '{go.name}'."); + } + + return new + { + success = true, + data = new + { + gameObjectID = instanceID, + gameObjectName = go.name, + component = GameObjectSerializer.GetComponentData(targetComponent) + } + }; + } + catch (Exception e) + { + McpLog.Error($"[GameObjectComponentResource] Error getting component: {e}"); + return new ErrorResponse($"Error getting component: {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta b/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta new file mode 100644 index 0000000..e545e44 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5ee79050d9f6d42798a0757cc7672517 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Resources/Tests.meta b/Assets/MCPForUnity/Editor/Resources/Tests.meta new file mode 100644 index 0000000..0aa0bf0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 412726d2e774048939b0d2bd4f11a503 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs b/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs new file mode 100644 index 0000000..4fe48c7 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Resources.Tests +{ + /// + /// Provides access to Unity tests from the Test Framework with pagination and filtering support. + /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (optional): Filter by "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination + /// - page_number (optional): 1-based page number (converted to cursor) + /// + [McpForUnityResource("get_tests")] + public static class GetTests + { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + + public static async Task HandleCommand(JObject @params) + { + // Parse mode filter + TestMode? modeFilter = null; + string modeStr = @params?["mode"]?.ToString(); + if (!string.IsNullOrEmpty(modeStr)) + { + if (!ModeParser.TryParse(modeStr, out modeFilter, out var parseError)) + { + return new ErrorResponse(parseError); + } + } + + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + + McpLog.Info($"[GetTests] Retrieving tests (mode={modeFilter?.ToString() ?? "all"}, filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; + try + { + allTests = await MCPServiceLocator.Tests.GetTestsAsync(modeFilter).ConfigureAwait(true); + } + catch (Exception ex) + { + McpLog.Error($"[GetTests] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); + return new ErrorResponse("Failed to retrieve tests"); + } + + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); + + string message = !string.IsNullOrEmpty(nameFilter) + ? $"Retrieved {response.Items.Count} of {response.TotalCount} tests matching '{nameFilter}' (cursor {response.Cursor})" + : $"Retrieved {response.Items.Count} of {response.TotalCount} tests (cursor {response.Cursor})"; + + return new SuccessResponse(message, response); + } + } + + /// + /// DEPRECATED: Use get_tests with mode parameter instead. + /// Provides access to Unity tests for a specific mode (EditMode or PlayMode). + /// This is a read-only resource that can be queried by MCP clients. + /// + /// Parameters: + /// - mode (required): "EditMode" or "PlayMode" + /// - filter (optional): Filter test names by pattern (case-insensitive contains) + /// - page_size (optional): Number of tests per page (default: 50, max: 200) + /// - cursor (optional): 0-based cursor for pagination + /// + [McpForUnityResource("get_tests_for_mode")] + public static class GetTestsForMode + { + private const int DEFAULT_PAGE_SIZE = 50; + private const int MAX_PAGE_SIZE = 200; + + public static async Task HandleCommand(JObject @params) + { + string modeStr = @params?["mode"]?.ToString(); + if (string.IsNullOrEmpty(modeStr)) + { + return new ErrorResponse("'mode' parameter is required"); + } + + if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) + { + return new ErrorResponse(parseError); + } + + // Parse name filter + string nameFilter = @params?["filter"]?.ToString(); + + McpLog.Info($"[GetTestsForMode] Retrieving tests for mode: {parsedMode.Value} (filter={nameFilter ?? "none"})"); + + IReadOnlyList> allTests; + try + { + allTests = await MCPServiceLocator.Tests.GetTestsAsync(parsedMode).ConfigureAwait(true); + } + catch (Exception ex) + { + McpLog.Error($"[GetTestsForMode] Error retrieving tests: {ex.Message}\n{ex.StackTrace}"); + return new ErrorResponse("Failed to retrieve tests"); + } + + // Apply name filter if provided and convert to List for pagination + List> filteredTests; + if (!string.IsNullOrEmpty(nameFilter)) + { + filteredTests = allTests + .Where(t => + (t.ContainsKey("name") && t["name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) || + (t.ContainsKey("full_name") && t["full_name"].IndexOf(nameFilter, StringComparison.OrdinalIgnoreCase) >= 0) + ) + .ToList(); + } + else + { + filteredTests = allTests.ToList(); + } + + // Clamp page_size before parsing pagination to ensure cursor is computed correctly + int requestedPageSize = ParamCoercion.CoerceInt( + @params?["page_size"] ?? @params?["pageSize"], + DEFAULT_PAGE_SIZE + ); + int clampedPageSize = System.Math.Min(requestedPageSize, MAX_PAGE_SIZE); + if (clampedPageSize <= 0) clampedPageSize = DEFAULT_PAGE_SIZE; + + // Create modified params with clamped page_size for cursor calculation + var paginationParams = new JObject(@params); + paginationParams["page_size"] = clampedPageSize; + + // Parse pagination with clamped page size + var pagination = PaginationRequest.FromParams(paginationParams, DEFAULT_PAGE_SIZE); + + // Create paginated response + var response = PaginationResponse>.Create(filteredTests, pagination); + + string message = nameFilter != null + ? $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests matching '{nameFilter}'" + : $"Retrieved {response.Items.Count} of {response.TotalCount} {parsedMode.Value} tests"; + + return new SuccessResponse(message, response); + } + } + + internal static class ModeParser + { + internal static bool TryParse(string modeStr, out TestMode? mode, out string error) + { + error = null; + mode = null; + + if (string.IsNullOrWhiteSpace(modeStr)) + { + error = "'mode' parameter cannot be empty"; + return false; + } + + if (modeStr.Equals("EditMode", StringComparison.OrdinalIgnoreCase)) + { + mode = TestMode.EditMode; + return true; + } + + if (modeStr.Equals("PlayMode", StringComparison.OrdinalIgnoreCase)) + { + mode = TestMode.PlayMode; + return true; + } + + error = $"Unknown test mode: '{modeStr}'. Use 'EditMode' or 'PlayMode'"; + return false; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta b/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta new file mode 100644 index 0000000..5342397 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 84183aaed077e4f25968269c952db2d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Resources/Tests/GetTests.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services.meta b/Assets/MCPForUnity/Editor/Services.meta new file mode 100644 index 0000000..e800dea --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2ab6b1cc527214416b21e07b96164f24 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs b/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs new file mode 100644 index 0000000..04583e7 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -0,0 +1,157 @@ + +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio). + /// + public class BridgeControlService : IBridgeControlService + { + private readonly TransportManager _transportManager; + private TransportMode _preferredMode = TransportMode.Http; + + public BridgeControlService() + { + _transportManager = MCPServiceLocator.TransportManager; + } + + private TransportMode ResolvePreferredMode() + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + _preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio; + return _preferredMode; + } + + private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null) + { + bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true); + string transportLabel = string.IsNullOrWhiteSpace(state.TransportName) + ? mode.ToString().ToLowerInvariant() + : state.TransportName; + string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]"; + string message = messageOverride + ?? state.Error + ?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}"); + + return new BridgeVerificationResult + { + Success = pingSucceeded && handshakeValid, + HandshakeValid = handshakeValid, + PingSucceeded = pingSucceeded, + Message = message + }; + } + + public bool IsRunning + { + get + { + var mode = ResolvePreferredMode(); + return _transportManager.IsRunning(mode); + } + } + + public int CurrentPort + { + get + { + var mode = ResolvePreferredMode(); + var state = _transportManager.GetState(mode); + if (state.Port.HasValue) + { + return state.Port.Value; + } + + // Legacy fallback while the stdio bridge is still in play + return StdioBridgeHost.GetCurrentPort(); + } + } + + public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode(); + public TransportMode? ActiveMode => ResolvePreferredMode(); + + public async Task StartAsync() + { + var mode = ResolvePreferredMode(); + try + { + // Treat transports as mutually exclusive for user-driven session starts: + // stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP). + var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http; + try + { + await _transportManager.StopAsync(otherMode); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}"); + } + + // Legacy safety: stdio may have been started outside TransportManager state. + if (otherMode == TransportMode.Stdio) + { + try { StdioBridgeHost.Stop(); } catch { } + } + + bool started = await _transportManager.StartAsync(mode); + if (!started) + { + McpLog.Warn($"Failed to start MCP transport: {mode}"); + } + return started; + } + catch (Exception ex) + { + McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}"); + return false; + } + } + + public async Task StopAsync() + { + try + { + var mode = ResolvePreferredMode(); + await _transportManager.StopAsync(mode); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping MCP transport: {ex.Message}"); + } + } + + public async Task VerifyAsync() + { + var mode = ResolvePreferredMode(); + bool pingSucceeded = await _transportManager.VerifyAsync(mode); + var state = _transportManager.GetState(mode); + return BuildVerificationResult(state, mode, pingSucceeded); + } + + public BridgeVerificationResult Verify(int port) + { + var mode = ResolvePreferredMode(); + bool pingSucceeded = _transportManager.VerifyAsync(mode).GetAwaiter().GetResult(); + var state = _transportManager.GetState(mode); + + if (mode == TransportMode.Stdio) + { + bool handshakeValid = state.IsConnected && port == CurrentPort; + string message = handshakeValid + ? $"STDIO transport listening on port {CurrentPort}" + : $"STDIO transport port mismatch (expected {CurrentPort}, got {port})"; + return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid); + } + + return BuildVerificationResult(state, mode, pingSucceeded); + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs.meta b/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs.meta new file mode 100644 index 0000000..402dbfd --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/BridgeControlService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ed4f9f69d84a945248dafc0f0b5a62dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/BridgeControlService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs b/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs new file mode 100644 index 0000000..65f0e1d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Implementation of client configuration service + /// + public class ClientConfigurationService : IClientConfigurationService + { + private readonly List configurators; + + public ClientConfigurationService() + { + configurators = McpClientRegistry.All.ToList(); + } + + public IReadOnlyList GetAllClients() => configurators; + + public void ConfigureClient(IMcpClientConfigurator configurator) + { + // When using a local server path, clean stale build artifacts first. + // This prevents old deleted .py files from being picked up by Python's auto-discovery. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + + configurator.Configure(); + } + + public ClientConfigurationSummary ConfigureAllDetectedClients() + { + // When using a local server path, clean stale build artifacts once before configuring all clients. + if (AssetPathUtility.IsLocalServerPath()) + { + AssetPathUtility.CleanLocalServerBuildArtifacts(); + } + + var summary = new ClientConfigurationSummary(); + foreach (var configurator in configurators) + { + try + { + // Always re-run configuration so core fields stay current + configurator.CheckStatus(attemptAutoRewrite: false); + configurator.Configure(); + summary.SuccessCount++; + summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully"); + } + catch (Exception ex) + { + summary.FailureCount++; + summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}"); + } + } + + return summary; + } + + public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true) + { + var previous = configurator.Status; + var current = configurator.CheckStatus(attemptAutoRewrite); + return current != previous; + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta b/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta new file mode 100644 index 0000000..f046337 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 76cad34d10fd24aaa95c4583c1f88fdf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/ClientConfigurationService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs b/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs new file mode 100644 index 0000000..40dedd5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs @@ -0,0 +1,345 @@ +using System; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Centralized cache for frequently-read EditorPrefs values. + /// Reduces scattered EditorPrefs.Get* calls and provides change notification. + /// + /// Usage: + /// var config = EditorConfigurationCache.Instance; + /// if (config.UseHttpTransport) { ... } + /// config.OnConfigurationChanged += (key) => { /* refresh UI */ }; + /// + public class EditorConfigurationCache + { + private static EditorConfigurationCache _instance; + private static readonly object _lock = new object(); + + /// + /// Singleton instance. Thread-safe lazy initialization. + /// + public static EditorConfigurationCache Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new EditorConfigurationCache(); + } + } + } + return _instance; + } + } + + /// + /// Event fired when any cached configuration value changes. + /// The string parameter is the EditorPrefKeys constant name that changed. + /// + public event Action OnConfigurationChanged; + + // Cached values - most frequently read + private bool _useHttpTransport; + private bool _debugLogs; + private bool _useBetaServer; + private bool _devModeForceServerRefresh; + private string _uvxPathOverride; + private string _gitUrlOverride; + private string _httpBaseUrl; + private string _httpRemoteBaseUrl; + private string _claudeCliPathOverride; + private string _httpTransportScope; + private int _unitySocketPort; + + /// + /// Whether to use HTTP transport (true) or Stdio transport (false). + /// Default: true + /// + public bool UseHttpTransport => _useHttpTransport; + + /// + /// Whether debug logging is enabled. + /// Default: false + /// + public bool DebugLogs => _debugLogs; + + /// + /// Whether to use the beta server channel. + /// Default: true + /// + public bool UseBetaServer => _useBetaServer; + + /// + /// Whether to force server refresh in dev mode (--no-cache --refresh). + /// Default: false + /// + public bool DevModeForceServerRefresh => _devModeForceServerRefresh; + + /// + /// Custom path override for uvx executable. + /// Default: empty string (auto-detect) + /// + public string UvxPathOverride => _uvxPathOverride; + + /// + /// Custom Git URL override for server installation. + /// Default: empty string (use default) + /// + public string GitUrlOverride => _gitUrlOverride; + + /// + /// HTTP base URL for the local MCP server. + /// Default: empty string + /// + public string HttpBaseUrl => _httpBaseUrl; + + /// + /// HTTP base URL for the remote-hosted MCP server. + /// Default: empty string + /// + public string HttpRemoteBaseUrl => _httpRemoteBaseUrl; + + /// + /// Custom path override for Claude CLI executable. + /// Default: empty string (auto-detect) + /// + public string ClaudeCliPathOverride => _claudeCliPathOverride; + + /// + /// HTTP transport scope: "local" or "remote". + /// Default: empty string + /// + public string HttpTransportScope => _httpTransportScope; + + /// + /// Unity socket port for Stdio transport. + /// Default: 0 (auto-assign) + /// + public int UnitySocketPort => _unitySocketPort; + + private EditorConfigurationCache() + { + Refresh(); + } + + /// + /// Refresh all cached values from EditorPrefs. + /// Call this after bulk EditorPrefs changes or domain reload. + /// + public void Refresh() + { + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + } + + /// + /// Set UseHttpTransport and update cache + EditorPrefs atomically. + /// + public void SetUseHttpTransport(bool value) + { + if (_useHttpTransport != value) + { + _useHttpTransport = value; + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value); + OnConfigurationChanged?.Invoke(nameof(UseHttpTransport)); + } + } + + /// + /// Set DebugLogs and update cache + EditorPrefs atomically. + /// + public void SetDebugLogs(bool value) + { + if (_debugLogs != value) + { + _debugLogs = value; + EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value); + OnConfigurationChanged?.Invoke(nameof(DebugLogs)); + } + } + + /// + /// Set UseBetaServer and update cache + EditorPrefs atomically. + /// + public void SetUseBetaServer(bool value) + { + if (_useBetaServer != value) + { + _useBetaServer = value; + EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, value); + OnConfigurationChanged?.Invoke(nameof(UseBetaServer)); + } + } + + /// + /// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically. + /// + public void SetDevModeForceServerRefresh(bool value) + { + if (_devModeForceServerRefresh != value) + { + _devModeForceServerRefresh = value; + EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value); + OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh)); + } + } + + /// + /// Set UvxPathOverride and update cache + EditorPrefs atomically. + /// + public void SetUvxPathOverride(string value) + { + value = value ?? string.Empty; + if (_uvxPathOverride != value) + { + _uvxPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(UvxPathOverride)); + } + } + + /// + /// Set GitUrlOverride and update cache + EditorPrefs atomically. + /// + public void SetGitUrlOverride(string value) + { + value = value ?? string.Empty; + if (_gitUrlOverride != value) + { + _gitUrlOverride = value; + EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value); + OnConfigurationChanged?.Invoke(nameof(GitUrlOverride)); + } + } + + /// + /// Set HttpBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpBaseUrl != value) + { + _httpBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl)); + } + } + + /// + /// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically. + /// + public void SetHttpRemoteBaseUrl(string value) + { + value = value ?? string.Empty; + if (_httpRemoteBaseUrl != value) + { + _httpRemoteBaseUrl = value; + EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value); + OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl)); + } + } + + /// + /// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically. + /// + public void SetClaudeCliPathOverride(string value) + { + value = value ?? string.Empty; + if (_claudeCliPathOverride != value) + { + _claudeCliPathOverride = value; + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value); + OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride)); + } + } + + /// + /// Set HttpTransportScope and update cache + EditorPrefs atomically. + /// + public void SetHttpTransportScope(string value) + { + value = value ?? string.Empty; + if (_httpTransportScope != value) + { + _httpTransportScope = value; + EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value); + OnConfigurationChanged?.Invoke(nameof(HttpTransportScope)); + } + } + + /// + /// Set UnitySocketPort and update cache + EditorPrefs atomically. + /// + public void SetUnitySocketPort(int value) + { + if (_unitySocketPort != value) + { + _unitySocketPort = value; + EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value); + OnConfigurationChanged?.Invoke(nameof(UnitySocketPort)); + } + } + + /// + /// Force refresh of a single cached value from EditorPrefs. + /// Useful when external code modifies EditorPrefs directly. + /// + public void InvalidateKey(string keyName) + { + switch (keyName) + { + case nameof(UseHttpTransport): + _useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + break; + case nameof(DebugLogs): + _debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); + break; + case nameof(UseBetaServer): + _useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true); + break; + case nameof(DevModeForceServerRefresh): + _devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); + break; + case nameof(UvxPathOverride): + _uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + break; + case nameof(GitUrlOverride): + _gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty); + break; + case nameof(HttpBaseUrl): + _httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty); + break; + case nameof(HttpRemoteBaseUrl): + _httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty); + break; + case nameof(ClaudeCliPathOverride): + _claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + break; + case nameof(HttpTransportScope): + _httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); + break; + case nameof(UnitySocketPort): + _unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0); + break; + } + OnConfigurationChanged?.Invoke(keyName); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta b/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta new file mode 100644 index 0000000..d25631e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b4a183ac9b63c408886bce40ae58f462 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/EditorConfigurationCache.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs b/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs new file mode 100644 index 0000000..2511b03 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs @@ -0,0 +1,54 @@ +using System; +using MCPForUnity.Editor.Windows; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing the EditorPrefs window + /// Follows the Class-level Singleton pattern + /// + public class EditorPrefsWindowService + { + private static EditorPrefsWindowService _instance; + + /// + /// Get the singleton instance + /// + public static EditorPrefsWindowService Instance + { + get + { + if (_instance == null) + { + throw new Exception("EditorPrefsWindowService not initialized"); + } + return _instance; + } + } + + /// + /// Initialize the service + /// + public static void Initialize() + { + if (_instance == null) + { + _instance = new EditorPrefsWindowService(); + } + } + + private EditorPrefsWindowService() + { + // Private constructor for singleton + } + + /// + /// Show the EditorPrefs window + /// + public void ShowWindow() + { + EditorPrefsWindow.ShowWindow(); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta b/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta new file mode 100644 index 0000000..7e49399 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/EditorPrefsWindowService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs b/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs new file mode 100644 index 0000000..24fec0f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -0,0 +1,546 @@ +using System; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy. + /// Updated on the main thread via Editor callbacks and periodic update ticks. + /// + [InitializeOnLoad] + internal static class EditorStateCache + { + private static readonly object LockObj = new(); + private static long _sequence; + private static long _observedUnixMs; + + private static bool _lastIsCompiling; + private static long? _lastCompileStartedUnixMs; + private static long? _lastCompileFinishedUnixMs; + + private static bool _domainReloadPending; + private static long? _domainReloadBeforeUnixMs; + private static long? _domainReloadAfterUnixMs; + + private static double _lastUpdateTimeSinceStartup; + private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s + + // State tracking to detect when snapshot actually changes (checked BEFORE building) + private static string _lastTrackedScenePath; + private static string _lastTrackedSceneName; + private static bool _lastTrackedIsFocused; + private static bool _lastTrackedIsPlaying; + private static bool _lastTrackedIsPaused; + private static bool _lastTrackedIsUpdating; + private static bool _lastTrackedTestsRunning; + private static string _lastTrackedActivityPhase; + + private static JObject _cached; + + private sealed class EditorStateSnapshot + { + [JsonProperty("schema_version")] + public string SchemaVersion { get; set; } + + [JsonProperty("observed_at_unix_ms")] + public long ObservedAtUnixMs { get; set; } + + [JsonProperty("sequence")] + public long Sequence { get; set; } + + [JsonProperty("unity")] + public EditorStateUnity Unity { get; set; } + + [JsonProperty("editor")] + public EditorStateEditor Editor { get; set; } + + [JsonProperty("activity")] + public EditorStateActivity Activity { get; set; } + + [JsonProperty("compilation")] + public EditorStateCompilation Compilation { get; set; } + + [JsonProperty("assets")] + public EditorStateAssets Assets { get; set; } + + [JsonProperty("tests")] + public EditorStateTests Tests { get; set; } + + [JsonProperty("transport")] + public EditorStateTransport Transport { get; set; } + } + + private sealed class EditorStateUnity + { + [JsonProperty("instance_id")] + public string InstanceId { get; set; } + + [JsonProperty("unity_version")] + public string UnityVersion { get; set; } + + [JsonProperty("project_id")] + public string ProjectId { get; set; } + + [JsonProperty("platform")] + public string Platform { get; set; } + + [JsonProperty("is_batch_mode")] + public bool? IsBatchMode { get; set; } + } + + private sealed class EditorStateEditor + { + [JsonProperty("is_focused")] + public bool? IsFocused { get; set; } + + [JsonProperty("play_mode")] + public EditorStatePlayMode PlayMode { get; set; } + + [JsonProperty("active_scene")] + public EditorStateActiveScene ActiveScene { get; set; } + } + + private sealed class EditorStatePlayMode + { + [JsonProperty("is_playing")] + public bool? IsPlaying { get; set; } + + [JsonProperty("is_paused")] + public bool? IsPaused { get; set; } + + [JsonProperty("is_changing")] + public bool? IsChanging { get; set; } + } + + private sealed class EditorStateActiveScene + { + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("guid")] + public string Guid { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + } + + private sealed class EditorStateActivity + { + [JsonProperty("phase")] + public string Phase { get; set; } + + [JsonProperty("since_unix_ms")] + public long SinceUnixMs { get; set; } + + [JsonProperty("reasons")] + public string[] Reasons { get; set; } + } + + private sealed class EditorStateCompilation + { + [JsonProperty("is_compiling")] + public bool? IsCompiling { get; set; } + + [JsonProperty("is_domain_reload_pending")] + public bool? IsDomainReloadPending { get; set; } + + [JsonProperty("last_compile_started_unix_ms")] + public long? LastCompileStartedUnixMs { get; set; } + + [JsonProperty("last_compile_finished_unix_ms")] + public long? LastCompileFinishedUnixMs { get; set; } + + [JsonProperty("last_domain_reload_before_unix_ms")] + public long? LastDomainReloadBeforeUnixMs { get; set; } + + [JsonProperty("last_domain_reload_after_unix_ms")] + public long? LastDomainReloadAfterUnixMs { get; set; } + } + + private sealed class EditorStateAssets + { + [JsonProperty("is_updating")] + public bool? IsUpdating { get; set; } + + [JsonProperty("external_changes_dirty")] + public bool? ExternalChangesDirty { get; set; } + + [JsonProperty("external_changes_last_seen_unix_ms")] + public long? ExternalChangesLastSeenUnixMs { get; set; } + + [JsonProperty("external_changes_dirty_since_unix_ms")] + public long? ExternalChangesDirtySinceUnixMs { get; set; } + + [JsonProperty("external_changes_last_cleared_unix_ms")] + public long? ExternalChangesLastClearedUnixMs { get; set; } + + [JsonProperty("refresh")] + public EditorStateRefresh Refresh { get; set; } + } + + private sealed class EditorStateRefresh + { + [JsonProperty("is_refresh_in_progress")] + public bool? IsRefreshInProgress { get; set; } + + [JsonProperty("last_refresh_requested_unix_ms")] + public long? LastRefreshRequestedUnixMs { get; set; } + + [JsonProperty("last_refresh_finished_unix_ms")] + public long? LastRefreshFinishedUnixMs { get; set; } + } + + private sealed class EditorStateTests + { + [JsonProperty("is_running")] + public bool? IsRunning { get; set; } + + [JsonProperty("mode")] + public string Mode { get; set; } + + [JsonProperty("current_job_id")] + public string CurrentJobId { get; set; } + + [JsonProperty("started_unix_ms")] + public long? StartedUnixMs { get; set; } + + [JsonProperty("started_by")] + public string StartedBy { get; set; } + + [JsonProperty("last_run")] + public EditorStateLastRun LastRun { get; set; } + } + + private sealed class EditorStateLastRun + { + [JsonProperty("finished_unix_ms")] + public long? FinishedUnixMs { get; set; } + + [JsonProperty("result")] + public string Result { get; set; } + + [JsonProperty("counts")] + public object Counts { get; set; } + } + + private sealed class EditorStateTransport + { + [JsonProperty("unity_bridge_connected")] + public bool? UnityBridgeConnected { get; set; } + + [JsonProperty("last_message_unix_ms")] + public long? LastMessageUnixMs { get; set; } + } + + static EditorStateCache() + { + try + { + _sequence = 0; + _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _cached = BuildSnapshot("init"); + + EditorApplication.update += OnUpdate; + EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode"); + + AssemblyReloadEvents.beforeAssemblyReload += () => + { + _domainReloadPending = true; + _domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ForceUpdate("before_domain_reload"); + }; + AssemblyReloadEvents.afterAssemblyReload += () => + { + _domainReloadPending = false; + _domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + ForceUpdate("after_domain_reload"); + }; + } + catch (Exception ex) + { + McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}"); + } + } + + private static void OnUpdate() + { + // Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients. + double now = EditorApplication.timeSinceStartup; + // Use GetActualIsCompiling() to avoid Play mode false positives (issue #582) + bool isCompiling = GetActualIsCompiling(); + + // Check for compilation edge transitions (always update on these) + bool compilationEdge = isCompiling != _lastIsCompiling; + + if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds) + { + return; + } + + // Fast state-change detection BEFORE building snapshot. + // This avoids the expensive BuildSnapshot() call entirely when nothing changed. + // These checks are much cheaper than building a full JSON snapshot. + var scene = EditorSceneManager.GetActiveScene(); + string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path; + string sceneName = scene.name ?? string.Empty; + bool isFocused = InternalEditorUtility.isApplicationActive; + bool isPlaying = EditorApplication.isPlaying; + bool isPaused = EditorApplication.isPaused; + bool isUpdating = EditorApplication.isUpdating; + bool testsRunning = TestRunStatus.IsRunning; + + var activityPhase = "idle"; + if (testsRunning) + { + activityPhase = "running_tests"; + } + else if (isCompiling) + { + activityPhase = "compiling"; + } + else if (_domainReloadPending) + { + activityPhase = "domain_reload"; + } + else if (isUpdating) + { + activityPhase = "asset_import"; + } + else if (EditorApplication.isPlayingOrWillChangePlaymode) + { + activityPhase = "playmode_transition"; + } + + bool hasChanges = compilationEdge + || _lastTrackedScenePath != scenePath + || _lastTrackedSceneName != sceneName + || _lastTrackedIsFocused != isFocused + || _lastTrackedIsPlaying != isPlaying + || _lastTrackedIsPaused != isPaused + || _lastTrackedIsUpdating != isUpdating + || _lastTrackedTestsRunning != testsRunning + || _lastTrackedActivityPhase != activityPhase; + + if (!hasChanges) + { + // No state change - skip the expensive BuildSnapshot entirely. + // This is the key optimization that prevents the 28ms GC spikes. + return; + } + + // Update tracked state + _lastTrackedScenePath = scenePath; + _lastTrackedSceneName = sceneName; + _lastTrackedIsFocused = isFocused; + _lastTrackedIsPlaying = isPlaying; + _lastTrackedIsPaused = isPaused; + _lastTrackedIsUpdating = isUpdating; + _lastTrackedTestsRunning = testsRunning; + _lastTrackedActivityPhase = activityPhase; + + _lastUpdateTimeSinceStartup = now; + ForceUpdate("tick"); + } + + private static void ForceUpdate(string reason) + { + lock (LockObj) + { + _cached = BuildSnapshot(reason); + } + } + + private static JObject BuildSnapshot(string reason) + { + _sequence++; + _observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + bool isCompiling = GetActualIsCompiling(); + if (isCompiling && !_lastIsCompiling) + { + _lastCompileStartedUnixMs = _observedUnixMs; + } + else if (!isCompiling && _lastIsCompiling) + { + _lastCompileFinishedUnixMs = _observedUnixMs; + } + _lastIsCompiling = isCompiling; + + var scene = EditorSceneManager.GetActiveScene(); + string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path; + string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null; + + bool testsRunning = TestRunStatus.IsRunning; + var testsMode = TestRunStatus.Mode?.ToString(); + string currentJobId = TestJobManager.CurrentJobId; + bool isFocused = InternalEditorUtility.isApplicationActive; + + var activityPhase = "idle"; + if (testsRunning) + { + activityPhase = "running_tests"; + } + else if (isCompiling) + { + activityPhase = "compiling"; + } + else if (_domainReloadPending) + { + activityPhase = "domain_reload"; + } + else if (EditorApplication.isUpdating) + { + activityPhase = "asset_import"; + } + else if (EditorApplication.isPlayingOrWillChangePlaymode) + { + activityPhase = "playmode_transition"; + } + + var snapshot = new EditorStateSnapshot + { + SchemaVersion = "unity-mcp/editor_state@2", + ObservedAtUnixMs = _observedUnixMs, + Sequence = _sequence, + Unity = new EditorStateUnity + { + InstanceId = null, + UnityVersion = Application.unityVersion, + ProjectId = null, + Platform = Application.platform.ToString(), + IsBatchMode = Application.isBatchMode + }, + Editor = new EditorStateEditor + { + IsFocused = isFocused, + PlayMode = new EditorStatePlayMode + { + IsPlaying = EditorApplication.isPlaying, + IsPaused = EditorApplication.isPaused, + IsChanging = EditorApplication.isPlayingOrWillChangePlaymode + }, + ActiveScene = new EditorStateActiveScene + { + Path = scenePath, + Guid = sceneGuid, + Name = scene.name ?? string.Empty + } + }, + Activity = new EditorStateActivity + { + Phase = activityPhase, + SinceUnixMs = _observedUnixMs, + Reasons = new[] { reason } + }, + Compilation = new EditorStateCompilation + { + IsCompiling = isCompiling, + IsDomainReloadPending = _domainReloadPending, + LastCompileStartedUnixMs = _lastCompileStartedUnixMs, + LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs, + LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs, + LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs + }, + Assets = new EditorStateAssets + { + IsUpdating = EditorApplication.isUpdating, + ExternalChangesDirty = false, + ExternalChangesLastSeenUnixMs = null, + ExternalChangesDirtySinceUnixMs = null, + ExternalChangesLastClearedUnixMs = null, + Refresh = new EditorStateRefresh + { + IsRefreshInProgress = false, + LastRefreshRequestedUnixMs = null, + LastRefreshFinishedUnixMs = null + } + }, + Tests = new EditorStateTests + { + IsRunning = testsRunning, + Mode = testsMode, + CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId, + StartedUnixMs = TestRunStatus.StartedUnixMs, + StartedBy = "unknown", + LastRun = TestRunStatus.FinishedUnixMs.HasValue + ? new EditorStateLastRun + { + FinishedUnixMs = TestRunStatus.FinishedUnixMs, + Result = "unknown", + Counts = null + } + : null + }, + Transport = new EditorStateTransport + { + UnityBridgeConnected = null, + LastMessageUnixMs = null + } + }; + + return JObject.FromObject(snapshot); + } + + public static JObject GetSnapshot() + { + lock (LockObj) + { + // Defensive: if something went wrong early, rebuild once. + if (_cached == null) + { + _cached = BuildSnapshot("rebuild"); + } + + // Always return a fresh clone to prevent mutation bugs. + // The main GC optimization comes from state-change detection (OnUpdate) + // which prevents unnecessary _cached rebuilds, not from caching the clone. + return (JObject)_cached.DeepClone(); + } + } + + /// + /// Returns the actual compilation state, working around a known Unity quirk where + /// EditorApplication.isCompiling can return false positives in Play mode. + /// See: https://github.com/CoplayDev/unity-mcp/issues/549 + /// + private static bool GetActualIsCompiling() + { + // If EditorApplication.isCompiling is false, Unity is definitely not compiling + if (!EditorApplication.isCompiling) + { + return false; + } + + // In Play mode, EditorApplication.isCompiling can have false positives. + // Double-check with CompilationPipeline.isCompiling via reflection. + if (EditorApplication.isPlaying) + { + try + { + Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", BindingFlags.Public | BindingFlags.Static); + if (prop != null) + { + return (bool)prop.GetValue(null); + } + } + catch + { + // If reflection fails, fall back to EditorApplication.isCompiling + } + } + + // Outside Play mode or if reflection failed, trust EditorApplication.isCompiling + return true; + } + } +} + + diff --git a/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs.meta b/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs.meta new file mode 100644 index 0000000..69478b5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/EditorStateCache.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: aa7909967ce3c48c493181c978782a54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/EditorStateCache.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs b/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs new file mode 100644 index 0000000..b0ffd3e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs @@ -0,0 +1,145 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Windows; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge. + /// + [InitializeOnLoad] + internal static class HttpBridgeReloadHandler + { + static HttpBridgeReloadHandler() + { + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + private static void OnBeforeAssemblyReload() + { + try + { + var transport = MCPServiceLocator.TransportManager; + bool shouldResume = transport.IsRunning(TransportMode.Http); + + if (shouldResume) + { + EditorPrefs.SetBool(EditorPrefKeys.ResumeHttpAfterReload, true); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + + if (shouldResume) + { + var stopTask = transport.StopAsync(TransportMode.Http); + stopTask.ContinueWith(t => + { + if (t.IsFaulted && t.Exception != null) + { + McpLog.Warn($"Error stopping MCP bridge before reload: {t.Exception.GetBaseException().Message}"); + } + }, TaskScheduler.Default); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}"); + } + } + + private static void OnAfterAssemblyReload() + { + bool resume = false; + try + { + // Only resume HTTP if it is still the selected transport. + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + resume = useHttp && EditorPrefs.GetBool(EditorPrefKeys.ResumeHttpAfterReload, false); + if (resume) + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeHttpAfterReload); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}"); + resume = false; + } + + if (!resume) + { + return; + } + + // If the editor is not compiling, attempt an immediate restart without relying on editor focus. + bool isCompiling = EditorApplication.isCompiling; + try + { + var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) isCompiling |= (bool)prop.GetValue(null); + } + catch { } + + if (!isCompiling) + { + try + { + var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); + startTask.ContinueWith(t => + { + if (t.IsFaulted) + { + var baseEx = t.Exception?.GetBaseException(); + McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {baseEx?.Message}"); + return; + } + bool started = t.Result; + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + }, TaskScheduler.Default); + return; + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + return; + } + } + + // Fallback when compiling: schedule on the editor loop + EditorApplication.delayCall += async () => + { + try + { + bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); + if (!started) + { + McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload"); + } + else + { + MCPForUnityEditorWindow.RequestHealthVerification(); + } + } + catch (Exception ex) + { + McpLog.Error($"Error resuming HTTP MCP bridge: {ex.Message}"); + } + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta b/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta new file mode 100644 index 0000000..083c372 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4c0cf970a7b494a659be151dc0124296 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/HttpBridgeReloadHandler.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs b/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs new file mode 100644 index 0000000..7cc593e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs @@ -0,0 +1,82 @@ +using System.Threading.Tasks; +using MCPForUnity.Editor.Services.Transport; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for controlling the MCP for Unity Bridge connection + /// + public interface IBridgeControlService + { + /// + /// Gets whether the bridge is currently running + /// + bool IsRunning { get; } + + /// + /// Gets the current port the bridge is listening on + /// + int CurrentPort { get; } + + /// + /// Gets whether the bridge is in auto-connect mode + /// + bool IsAutoConnectMode { get; } + + /// + /// Gets the currently active transport mode, if any + /// + TransportMode? ActiveMode { get; } + + /// + /// Starts the MCP for Unity Bridge asynchronously + /// + /// True if the bridge started successfully + Task StartAsync(); + + /// + /// Stops the MCP for Unity Bridge asynchronously + /// + Task StopAsync(); + + /// + /// Verifies the bridge connection by sending a ping and waiting for a pong response + /// + /// The port to verify + /// Verification result with detailed status + BridgeVerificationResult Verify(int port); + + /// + /// Verifies the connection asynchronously (works for both HTTP and stdio transports) + /// + /// Verification result with detailed status + Task VerifyAsync(); + + } + + /// + /// Result of a bridge verification attempt + /// + public class BridgeVerificationResult + { + /// + /// Whether the verification was successful + /// + public bool Success { get; set; } + + /// + /// Human-readable message about the verification result + /// + public string Message { get; set; } + + /// + /// Whether the handshake was valid (FRAMING=1 protocol) + /// + public bool HandshakeValid { get; set; } + + /// + /// Whether the ping/pong exchange succeeded + /// + public bool PingSucceeded { get; set; } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta b/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta new file mode 100644 index 0000000..a517fc2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6b5d9f677f6f54fc59e6fe921b260c61 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IBridgeControlService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs b/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs new file mode 100644 index 0000000..6172e8f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for configuring MCP clients + /// + public interface IClientConfigurationService + { + /// + /// Configures a specific MCP client + /// + /// The client to configure + void ConfigureClient(IMcpClientConfigurator configurator); + + /// + /// Configures all detected/installed MCP clients (skips clients where CLI/tools not found) + /// + /// Summary of configuration results + ClientConfigurationSummary ConfigureAllDetectedClients(); + + /// + /// Checks the configuration status of a client + /// + /// The client to check + /// If true, attempts to auto-fix mismatched paths + /// True if status changed, false otherwise + bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true); + + /// Gets the registry of discovered configurators. + IReadOnlyList GetAllClients(); + } + + /// + /// Summary of configuration results for multiple clients + /// + public class ClientConfigurationSummary + { + /// + /// Number of clients successfully configured + /// + public int SuccessCount { get; set; } + + /// + /// Number of clients that failed to configure + /// + public int FailureCount { get; set; } + + /// + /// Number of clients skipped (already configured or tool not found) + /// + public int SkippedCount { get; set; } + + /// + /// Detailed messages for each client + /// + public System.Collections.Generic.List Messages { get; set; } = new(); + + /// + /// Gets a human-readable summary message + /// + public string GetSummaryMessage() + { + return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta b/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta new file mode 100644 index 0000000..093fbe9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: aae139cfae7ac4044ac52e2658005ea1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IClientConfigurationService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs b/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs new file mode 100644 index 0000000..743834c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs @@ -0,0 +1,29 @@ +using System; + +namespace MCPForUnity.Editor.Services +{ + public interface IPackageDeploymentService + { + string GetStoredSourcePath(); + void SetStoredSourcePath(string path); + void ClearStoredSourcePath(); + + string GetTargetPath(); + string GetTargetDisplayPath(); + + string GetLastBackupPath(); + bool HasBackup(); + + PackageDeploymentResult DeployFromStoredSource(); + PackageDeploymentResult RestoreLastBackup(); + } + + public class PackageDeploymentResult + { + public bool Success { get; set; } + public string Message { get; set; } + public string SourcePath { get; set; } + public string TargetPath { get; set; } + public string BackupPath { get; set; } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta b/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta new file mode 100644 index 0000000..5c9f251 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IPackageDeploymentService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs b/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs new file mode 100644 index 0000000..9d4d2e4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs @@ -0,0 +1,60 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for checking package updates and version information + /// + public interface IPackageUpdateService + { + /// + /// Checks if a newer version of the package is available + /// + /// The current package version + /// Update check result containing availability and latest version info + UpdateCheckResult CheckForUpdate(string currentVersion); + + /// + /// Compares two version strings to determine if the first is newer than the second + /// + /// First version string + /// Second version string + /// True if version1 is newer than version2 + bool IsNewerVersion(string version1, string version2); + + /// + /// Determines if the package was installed via Git or Asset Store + /// + /// True if installed via Git, false if Asset Store or unknown + bool IsGitInstallation(); + + /// + /// Clears the cached update check data, forcing a fresh check on next request + /// + void ClearCache(); + } + + /// + /// Result of an update check operation + /// + public class UpdateCheckResult + { + /// + /// Whether an update is available + /// + public bool UpdateAvailable { get; set; } + + /// + /// The latest version available (null if check failed or no update) + /// + public string LatestVersion { get; set; } + + /// + /// Whether the check was successful (false if network error, etc.) + /// + public bool CheckSucceeded { get; set; } + + /// + /// Optional message about the check result + /// + public string Message { get; set; } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta b/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta new file mode 100644 index 0000000..2cb86c2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: e94ae28f193184e4fb5068f62f4f00c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IPackageUpdateService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs b/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs new file mode 100644 index 0000000..d57581d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs @@ -0,0 +1,77 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for resolving paths to required tools and supporting user overrides + /// + public interface IPathResolverService + { + /// + /// Gets the uvx package manager path (respects override if set) + /// + /// Path to the uvx executable, or null if not found + string GetUvxPath(); + + /// + /// Gets the Claude CLI path (respects override if set) + /// + /// Path to the claude executable, or null if not found + string GetClaudeCliPath(); + + /// + /// Checks if Python is detected on the system + /// + /// True if Python is found + bool IsPythonDetected(); + + /// + /// Checks if Claude CLI is detected on the system + /// + /// True if Claude CLI is found + bool IsClaudeCliDetected(); + + /// + /// Sets an override for the uvx path + /// + /// Path to override with + void SetUvxPathOverride(string path); + + /// + /// Sets an override for the Claude CLI path + /// + /// Path to override with + void SetClaudeCliPathOverride(string path); + + /// + /// Clears the uvx path override + /// + void ClearUvxPathOverride(); + + /// + /// Clears the Claude CLI path override + /// + void ClearClaudeCliPathOverride(); + + /// + /// Gets whether a uvx path override is active + /// + bool HasUvxPathOverride { get; } + + /// + /// Gets whether a Claude CLI path override is active + /// + bool HasClaudeCliPathOverride { get; } + + /// + /// Gets whether the uvx path used a fallback from override to system path + /// + bool HasUvxPathFallback { get; } + + /// + /// Validates the provided uv executable by running "--version" and parsing the output. + /// + /// Absolute or relative path to the uv/uvx executable. + /// Parsed version string if successful. + /// True when the executable runs and returns a uv version string. + bool TryValidateUvxExecutable(string uvPath, out string version); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs.meta b/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs.meta new file mode 100644 index 0000000..399c724 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPathResolverService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 1e8d388be507345aeb0eaf27fbd3c022 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IPathResolverService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IPlatformService.cs b/Assets/MCPForUnity/Editor/Services/IPlatformService.cs new file mode 100644 index 0000000..ec686b2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPlatformService.cs @@ -0,0 +1,20 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for platform detection and platform-specific environment access + /// + public interface IPlatformService + { + /// + /// Checks if the current platform is Windows + /// + /// True if running on Windows + bool IsWindows(); + + /// + /// Gets the SystemRoot environment variable (Windows-specific) + /// + /// SystemRoot path, or null if not available + string GetSystemRoot(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IPlatformService.cs.meta b/Assets/MCPForUnity/Editor/Services/IPlatformService.cs.meta new file mode 100644 index 0000000..f0ac451 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IPlatformService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IPlatformService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs b/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs new file mode 100644 index 0000000..6595fc8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered resource + /// + public class ResourceMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + public string AssemblyName { get; set; } + public bool IsBuiltIn { get; set; } + } + + /// + /// Service for discovering MCP resources via reflection + /// + public interface IResourceDiscoveryService + { + /// + /// Discovers all resources marked with [McpForUnityResource] + /// + List DiscoverAllResources(); + + /// + /// Gets metadata for a specific resource + /// + ResourceMetadata GetResourceMetadata(string resourceName); + + /// + /// Returns only the resources currently enabled + /// + List GetEnabledResources(); + + /// + /// Checks whether a resource is currently enabled + /// + bool IsResourceEnabled(string resourceName); + + /// + /// Updates the enabled state for a resource + /// + void SetResourceEnabled(string resourceName, bool enabled); + + /// + /// Invalidates the resource discovery cache + /// + void InvalidateCache(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta b/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta new file mode 100644 index 0000000..9c0fd0e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7afb4739669224c74b4b4d706e6bbb49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IResourceDiscoveryService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs b/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs new file mode 100644 index 0000000..299fad5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -0,0 +1,64 @@ +namespace MCPForUnity.Editor.Services +{ + /// + /// Interface for server management operations + /// + public interface IServerManagementService + { + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + bool ClearUvxCache(); + + /// + /// Start the local HTTP server in a new terminal window. + /// Stops any existing server on the port and clears the uvx cache first. + /// + /// True if server was started successfully, false otherwise + bool StartLocalHttpServer(); + + /// + /// Stop the local HTTP server by finding the process listening on the configured port + /// + bool StopLocalHttpServer(); + + /// + /// Stop the Unity-managed local HTTP server if a handshake/pidfile exists, + /// even if the current transport selection has changed. + /// + bool StopManagedLocalHttpServer(); + + /// + /// Best-effort detection: returns true if a local MCP HTTP server appears to be running + /// on the configured local URL/port (used to drive UI state even if the session is not active). + /// + bool IsLocalHttpServerRunning(); + + /// + /// Fast reachability check: returns true if a local TCP listener is accepting connections + /// for the configured local URL/port (used for UI state without process inspection). + /// + bool IsLocalHttpServerReachable(); + + /// + /// Attempts to get the command that will be executed when starting the local HTTP server + /// + /// The command that will be executed when available + /// Reason why a command could not be produced + /// True if a command is available, false otherwise + bool TryGetLocalHttpServerCommand(out string command, out string error); + + /// + /// Check if the configured HTTP URL is a local address + /// + /// True if URL is local (localhost, 127.0.0.1, etc.) + bool IsLocalUrl(); + + /// + /// Check if the local HTTP server can be started + /// + /// True if HTTP transport is enabled and URL is local + bool CanStartLocalServer(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs.meta b/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs.meta new file mode 100644 index 0000000..7510a86 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IServerManagementService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d41bfc9780b774affa6afbffd081eb79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IServerManagementService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs b/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs new file mode 100644 index 0000000..c24d9e4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Options for filtering which tests to run. + /// All properties are optional - null or empty arrays are ignored. + /// + public class TestFilterOptions + { + /// + /// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod"). + /// + public string[] TestNames { get; set; } + + /// + /// Same as TestNames, except it allows for Regex. + /// + public string[] GroupNames { get; set; } + + /// + /// NUnit category names to filter by (tests marked with [Category] attribute). + /// + public string[] CategoryNames { get; set; } + + /// + /// Assembly names to filter tests by. + /// + public string[] AssemblyNames { get; set; } + } + + /// + /// Provides access to Unity Test Runner data and execution. + /// + public interface ITestRunnerService + { + /// + /// Retrieve the list of tests for the requested mode(s). + /// When is null, tests for both EditMode and PlayMode are returned. + /// + Task>> GetTestsAsync(TestMode? mode); + + /// + /// Execute tests for the supplied mode with optional filtering. + /// + /// The test mode (EditMode or PlayMode). + /// Optional filter options to run specific tests. Pass null to run all tests. + Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta b/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta new file mode 100644 index 0000000..18e0e90 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d23bf32361ff444beaf3510818c94bae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/ITestRunnerService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs b/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs new file mode 100644 index 0000000..ee2c616 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Metadata for a discovered tool + /// + public class ToolMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public bool StructuredOutput { get; set; } + public List Parameters { get; set; } + public string ClassName { get; set; } + public string Namespace { get; set; } + public string AssemblyName { get; set; } + public bool AutoRegister { get; set; } = true; + public bool RequiresPolling { get; set; } = false; + public string PollAction { get; set; } = "status"; + public bool IsBuiltIn { get; set; } + } + + /// + /// Metadata for a tool parameter + /// + public class ParameterMetadata + { + public string Name { get; set; } + public string Description { get; set; } + public string Type { get; set; } // "string", "int", "bool", "float", etc. + public bool Required { get; set; } + public string DefaultValue { get; set; } + } + + /// + /// Service for discovering MCP tools via reflection + /// + public interface IToolDiscoveryService + { + /// + /// Discovers all tools marked with [McpForUnityTool] + /// + List DiscoverAllTools(); + + /// + /// Gets metadata for a specific tool + /// + ToolMetadata GetToolMetadata(string toolName); + + /// + /// Returns only the tools currently enabled for registration + /// + List GetEnabledTools(); + + /// + /// Checks whether a tool is currently enabled for registration + /// + bool IsToolEnabled(string toolName); + + /// + /// Updates the enabled state for a tool + /// + void SetToolEnabled(string toolName, bool enabled); + + /// + /// Invalidates the tool discovery cache + /// + void InvalidateCache(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta b/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta new file mode 100644 index 0000000..663f9f0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 497592a93fd994b2cb9803e7c8636ff7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/IToolDiscoveryService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs b/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs new file mode 100644 index 0000000..c8ceb4d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs @@ -0,0 +1,98 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service locator for accessing MCP services without dependency injection + /// + public static class MCPServiceLocator + { + private static IBridgeControlService _bridgeService; + private static IClientConfigurationService _clientService; + private static IPathResolverService _pathService; + private static ITestRunnerService _testRunnerService; + private static IPackageUpdateService _packageUpdateService; + private static IPlatformService _platformService; + private static IToolDiscoveryService _toolDiscoveryService; + private static IResourceDiscoveryService _resourceDiscoveryService; + private static IServerManagementService _serverManagementService; + private static TransportManager _transportManager; + private static IPackageDeploymentService _packageDeploymentService; + + public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService(); + public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService(); + public static IPathResolverService Paths => _pathService ??= new PathResolverService(); + public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService(); + public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService(); + public static IPlatformService Platform => _platformService ??= new PlatformService(); + public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService(); + public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService(); + public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService(); + public static TransportManager TransportManager => _transportManager ??= new TransportManager(); + public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService(); + + /// + /// Registers a custom implementation for a service (useful for testing) + /// + /// The service interface type + /// The implementation to register + public static void Register(T implementation) where T : class + { + if (implementation is IBridgeControlService b) + _bridgeService = b; + else if (implementation is IClientConfigurationService c) + _clientService = c; + else if (implementation is IPathResolverService p) + _pathService = p; + else if (implementation is ITestRunnerService t) + _testRunnerService = t; + else if (implementation is IPackageUpdateService pu) + _packageUpdateService = pu; + else if (implementation is IPlatformService ps) + _platformService = ps; + else if (implementation is IToolDiscoveryService td) + _toolDiscoveryService = td; + else if (implementation is IResourceDiscoveryService rd) + _resourceDiscoveryService = rd; + else if (implementation is IServerManagementService sm) + _serverManagementService = sm; + else if (implementation is IPackageDeploymentService pd) + _packageDeploymentService = pd; + else if (implementation is TransportManager tm) + _transportManager = tm; + } + + /// + /// Resets all services to their default implementations (useful for testing) + /// + public static void Reset() + { + (_bridgeService as IDisposable)?.Dispose(); + (_clientService as IDisposable)?.Dispose(); + (_pathService as IDisposable)?.Dispose(); + (_testRunnerService as IDisposable)?.Dispose(); + (_packageUpdateService as IDisposable)?.Dispose(); + (_platformService as IDisposable)?.Dispose(); + (_toolDiscoveryService as IDisposable)?.Dispose(); + (_resourceDiscoveryService as IDisposable)?.Dispose(); + (_serverManagementService as IDisposable)?.Dispose(); + (_transportManager as IDisposable)?.Dispose(); + (_packageDeploymentService as IDisposable)?.Dispose(); + + _bridgeService = null; + _clientService = null; + _pathService = null; + _testRunnerService = null; + _packageUpdateService = null; + _platformService = null; + _toolDiscoveryService = null; + _resourceDiscoveryService = null; + _serverManagementService = null; + _transportManager = null; + _packageDeploymentService = null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta b/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta new file mode 100644 index 0000000..0e784e8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 276d6a9f9a1714ead91573945de78992 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/MCPServiceLocator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs new file mode 100644 index 0000000..9b4bd61 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Best-effort cleanup when the Unity Editor is quitting. + /// - Stops active transports so clients don't see a "hung" session longer than necessary. + /// - If HTTP Local is selected, attempts to stop the local HTTP server (guarded by PID heuristics). + /// + [InitializeOnLoad] + internal static class McpEditorShutdownCleanup + { + static McpEditorShutdownCleanup() + { + // Guard against duplicate subscriptions across domain reloads. + try { EditorApplication.quitting -= OnEditorQuitting; } catch { } + EditorApplication.quitting += OnEditorQuitting; + } + + private static void OnEditorQuitting() + { + // 1) Stop transports (best-effort, bounded wait). + try + { + var transport = MCPServiceLocator.TransportManager; + + Task stopHttp = transport.StopAsync(TransportMode.Http); + Task stopStdio = transport.StopAsync(TransportMode.Stdio); + + try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { } + } + catch (Exception ex) + { + // Avoid hard failures on quit. + McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}"); + } + + // 2) Stop local HTTP server if it was Unity-managed (best-effort). + try + { + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + string scope = string.Empty; + try { scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); } catch { } + + bool stopped = false; + bool httpLocalSelected = + useHttp && + (string.Equals(scope, "local", StringComparison.OrdinalIgnoreCase) + || (string.IsNullOrEmpty(scope) && MCPServiceLocator.Server.IsLocalUrl())); + + if (httpLocalSelected) + { + // StopLocalHttpServer is already guarded to only terminate processes that look like mcp-for-unity. + // If it refuses to stop (e.g. URL was edited away from local), fall back to the Unity-managed stop. + stopped = MCPServiceLocator.Server.StopLocalHttpServer(); + } + + // Always attempt to stop a Unity-managed server if one exists. + // This covers cases where the user switched transports (e.g. to stdio) or StopLocalHttpServer refused. + if (!stopped) + { + MCPServiceLocator.Server.StopManagedLocalHttpServer(); + } + } + catch (Exception ex) + { + McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}"); + } + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta b/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta new file mode 100644 index 0000000..f053545 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4150c04e0907c45d7b332260911a0567 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs b/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs new file mode 100644 index 0000000..7c0e27c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs @@ -0,0 +1,304 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; +using PackageInfo = UnityEditor.PackageManager.PackageInfo; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Handles copying a local MCPForUnity folder into the current project's package location with backup/restore support. + /// + public class PackageDeploymentService : IPackageDeploymentService + { + private const string BackupRootFolderName = "MCPForUnityDeployBackups"; + + public string GetStoredSourcePath() + { + return EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty); + } + + public void SetStoredSourcePath(string path) + { + ValidateSource(path); + EditorPrefs.SetString(EditorPrefKeys.PackageDeploySourcePath, Path.GetFullPath(path)); + } + + public void ClearStoredSourcePath() + { + EditorPrefs.DeleteKey(EditorPrefKeys.PackageDeploySourcePath); + } + + public string GetTargetPath() + { + // Prefer Package Manager resolved path for the installed package + var packageInfo = PackageInfo.FindForAssembly(typeof(PackageDeploymentService).Assembly); + if (packageInfo != null) + { + if (!string.IsNullOrEmpty(packageInfo.resolvedPath) && Directory.Exists(packageInfo.resolvedPath)) + { + return packageInfo.resolvedPath; + } + + if (!string.IsNullOrEmpty(packageInfo.assetPath)) + { + string absoluteFromAsset = MakeAbsolute(packageInfo.assetPath); + if (Directory.Exists(absoluteFromAsset)) + { + return absoluteFromAsset; + } + } + } + + // Fallback to computed package root + string packageRoot = AssetPathUtility.GetMcpPackageRootPath(); + if (!string.IsNullOrEmpty(packageRoot)) + { + string absolutePath = MakeAbsolute(packageRoot); + if (Directory.Exists(absolutePath)) + { + return absolutePath; + } + } + + return null; + } + + public string GetTargetDisplayPath() + { + string target = GetTargetPath(); + if (string.IsNullOrEmpty(target)) + return "Not found (check Packages/manifest.json)"; + // Use forward slashes to avoid backslash escape sequence issues in UI text + return target.Replace('\\', '/'); + } + + public string GetLastBackupPath() + { + return EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty); + } + + public bool HasBackup() + { + string path = GetLastBackupPath(); + return !string.IsNullOrEmpty(path) && Directory.Exists(path); + } + + public PackageDeploymentResult DeployFromStoredSource() + { + string sourcePath = GetStoredSourcePath(); + if (string.IsNullOrEmpty(sourcePath)) + { + return Fail("Select a MCPForUnity folder first."); + } + + string validationError = ValidateSource(sourcePath, throwOnError: false); + if (!string.IsNullOrEmpty(validationError)) + { + return Fail(validationError); + } + + string targetPath = GetTargetPath(); + if (string.IsNullOrEmpty(targetPath)) + { + return Fail("Could not locate the installed MCP package. Check Packages/manifest.json."); + } + + if (PathsEqual(sourcePath, targetPath)) + { + return Fail("Source and target are the same. Choose a different MCPForUnity folder."); + } + + try + { + EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Creating backup...", 0.25f); + string backupPath = CreateBackup(targetPath); + + EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Replacing package contents...", 0.7f); + CopyCoreFolders(sourcePath, targetPath); + + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastBackupPath, backupPath); + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastTargetPath, targetPath); + EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastSourcePath, sourcePath); + + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + return Success("Deployment completed.", sourcePath, targetPath, backupPath); + } + catch (Exception ex) + { + McpLog.Error($"Deployment failed: {ex.Message}"); + return Fail($"Deployment failed: {ex.Message}"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + public PackageDeploymentResult RestoreLastBackup() + { + string backupPath = GetLastBackupPath(); + string targetPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastTargetPath, string.Empty); + + if (string.IsNullOrEmpty(backupPath) || !Directory.Exists(backupPath)) + { + return Fail("No backup available to restore."); + } + + if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath)) + { + targetPath = GetTargetPath(); + } + + if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath)) + { + return Fail("Could not locate target package path."); + } + + try + { + EditorUtility.DisplayProgressBar("Restore MCP for Unity", "Restoring backup...", 0.5f); + ReplaceDirectory(backupPath, targetPath); + + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + return Success("Restore completed.", null, targetPath, backupPath); + } + catch (Exception ex) + { + McpLog.Error($"Restore failed: {ex.Message}"); + return Fail($"Restore failed: {ex.Message}"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + private void CopyCoreFolders(string sourceRoot, string targetRoot) + { + string sourceEditor = Path.Combine(sourceRoot, "Editor"); + string sourceRuntime = Path.Combine(sourceRoot, "Runtime"); + + ReplaceDirectory(sourceEditor, Path.Combine(targetRoot, "Editor")); + ReplaceDirectory(sourceRuntime, Path.Combine(targetRoot, "Runtime")); + } + + private static void ReplaceDirectory(string source, string destination) + { + if (Directory.Exists(destination)) + { + FileUtil.DeleteFileOrDirectory(destination); + } + + FileUtil.CopyFileOrDirectory(source, destination); + } + + private string CreateBackup(string targetPath) + { + string backupRoot = Path.Combine(GetProjectRoot(), "Library", BackupRootFolderName); + Directory.CreateDirectory(backupRoot); + + string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string backupPath = Path.Combine(backupRoot, $"backup_{stamp}"); + + if (Directory.Exists(backupPath)) + { + FileUtil.DeleteFileOrDirectory(backupPath); + } + + FileUtil.CopyFileOrDirectory(targetPath, backupPath); + return backupPath; + } + + private static string ValidateSource(string sourcePath, bool throwOnError = true) + { + if (string.IsNullOrEmpty(sourcePath)) + { + if (throwOnError) + { + throw new ArgumentException("Source path cannot be empty."); + } + + return "Source path is empty."; + } + + if (!Directory.Exists(sourcePath)) + { + if (throwOnError) + { + throw new ArgumentException("Selected folder does not exist."); + } + + return "Selected folder does not exist."; + } + + bool hasEditor = Directory.Exists(Path.Combine(sourcePath, "Editor")); + bool hasRuntime = Directory.Exists(Path.Combine(sourcePath, "Runtime")); + + if (!hasEditor || !hasRuntime) + { + string message = "Folder must contain Editor and Runtime subfolders."; + if (throwOnError) + { + throw new ArgumentException(message); + } + + return message; + } + + return null; + } + + private static string MakeAbsolute(string assetPath) + { + assetPath = assetPath.Replace('\\', '/'); + + if (assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath)); + } + + if (assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath)); + } + + return Path.GetFullPath(assetPath); + } + + private static string GetProjectRoot() + { + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + + private static bool PathsEqual(string a, string b) + { + string normA = Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string normB = Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(normA, normB, StringComparison.OrdinalIgnoreCase); + } + + private static PackageDeploymentResult Success(string message, string source, string target, string backup) + { + return new PackageDeploymentResult + { + Success = true, + Message = message, + SourcePath = source, + TargetPath = target, + BackupPath = backup + }; + } + + private static PackageDeploymentResult Fail(string message) + { + return new PackageDeploymentResult + { + Success = false, + Message = message + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta b/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta new file mode 100644 index 0000000..24a0982 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/PackageDeploymentService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs b/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs new file mode 100644 index 0000000..81c0161 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs @@ -0,0 +1,185 @@ +using System; +using System.Net; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for checking package updates from GitHub or Asset Store metadata + /// + public class PackageUpdateService : IPackageUpdateService + { + private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck; + private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion; + private const string LastAssetStoreCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck; + private const string CachedAssetStoreVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion; + private const string PackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json"; + private const string AssetStoreVersionUrl = "https://gqoqjkkptwfbkwyssmnj.supabase.co/storage/v1/object/public/coplay-images/assetstoreversion.json"; + + /// + public UpdateCheckResult CheckForUpdate(string currentVersion) + { + bool isGitInstallation = IsGitInstallation(); + string lastCheckDate = EditorPrefs.GetString(isGitInstallation ? LastCheckDateKey : LastAssetStoreCheckDateKey, ""); + string cachedLatestVersion = EditorPrefs.GetString(isGitInstallation ? CachedVersionKey : CachedAssetStoreVersionKey, ""); + + if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion)) + { + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = cachedLatestVersion, + UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion), + Message = "Using cached version check" + }; + } + + string latestVersion = isGitInstallation + ? FetchLatestVersionFromGitHub() + : FetchLatestVersionFromAssetStoreJson(); + + if (!string.IsNullOrEmpty(latestVersion)) + { + // Cache the result + EditorPrefs.SetString(isGitInstallation ? LastCheckDateKey : LastAssetStoreCheckDateKey, DateTime.Now.ToString("yyyy-MM-dd")); + EditorPrefs.SetString(isGitInstallation ? CachedVersionKey : CachedAssetStoreVersionKey, latestVersion); + + return new UpdateCheckResult + { + CheckSucceeded = true, + LatestVersion = latestVersion, + UpdateAvailable = IsNewerVersion(latestVersion, currentVersion), + Message = "Successfully checked for updates" + }; + } + + return new UpdateCheckResult + { + CheckSucceeded = false, + UpdateAvailable = false, + Message = isGitInstallation + ? "Failed to check for updates (network issue or offline)" + : "Failed to check for Asset Store updates (network issue or offline)" + }; + } + + /// + public bool IsNewerVersion(string version1, string version2) + { + try + { + // Remove any "v" prefix + version1 = version1.TrimStart('v', 'V'); + version2 = version2.TrimStart('v', 'V'); + + var version1Parts = version1.Split('.'); + var version2Parts = version2.Split('.'); + + for (int i = 0; i < Math.Min(version1Parts.Length, version2Parts.Length); i++) + { + if (int.TryParse(version1Parts[i], out int v1Num) && + int.TryParse(version2Parts[i], out int v2Num)) + { + if (v1Num > v2Num) return true; + if (v1Num < v2Num) return false; + } + } + return false; + } + catch + { + return false; + } + } + + /// + public virtual bool IsGitInstallation() + { + // Git packages are installed via Package Manager and have a package.json in Packages/ + // Asset Store packages are in Assets/ + string packageRoot = AssetPathUtility.GetMcpPackageRootPath(); + + if (string.IsNullOrEmpty(packageRoot)) + { + return false; + } + + // If the package is in Packages/ it's a PM install (likely Git) + // If it's in Assets/ it's an Asset Store install + return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase); + } + + /// + public void ClearCache() + { + EditorPrefs.DeleteKey(LastCheckDateKey); + EditorPrefs.DeleteKey(CachedVersionKey); + EditorPrefs.DeleteKey(LastAssetStoreCheckDateKey); + EditorPrefs.DeleteKey(CachedAssetStoreVersionKey); + } + + /// + /// Fetches the latest version from GitHub's main branch package.json + /// + protected virtual string FetchLatestVersionFromGitHub() + { + try + { + // GitHub API endpoint (Option 1 - has rate limits): + // https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest + // + // We use Option 2 (package.json directly) because: + // - No API rate limits (GitHub serves raw files freely) + // - Simpler - just parse JSON for version field + // - More reliable - doesn't require releases to be published + // - Direct source of truth from the main branch + + using (var client = new WebClient()) + { + client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker"); + string jsonContent = client.DownloadString(PackageJsonUrl); + + var packageJson = JObject.Parse(jsonContent); + string version = packageJson["version"]?.ToString(); + + return string.IsNullOrEmpty(version) ? null : version; + } + } + catch (Exception ex) + { + // Silent fail - don't interrupt the user if network is unavailable + McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}"); + return null; + } + } + + /// + /// Fetches the latest Asset Store version from a hosted JSON file. + /// + protected virtual string FetchLatestVersionFromAssetStoreJson() + { + try + { + using (var client = new WebClient()) + { + client.Headers.Add("User-Agent", "Unity-MCPForUnity-AssetStoreUpdateChecker"); + string jsonContent = client.DownloadString(AssetStoreVersionUrl); + + var versionJson = JObject.Parse(jsonContent); + string version = versionJson["version"]?.ToString(); + + return string.IsNullOrEmpty(version) ? null : version; + } + } + catch (Exception ex) + { + // Silent fail - don't interrupt the user if network is unavailable + McpLog.Info($"Asset Store update check failed (this is normal if offline): {ex.Message}"); + return null; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs.meta b/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs.meta new file mode 100644 index 0000000..fd46cab --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7c3c2304b14e9485ca54182fad73b035 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/PackageUpdateService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/PathResolverService.cs b/Assets/MCPForUnity/Editor/Services/PathResolverService.cs new file mode 100644 index 0000000..4181cf4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PathResolverService.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Implementation of path resolver service with override support + /// + public class PathResolverService : IPathResolverService + { + private bool _hasUvxPathFallback; + + public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null)); + public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null)); + public bool HasUvxPathFallback => _hasUvxPathFallback; + + public string GetUvxPath() + { + // Reset fallback flag at the start of each resolution + _hasUvxPathFallback = false; + + // Check override first - only validate if explicitly set + if (HasUvxPathOverride) + { + string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty); + // Validate the override - if invalid, fall back to system discovery + if (TryValidateUvxExecutable(overridePath, out string version)) + { + return overridePath; + } + // Override is set but invalid - fall back to system discovery + string fallbackPath = ResolveUvxFromSystem(); + if (!string.IsNullOrEmpty(fallbackPath)) + { + _hasUvxPathFallback = true; + return fallbackPath; + } + // Return null to indicate override is invalid and no system fallback found + return null; + } + + // No override set - try discovery (uvx first, then uv) + string discovered = ResolveUvxFromSystem(); + if (!string.IsNullOrEmpty(discovered)) + { + return discovered; + } + + // Fallback to bare command + return "uvx"; + } + + /// + /// Resolves uv/uvx from system by trying both commands. + /// Returns the full path if found, null otherwise. + /// + private static string ResolveUvxFromSystem() + { + try + { + // Try uvx first, then uv + string[] commandNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "uvx.exe", "uv.exe" } + : new[] { "uvx", "uv" }; + + foreach (string commandName in commandNames) + { + foreach (string candidate in EnumerateCommandCandidates(commandName)) + { + if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) + { + return candidate; + } + } + } + } + catch (Exception ex) + { + McpLog.Debug($"PathResolver error: {ex.Message}"); + } + + return null; + } + + + + public string GetClaudeCliPath() + { + // Check override first - only validate if explicitly set + if (HasClaudeCliPathOverride) + { + string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty); + // Validate the override - if invalid, don't fall back to discovery + if (File.Exists(overridePath)) + { + return overridePath; + } + // Override is set but invalid - return null (no fallback) + return null; + } + + // No override - use platform-specific discovery + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string[] candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), + "claude.exe" + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string[] candidates = new[] + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string[] candidates = new[] + { + "/usr/bin/claude", + "/usr/local/bin/claude", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") + }; + + foreach (var c in candidates) + { + if (File.Exists(c)) return c; + } + } + + return null; + } + + public bool IsPythonDetected() + { + return ExecPath.TryRun( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3", + "--version", + null, + out _, + out _, + 2000); + } + + public bool IsClaudeCliDetected() + { + return !string.IsNullOrEmpty(GetClaudeCliPath()); + } + + public void SetUvxPathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearUvxPathOverride(); + return; + } + + if (!File.Exists(path)) + { + throw new ArgumentException("The selected uvx executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path); + } + + public void SetClaudeCliPathOverride(string path) + { + if (string.IsNullOrEmpty(path)) + { + ClearClaudeCliPathOverride(); + return; + } + + if (!File.Exists(path)) + { + throw new ArgumentException("The selected Claude CLI executable does not exist"); + } + + EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path); + } + + public void ClearUvxPathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride); + } + + public void ClearClaudeCliPathOverride() + { + EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride); + } + + /// + /// Validates the provided uv executable by running "--version" and parsing the output. + /// + /// Absolute or relative path to the uv/uvx executable. + /// Parsed version string if successful. + /// True when the executable runs and returns a uvx version string. + public bool TryValidateUvxExecutable(string uvxPath, out string version) + { + version = null; + + if (string.IsNullOrEmpty(uvxPath)) + return false; + + try + { + // Check if the path is just a command name (no directory separator) + bool isBareCommand = !uvxPath.Contains('/') && !uvxPath.Contains('\\'); + + if (isBareCommand) + { + // For bare commands like "uvx" or "uv", use EnumerateCommandCandidates to find full path first + string fullPath = FindUvxExecutableInPath(uvxPath); + if (string.IsNullOrEmpty(fullPath)) + return false; + uvxPath = fullPath; + } + + // Use ExecPath.TryRun which properly handles async output reading and timeouts + if (!ExecPath.TryRun(uvxPath, "--version", null, out string stdout, out string stderr, 5000)) + return false; + + // Check stdout first, then stderr (some tools output to stderr) + string versionOutput = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim(); + + // uv/uvx outputs "uv x.y.z" or "uvx x.y.z", extract version number + if (versionOutput.StartsWith("uvx ") || versionOutput.StartsWith("uv ")) + { + // Extract version: "uv 0.9.18 (hash date)" -> "0.9.18" + int spaceIndex = versionOutput.IndexOf(' '); + if (spaceIndex >= 0) + { + string afterCommand = versionOutput.Substring(spaceIndex + 1).Trim(); + // Version is up to the first space or parenthesis + int nextSpace = afterCommand.IndexOf(' '); + int parenIndex = afterCommand.IndexOf('('); + int endIndex = Math.Min( + nextSpace >= 0 ? nextSpace : int.MaxValue, + parenIndex >= 0 ? parenIndex : int.MaxValue + ); + version = endIndex < int.MaxValue ? afterCommand.Substring(0, endIndex).Trim() : afterCommand; + return true; + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private string FindUvxExecutableInPath(string commandName) + { + try + { + // Generic search for any command in PATH and common locations + foreach (string candidate in EnumerateCommandCandidates(commandName)) + { + if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) + { + return candidate; + } + } + } + catch + { + // Ignore errors + } + + return null; + } + + /// + /// Enumerates candidate paths for a generic command name. + /// Searches PATH and common locations. + /// + private static IEnumerable EnumerateCommandCandidates(string commandName) + { + string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !commandName.EndsWith(".exe") + ? commandName + ".exe" + : commandName; + + // Search PATH first + string pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(pathEnv)) + { + foreach (string rawDir in pathEnv.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(rawDir)) continue; + string dir = rawDir.Trim(); + yield return Path.Combine(dir, exeName); + } + } + + // User-local binary directories + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + { + yield return Path.Combine(home, ".local", "bin", exeName); + yield return Path.Combine(home, ".cargo", "bin", exeName); + } + + // System directories (platform-specific) + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + yield return "/opt/homebrew/bin/" + exeName; + yield return "/usr/local/bin/" + exeName; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + yield return "/usr/local/bin/" + exeName; + yield return "/usr/bin/" + exeName; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + if (!string.IsNullOrEmpty(localAppData)) + { + yield return Path.Combine(localAppData, "Programs", "uv", exeName); + // WinGet creates shim files in this location + yield return Path.Combine(localAppData, "Microsoft", "WinGet", "Links", exeName); + } + + if (!string.IsNullOrEmpty(programFiles)) + { + yield return Path.Combine(programFiles, "uv", exeName); + } + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/PathResolverService.cs.meta b/Assets/MCPForUnity/Editor/Services/PathResolverService.cs.meta new file mode 100644 index 0000000..1a35323 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PathResolverService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 00a6188fd15a847fa8cc7cb7a4ce3dce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/PathResolverService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/PlatformService.cs b/Assets/MCPForUnity/Editor/Services/PlatformService.cs new file mode 100644 index 0000000..6e66371 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PlatformService.cs @@ -0,0 +1,31 @@ +using System; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Default implementation of platform detection service + /// + public class PlatformService : IPlatformService + { + /// + /// Checks if the current platform is Windows + /// + /// True if running on Windows + public bool IsWindows() + { + return Environment.OSVersion.Platform == PlatformID.Win32NT; + } + + /// + /// Gets the SystemRoot environment variable (Windows-specific) + /// + /// SystemRoot path, or "C:\\Windows" as fallback on Windows, null on other platforms + public string GetSystemRoot() + { + if (!IsWindows()) + return null; + + return Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/PlatformService.cs.meta b/Assets/MCPForUnity/Editor/Services/PlatformService.cs.meta new file mode 100644 index 0000000..3b5fa83 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/PlatformService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 3b2d7f32a595c45dd8c01f141c69761c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/PlatformService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs b/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs new file mode 100644 index 0000000..d964259 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Resources; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + public class ResourceDiscoveryService : IResourceDiscoveryService + { + private Dictionary _cachedResources; + + public List DiscoverAllResources() + { + if (_cachedResources != null) + { + return _cachedResources.Values.ToList(); + } + + _cachedResources = new Dictionary(); + + var resourceTypes = TypeCache.GetTypesWithAttribute(); + foreach (var type in resourceTypes) + { + McpForUnityResourceAttribute resourceAttr; + try + { + resourceAttr = type.GetCustomAttribute(); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read [McpForUnityResource] for {type.FullName}: {ex.Message}"); + continue; + } + + if (resourceAttr == null) + { + continue; + } + + var metadata = ExtractResourceMetadata(type, resourceAttr); + if (metadata != null) + { + if (_cachedResources.ContainsKey(metadata.Name)) + { + McpLog.Warn($"Duplicate resource name '{metadata.Name}' from {type.FullName}; overwriting previous registration."); + } + _cachedResources[metadata.Name] = metadata; + EnsurePreferenceInitialized(metadata); + } + } + + McpLog.Info($"Discovered {_cachedResources.Count} MCP resources via reflection", false); + return _cachedResources.Values.ToList(); + } + + public ResourceMetadata GetResourceMetadata(string resourceName) + { + if (string.IsNullOrEmpty(resourceName)) + { + return null; + } + + if (_cachedResources == null) + { + DiscoverAllResources(); + } + + return _cachedResources.TryGetValue(resourceName, out var metadata) ? metadata : null; + } + + public List GetEnabledResources() + { + return DiscoverAllResources() + .Where(r => IsResourceEnabled(r.Name)) + .ToList(); + } + + public bool IsResourceEnabled(string resourceName) + { + if (string.IsNullOrEmpty(resourceName)) + { + return false; + } + + string key = GetResourcePreferenceKey(resourceName); + if (EditorPrefs.HasKey(key)) + { + return EditorPrefs.GetBool(key, true); + } + + // Default: all resources enabled + return true; + } + + public void SetResourceEnabled(string resourceName, bool enabled) + { + if (string.IsNullOrEmpty(resourceName)) + { + return; + } + + string key = GetResourcePreferenceKey(resourceName); + EditorPrefs.SetBool(key, enabled); + } + + public void InvalidateCache() + { + _cachedResources = null; + } + + private ResourceMetadata ExtractResourceMetadata(Type type, McpForUnityResourceAttribute resourceAttr) + { + try + { + string resourceName = resourceAttr.ResourceName; + if (string.IsNullOrEmpty(resourceName)) + { + resourceName = StringCaseUtility.ToSnakeCase(type.Name); + } + + string description = resourceAttr.Description ?? $"Resource: {resourceName}"; + + var metadata = new ResourceMetadata + { + Name = resourceName, + Description = description, + ClassName = type.Name, + Namespace = type.Namespace ?? "", + AssemblyName = type.Assembly.GetName().Name + }; + + metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType( + type, metadata.AssemblyName, "MCPForUnity.Editor.Resources"); + + return metadata; + } + catch (Exception ex) + { + McpLog.Error($"Failed to extract metadata for resource {type.Name}: {ex.Message}"); + return null; + } + } + + private void EnsurePreferenceInitialized(ResourceMetadata metadata) + { + if (metadata == null || string.IsNullOrEmpty(metadata.Name)) + { + return; + } + + string key = GetResourcePreferenceKey(metadata.Name); + if (!EditorPrefs.HasKey(key)) + { + EditorPrefs.SetBool(key, true); + } + } + + private static string GetResourcePreferenceKey(string resourceName) + { + return EditorPrefKeys.ResourceEnabledPrefix + resourceName; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs.meta b/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs.meta new file mode 100644 index 0000000..664e411 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 66ce49d2cc47a4bd3aa85ac9f099b757 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/ResourceDiscoveryService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server.meta b/Assets/MCPForUnity/Editor/Services/Server.meta new file mode 100644 index 0000000..e1e1dd4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bb072befc9fe4242a501f46dce3fea1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs b/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs new file mode 100644 index 0000000..b9bd74b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs @@ -0,0 +1,94 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for managing PID files and handshake state for the local HTTP server. + /// Handles persistence of server process information across Unity domain reloads. + /// + public interface IPidFileManager + { + /// + /// Gets the directory where PID files are stored. + /// + /// Path to the PID file directory + string GetPidDirectory(); + + /// + /// Gets the path to the PID file for a specific port. + /// + /// The port number + /// Full path to the PID file + string GetPidFilePath(int port); + + /// + /// Attempts to read the PID from a PID file. + /// + /// Path to the PID file + /// Output: the process ID if found + /// True if a valid PID was read + bool TryReadPid(string pidFilePath, out int pid); + + /// + /// Attempts to extract the port number from a PID file path. + /// + /// Path to the PID file + /// Output: the port number + /// True if the port was extracted successfully + bool TryGetPortFromPidFilePath(string pidFilePath, out int port); + + /// + /// Deletes a PID file. + /// + /// Path to the PID file to delete + void DeletePidFile(string pidFilePath); + + /// + /// Stores the handshake information (PID file path and instance token) in EditorPrefs. + /// + /// Path to the PID file + /// Unique instance token for the server + void StoreHandshake(string pidFilePath, string instanceToken); + + /// + /// Attempts to retrieve stored handshake information from EditorPrefs. + /// + /// Output: stored PID file path + /// Output: stored instance token + /// True if valid handshake information was found + bool TryGetHandshake(out string pidFilePath, out string instanceToken); + + /// + /// Stores PID tracking information in EditorPrefs. + /// + /// The process ID + /// The port number + /// Optional hash of the command arguments + void StoreTracking(int pid, int port, string argsHash = null); + + /// + /// Attempts to retrieve a stored PID for the expected port. + /// Validates that the stored information is still valid (within 6-hour window). + /// + /// The expected port number + /// Output: the stored process ID + /// True if a valid stored PID was found + bool TryGetStoredPid(int expectedPort, out int pid); + + /// + /// Gets the stored args hash for the tracked server. + /// + /// The stored args hash, or empty string if not found + string GetStoredArgsHash(); + + /// + /// Clears all PID tracking information from EditorPrefs. + /// + void ClearTracking(); + + /// + /// Computes a short hash of the input string for fingerprinting. + /// + /// The input string + /// A short hash string (16 hex characters) + string ComputeShortHash(string input); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta new file mode 100644 index 0000000..2470b00 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f4a4c5d093da74ce79fb29a0670a58a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/IPidFileManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs b/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs new file mode 100644 index 0000000..886e29d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for platform-specific process inspection operations. + /// Provides methods to detect MCP server processes, query process command lines, + /// and find processes listening on specific ports. + /// + public interface IProcessDetector + { + /// + /// Determines if a process looks like an MCP server process based on its command line. + /// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc. + /// + /// The process ID to check + /// True if the process appears to be an MCP server + bool LooksLikeMcpServerProcess(int pid); + + /// + /// Attempts to get the command line arguments for a Unix process. + /// + /// The process ID + /// Output: normalized (lowercase, whitespace removed) command line args + /// True if the command line was retrieved successfully + bool TryGetProcessCommandLine(int pid, out string argsLower); + + /// + /// Gets the process IDs of all processes listening on a specific TCP port. + /// + /// The port number to check + /// List of process IDs listening on the port + List GetListeningProcessIdsForPort(int port); + + /// + /// Gets the current Unity Editor process ID safely. + /// + /// The current process ID, or -1 if it cannot be determined + int GetCurrentProcessId(); + + /// + /// Checks if a process exists on Unix systems. + /// + /// The process ID to check + /// True if the process exists + bool ProcessExists(int pid); + + /// + /// Normalizes a string for matching by removing whitespace and converting to lowercase. + /// + /// The input string + /// Normalized string for matching + string NormalizeForMatch(string input); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta new file mode 100644 index 0000000..cef8c50 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 25f32875fb87541b69ead19c08520836 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/IProcessDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs b/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs new file mode 100644 index 0000000..0f6e9f8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs @@ -0,0 +1,18 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for platform-specific process termination. + /// Provides methods to terminate processes gracefully or forcefully. + /// + public interface IProcessTerminator + { + /// + /// Terminates a process using platform-appropriate methods. + /// On Unix: Tries SIGTERM first with grace period, then SIGKILL. + /// On Windows: Tries taskkill, then taskkill /F. + /// + /// The process ID to terminate + /// True if the process was terminated successfully + bool Terminate(int pid); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta new file mode 100644 index 0000000..c802068 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6a55c18e08b534afa85654410da8a463 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs b/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs new file mode 100644 index 0000000..f32b1eb --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs @@ -0,0 +1,39 @@ +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for building uvx/server command strings. + /// Handles platform-specific command construction for starting the MCP HTTP server. + /// + public interface IServerCommandBuilder + { + /// + /// Attempts to build the command parts for starting the local HTTP server. + /// + /// Output: the executable file name (e.g., uvx path) + /// Output: the command arguments + /// Output: the full command string for display + /// Output: error message if the command cannot be built + /// True if the command was built successfully + bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error); + + /// + /// Builds the uv path from the uvx path by replacing uvx with uv. + /// + /// Path to uvx executable + /// Path to uv executable + string BuildUvPathFromUvx(string uvxPath); + + /// + /// Gets the platform-specific PATH prepend string for finding uv/uvx. + /// + /// Paths to prepend to PATH environment variable + string GetPlatformSpecificPathPrepend(); + + /// + /// Quotes a string if it contains spaces. + /// + /// The input string + /// The string, wrapped in quotes if it contains spaces + string QuoteIfNeeded(string input); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta new file mode 100644 index 0000000..a8c76f2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 12e80005e3f5b45239c48db981675ccf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/IServerCommandBuilder.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs b/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs new file mode 100644 index 0000000..3a89684 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Interface for launching commands in platform-specific terminal windows. + /// Supports macOS Terminal, Windows cmd, and Linux terminal emulators. + /// + public interface ITerminalLauncher + { + /// + /// Creates a ProcessStartInfo for opening a terminal window with the given command. + /// Works cross-platform: macOS, Windows, and Linux. + /// + /// The command to execute in the terminal + /// A configured ProcessStartInfo for launching the terminal + ProcessStartInfo CreateTerminalProcessStartInfo(string command); + + /// + /// Gets the project root path for storing terminal scripts. + /// + /// Path to the project root directory + string GetProjectRootPath(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta new file mode 100644 index 0000000..f87d18a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: a5990e868c0cd4999858ce1c1a2defed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/ITerminalLauncher.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs b/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs new file mode 100644 index 0000000..eca60ee --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs @@ -0,0 +1,275 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Constants; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Manages PID files and handshake state for the local HTTP server. + /// Handles persistence of server process information across Unity domain reloads. + /// + public class PidFileManager : IPidFileManager + { + /// + public string GetPidDirectory() + { + return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState"); + } + + /// + public string GetPidFilePath(int port) + { + string dir = GetPidDirectory(); + Directory.CreateDirectory(dir); + return Path.Combine(dir, $"mcp_http_{port}.pid"); + } + + /// + public bool TryReadPid(string pidFilePath, out int pid) + { + pid = 0; + try + { + if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath)) + { + return false; + } + + string text = File.ReadAllText(pidFilePath).Trim(); + if (int.TryParse(text, out pid)) + { + return pid > 0; + } + + // Best-effort: tolerate accidental extra whitespace/newlines. + var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (int.TryParse(firstLine, out pid)) + { + return pid > 0; + } + + pid = 0; + return false; + } + catch + { + pid = 0; + return false; + } + } + + /// + public bool TryGetPortFromPidFilePath(string pidFilePath, out int port) + { + port = 0; + if (string.IsNullOrEmpty(pidFilePath)) + { + return false; + } + + try + { + string fileName = Path.GetFileNameWithoutExtension(pidFilePath); + if (string.IsNullOrEmpty(fileName)) + { + return false; + } + + const string prefix = "mcp_http_"; + if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string portText = fileName.Substring(prefix.Length); + return int.TryParse(portText, out port) && port > 0; + } + catch + { + port = 0; + return false; + } + } + + /// + public void DeletePidFile(string pidFilePath) + { + try + { + if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath)) + { + File.Delete(pidFilePath); + } + } + catch { } + } + + /// + public void StoreHandshake(string pidFilePath, string instanceToken) + { + try + { + if (!string.IsNullOrEmpty(pidFilePath)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath); + } + } + catch { } + + try + { + if (!string.IsNullOrEmpty(instanceToken)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken); + } + } + catch { } + } + + /// + public bool TryGetHandshake(out string pidFilePath, out string instanceToken) + { + pidFilePath = null; + instanceToken = null; + try + { + pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty); + instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty); + if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken)) + { + pidFilePath = null; + instanceToken = null; + return false; + } + return true; + } + catch + { + pidFilePath = null; + instanceToken = null; + return false; + } + } + + /// + public void StoreTracking(int pid, int port, string argsHash = null) + { + try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { } + try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { } + try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { } + try + { + if (!string.IsNullOrEmpty(argsHash)) + { + EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); + } + } + catch { } + } + + /// + public bool TryGetStoredPid(int expectedPort, out int pid) + { + pid = 0; + try + { + int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0); + int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0); + string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty); + + if (storedPid <= 0 || storedPort != expectedPort) + { + return false; + } + + // Only trust the stored PID for a short window to avoid PID reuse issues. + // (We still verify the PID is listening on the expected port before killing.) + if (!string.IsNullOrEmpty(storedUtc) + && DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt)) + { + if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6)) + { + return false; + } + } + + pid = storedPid; + return true; + } + catch + { + return false; + } + } + + /// + public string GetStoredArgsHash() + { + try + { + return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty); + } + catch + { + return string.Empty; + } + } + + /// + public void ClearTracking() + { + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { } + } + + /// + public string ComputeShortHash(string input) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + try + { + using var sha = SHA256.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input); + byte[] hash = sha.ComputeHash(bytes); + // 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes. + var sb = new StringBuilder(16); + for (int i = 0; i < 8 && i < hash.Length; i++) + { + sb.Append(hash[i].ToString("x2")); + } + return sb.ToString(); + } + catch + { + return string.Empty; + } + } + + private static string GetProjectRootPath() + { + try + { + // Application.dataPath is "...//Assets" + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + catch + { + return Application.dataPath; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta new file mode 100644 index 0000000..cd97e43 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 57875f281fda94a4ea17cb74d4b13378 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/PidFileManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs b/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs new file mode 100644 index 0000000..b553cb3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Platform-specific process inspection for detecting MCP server processes. + /// + public class ProcessDetector : IProcessDetector + { + /// + public string NormalizeForMatch(string input) + { + if (string.IsNullOrEmpty(input)) return string.Empty; + var sb = new StringBuilder(input.Length); + foreach (char c in input) + { + if (char.IsWhiteSpace(c)) continue; + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + /// + public int GetCurrentProcessId() + { + try { return System.Diagnostics.Process.GetCurrentProcess().Id; } + catch { return -1; } + } + + /// + public bool ProcessExists(int pid) + { + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // On Windows, use tasklist to check if process exists + bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000); + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant(); + return ok && combined.Contains(pid.ToString()); + } + + // Unix: ps exits non-zero when PID is not found. + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var psStdout, out var psStderr, 2000); + string combined2 = ((psStdout ?? string.Empty) + "\n" + (psStderr ?? string.Empty)).Trim(); + return !string.IsNullOrEmpty(combined2) && combined2.Any(char.IsDigit); + } + catch + { + return true; // Assume it exists if we cannot verify. + } + } + + /// + public bool TryGetProcessCommandLine(int pid, out string argsLower) + { + argsLower = string.Empty; + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Windows: use wmic to get command line + ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000); + string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)); + if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.ToLowerInvariant().Contains("commandline=")) + { + argsLower = NormalizeForMatch(wmicOut ?? string.Empty); + return true; + } + return false; + } + + // Unix: ps -p pid -ww -o args= + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + + bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000); + if (!ok && string.IsNullOrWhiteSpace(stdout)) + { + return false; + } + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim(); + if (string.IsNullOrEmpty(combined)) return false; + // Normalize for matching to tolerate ps wrapping/newlines. + argsLower = NormalizeForMatch(combined); + return true; + } + catch + { + return false; + } + } + + /// + public List GetListeningProcessIdsForPort(int port) + { + var results = new List(); + try + { + string stdout, stderr; + bool success; + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Run netstat -ano directly (without findstr) and filter in C#. + // Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found, + // which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success. + success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr); + + // Process stdout regardless of success flag - netstat might still produce valid output + if (!string.IsNullOrEmpty(stdout)) + { + string portSuffix = $":{port}"; + var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + // Windows netstat format: Proto Local Address Foreign Address State PID + // Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345 + if (line.Contains("LISTENING") && line.Contains(portSuffix)) + { + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + // Verify the local address column actually ends with :{port} + // parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID + if (parts.Length >= 5) + { + string localAddr = parts[1]; + if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int parsedPid)) + { + results.Add(parsedPid); + } + } + } + } + } + } + else + { + // lsof: only return LISTENers (avoids capturing random clients) + // Use /usr/sbin/lsof directly as it might not be in PATH for Unity + string lsofPath = "/usr/sbin/lsof"; + if (!File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback + + // -nP: avoid DNS/service name lookups; faster and less error-prone + success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr); + if (success && !string.IsNullOrWhiteSpace(stdout)) + { + var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var pidString in pidStrings) + { + if (int.TryParse(pidString.Trim(), out int parsedPid)) + { + results.Add(parsedPid); + } + } + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Error checking port {port}: {ex.Message}"); + } + return results.Distinct().ToList(); + } + + /// + public bool LooksLikeMcpServerProcess(int pid) + { + try + { + // Windows best-effort: First check process name with tasklist, then try to get command line with wmic + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Step 1: Check if process name matches known server executables + ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000); + string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant(); + + // Check for common process names + bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe"); + if (!isPythonOrUv) + { + return false; + } + + // Step 2: Try to get command line with wmic for better validation + ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000); + string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant(); + string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty); + + // If we can see the command line, validate it's our server + if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline=")) + { + bool mentionsMcp = wmicCompact.Contains("mcp-for-unity") + || wmicCompact.Contains("mcp_for_unity") + || wmicCompact.Contains("mcpforunity") + || wmicCompact.Contains("mcpforunityserver"); + bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http")); + bool mentionsUvicorn = wmicCombined.Contains("uvicorn"); + + if (mentionsMcp || mentionsTransport || mentionsUvicorn) + { + return true; + } + } + + // Fall back to just checking for python/uv processes if wmic didn't give us details + // This is less precise but necessary for cases where wmic access is restricted + return isPythonOrUv; + } + + // macOS/Linux: ps -p pid -ww -o comm= -o args= + // Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity'). + // Use an absolute ps path to avoid relying on PATH inside the Unity Editor process. + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + // Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful. + // Always parse stdout/stderr regardless of exit code to avoid false negatives. + ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000); + string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim(); + string s = raw.ToLowerInvariant(); + string sCompact = NormalizeForMatch(raw); + if (!string.IsNullOrEmpty(s)) + { + bool mentionsMcp = sCompact.Contains("mcp-for-unity") + || sCompact.Contains("mcp_for_unity") + || sCompact.Contains("mcpforunity"); + + // If it explicitly mentions the server package/entrypoint, that is sufficient. + // Note: Check before Unity exclusion since "mcp-for-unity" contains "unity". + if (mentionsMcp) + { + return true; + } + + // Explicitly never kill Unity / Unity Hub processes + // Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above. + if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp)) + { + return false; + } + + // Positive indicators + bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx "); + bool mentionsUv = s.Contains("uv ") || s.Contains("/uv"); + bool mentionsPython = s.Contains("python"); + bool mentionsUvicorn = s.Contains("uvicorn"); + bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http")); + + // Accept if it looks like uv/uvx/python launching our server package/entrypoint + if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport) + { + return true; + } + } + } + catch { } + + return false; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta new file mode 100644 index 0000000..6af7311 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4df6fa24a35d74d1cb9b67e40e50b45d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/ProcessDetector.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs b/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs new file mode 100644 index 0000000..7e803b1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Platform-specific process termination for stopping MCP server processes. + /// + public class ProcessTerminator : IProcessTerminator + { + private readonly IProcessDetector _processDetector; + + /// + /// Creates a new ProcessTerminator with the specified process detector. + /// + /// Process detector for checking process existence + public ProcessTerminator(IProcessDetector processDetector) + { + _processDetector = processDetector ?? throw new ArgumentNullException(nameof(processDetector)); + } + + /// + public bool Terminate(int pid) + { + // CRITICAL: Validate PID before any kill operation. + // On Unix, kill(-1) kills ALL processes the user can signal! + // On Unix, kill(0) signals all processes in the process group. + // PID 1 is init/launchd and must never be killed. + // Only positive PIDs > 1 are valid for targeted termination. + if (pid <= 1) + { + return false; + } + + // Never kill the current Unity process + int currentPid = _processDetector.GetCurrentProcessId(); + if (currentPid > 0 && pid == currentPid) + { + return false; + } + + try + { + string stdout, stderr; + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // taskkill without /F first; fall back to /F if needed. + bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr); + if (!ok) + { + ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr); + } + return ok; + } + else + { + // Try a graceful termination first, then escalate if the process is still alive. + // Note: `kill -15` can succeed (exit 0) even if the process takes time to exit, + // so we verify and only escalate when needed. + string killPath = "/bin/kill"; + if (!File.Exists(killPath)) killPath = "kill"; + ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr); + + // Wait briefly for graceful shutdown. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8); + while (DateTime.UtcNow < deadline) + { + if (!_processDetector.ProcessExists(pid)) + { + return true; + } + System.Threading.Thread.Sleep(100); + } + + // Escalate. + ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr); + return !_processDetector.ProcessExists(pid); + } + } + catch (Exception ex) + { + McpLog.Error($"Error killing process {pid}: {ex.Message}"); + return false; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta new file mode 100644 index 0000000..0adb4cd --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 900df88b4d0844704af9cb47633d44a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs b/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs new file mode 100644 index 0000000..47b4675 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Builds uvx/server command strings for starting the MCP HTTP server. + /// Handles platform-specific command construction. + /// + public class ServerCommandBuilder : IServerCommandBuilder + { + /// + public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error) + { + fileName = null; + arguments = null; + displayCommand = null; + error = null; + + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + if (!useHttpTransport) + { + error = "HTTP transport is disabled. Enable it in the MCP For Unity window first."; + return false; + } + + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost."; + return false; + } + + var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts(); + if (string.IsNullOrEmpty(uvxPath)) + { + error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings."; + return false; + } + + // Use central helper that checks both DevModeForceServerRefresh AND local path detection. + // Note: --reinstall is not supported by uvx, use --no-cache --refresh instead + string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty; + bool projectScopedTools = EditorPrefs.GetBool( + EditorPrefKeys.ProjectScopedToolsLocalHttp, + true + ); + string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty; + + // Use centralized helper for beta server / prerelease args + string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true); + + string args = string.IsNullOrEmpty(fromArgs) + ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}" + : $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}"; + + fileName = uvxPath; + arguments = args; + displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}"; + return true; + } + + /// + public string BuildUvPathFromUvx(string uvxPath) + { + if (string.IsNullOrWhiteSpace(uvxPath)) + { + return uvxPath; + } + + string directory = Path.GetDirectoryName(uvxPath); + string extension = Path.GetExtension(uvxPath); + string uvFileName = "uv" + extension; + + return string.IsNullOrEmpty(directory) + ? uvFileName + : Path.Combine(directory, uvFileName); + } + + /// + public string GetPlatformSpecificPathPrepend() + { + if (Application.platform == RuntimePlatform.OSXEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.LinuxEditor) + { + return string.Join(Path.PathSeparator.ToString(), new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + } + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + return string.Join(Path.PathSeparator.ToString(), new[] + { + !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null, + !string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null + }.Where(p => !string.IsNullOrEmpty(p)).ToArray()); + } + + return null; + } + + /// + public string QuoteIfNeeded(string input) + { + if (string.IsNullOrEmpty(input)) return input; + return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input; + } + + /// + /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1) + /// + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) return false; + + try + { + var uri = new Uri(url); + string host = uri.Host.ToLower(); + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + } + catch + { + return false; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta new file mode 100644 index 0000000..55b0cac --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: db917800a5c2948088ede8a5d230b56e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/ServerCommandBuilder.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs b/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs new file mode 100644 index 0000000..fd8bd5d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Server +{ + /// + /// Launches commands in platform-specific terminal windows. + /// Supports macOS Terminal, Windows cmd, and Linux terminal emulators. + /// + public class TerminalLauncher : ITerminalLauncher + { + /// + public string GetProjectRootPath() + { + try + { + // Application.dataPath is "...//Assets" + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + catch + { + return Application.dataPath; + } + } + + /// + public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) + { + if (string.IsNullOrWhiteSpace(command)) + throw new ArgumentException("Command cannot be empty", nameof(command)); + + command = command.Replace("\r", "").Replace("\n", ""); + +#if UNITY_EDITOR_OSX + // macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it. + string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); + Directory.CreateDirectory(scriptsDir); + string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command"); + File.WriteAllText( + scriptPath, + "#!/bin/bash\n" + + "set -e\n" + + "clear\n" + + $"{command}\n"); + ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/open", + Arguments = $"-a Terminal \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#elif UNITY_EDITOR_WIN + // Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window. + string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); + Directory.CreateDirectory(scriptsDir); + string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd"); + File.WriteAllText( + scriptPath, + "@echo off\r\n" + + "cls\r\n" + + command + "\r\n"); + return new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; +#else + // Linux: Try common terminal emulators + // We use bash -c to execute the command, so we must properly quote/escape for bash + // Escape single quotes for the inner bash string + string escapedCommandLinux = command.Replace("'", "'\\''"); + // Wrap the command in single quotes for bash -c + string script = $"'{escapedCommandLinux}; exec bash'"; + // Escape double quotes for the outer Process argument string + string escapedScriptForArg = script.Replace("\"", "\\\""); + string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\""; + + string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" }; + string terminalCmd = null; + + foreach (var term in terminals) + { + try + { + var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "which", + Arguments = term, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous + if (which.ExitCode == 0) + { + terminalCmd = term; + break; + } + } + catch { } + } + + if (terminalCmd == null) + { + terminalCmd = "xterm"; // Fallback + } + + // Different terminals have different argument formats + string args; + if (terminalCmd == "gnome-terminal") + { + args = $"-- {bashCmdArgs}"; + } + else if (terminalCmd == "konsole") + { + args = $"-e {bashCmdArgs}"; + } + else if (terminalCmd == "xfce4-terminal") + { + // xfce4-terminal expects -e "command string" or -e command arg + args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\""; + } + else // xterm and others + { + args = $"-hold -e {bashCmdArgs}"; + } + + return new System.Diagnostics.ProcessStartInfo + { + FileName = terminalCmd, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true + }; +#endif + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta b/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta new file mode 100644 index 0000000..23d6743 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d9693a18d706548b3aae28ea87f1ed08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Server/TerminalLauncher.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs b/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs new file mode 100644 index 0000000..1df3384 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -0,0 +1,876 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Server; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Service for managing MCP server lifecycle + /// + public class ServerManagementService : IServerManagementService + { + private readonly IProcessDetector _processDetector; + private readonly IPidFileManager _pidFileManager; + private readonly IProcessTerminator _processTerminator; + private readonly IServerCommandBuilder _commandBuilder; + private readonly ITerminalLauncher _terminalLauncher; + + /// + /// Creates a new ServerManagementService with default dependencies. + /// + public ServerManagementService() : this(null, null, null, null, null) { } + + /// + /// Creates a new ServerManagementService with injected dependencies (for testing). + /// + /// Process detector implementation (null for default) + /// PID file manager implementation (null for default) + /// Process terminator implementation (null for default) + /// Server command builder implementation (null for default) + /// Terminal launcher implementation (null for default) + public ServerManagementService( + IProcessDetector processDetector, + IPidFileManager pidFileManager = null, + IProcessTerminator processTerminator = null, + IServerCommandBuilder commandBuilder = null, + ITerminalLauncher terminalLauncher = null) + { + _processDetector = processDetector ?? new ProcessDetector(); + _pidFileManager = pidFileManager ?? new PidFileManager(); + _processTerminator = processTerminator ?? new ProcessTerminator(_processDetector); + _commandBuilder = commandBuilder ?? new ServerCommandBuilder(); + _terminalLauncher = terminalLauncher ?? new TerminalLauncher(); + } + + private string QuoteIfNeeded(string s) + { + return _commandBuilder.QuoteIfNeeded(s); + } + + private string NormalizeForMatch(string s) + { + return _processDetector.NormalizeForMatch(s); + } + + private void ClearLocalServerPidTracking() + { + _pidFileManager.ClearTracking(); + } + + private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken) + { + _pidFileManager.StoreHandshake(pidFilePath, instanceToken); + } + + private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken) + { + return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken); + } + + private string GetLocalHttpServerPidFilePath(int port) + { + return _pidFileManager.GetPidFilePath(port); + } + + private bool TryReadPidFromPidFile(string pidFilePath, out int pid) + { + return _pidFileManager.TryReadPid(pidFilePath, out pid); + } + + private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken) + { + containsToken = false; + if (pid <= 0 || string.IsNullOrEmpty(instanceToken)) + { + return false; + } + + try + { + string tokenNeedle = instanceToken.ToLowerInvariant(); + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Query full command line so we can validate token (reduces PID reuse risk). + // Use CIM via PowerShell (wmic is deprecated). + string ps = $"(Get-CimInstance Win32_Process -Filter \\\"ProcessId={pid}\\\").CommandLine"; + bool ok = ExecPath.TryRun("powershell", $"-NoProfile -Command \"{ps}\"", Application.dataPath, out var stdout, out var stderr, 5000); + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant(); + containsToken = combined.Contains(tokenNeedle); + return ok; + } + + if (TryGetUnixProcessArgs(pid, out var argsLowerNow)) + { + containsToken = argsLowerNow.Contains(NormalizeForMatch(tokenNeedle)); + return true; + } + } + catch { } + + return false; + } + + private string ComputeShortHash(string input) + { + return _pidFileManager.ComputeShortHash(input); + } + + private bool TryGetStoredLocalServerPid(int expectedPort, out int pid) + { + return _pidFileManager.TryGetStoredPid(expectedPort, out pid); + } + + private string GetStoredArgsHash() + { + return _pidFileManager.GetStoredArgsHash(); + } + + /// + /// Clear the local uvx cache for the MCP server package + /// + /// True if successful, false otherwise + public bool ClearUvxCache() + { + try + { + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string uvCommand = BuildUvPathFromUvx(uvxPath); + + // Get the package name + string packageName = "mcp-for-unity"; + + // Run uvx cache clean command + string args = $"cache clean {packageName}"; + + bool success; + string stdout; + string stderr; + + success = ExecuteUvCommand(uvCommand, args, out stdout, out stderr); + + if (success) + { + McpLog.Info($"uv cache cleared successfully: {stdout}"); + return true; + } + string combinedOutput = string.Join( + Environment.NewLine, + new[] { stderr, stdout }.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim())); + + string lockHint = (!string.IsNullOrEmpty(combinedOutput) && + combinedOutput.IndexOf("currently in-use", StringComparison.OrdinalIgnoreCase) >= 0) + ? "Another uv process may be holding the cache lock; wait a moment and try again or clear with '--force' from a terminal." + : string.Empty; + + if (string.IsNullOrEmpty(combinedOutput)) + { + combinedOutput = "Command failed with no output. Ensure uv is installed, on PATH, or set an override in Advanced Settings."; + } + + McpLog.Error( + $"Failed to clear uv cache using '{uvCommand} {args}'. " + + $"Details: {combinedOutput}{(string.IsNullOrEmpty(lockHint) ? string.Empty : " Hint: " + lockHint)}"); + return false; + } + catch (Exception ex) + { + McpLog.Error($"Error clearing uv cache: {ex.Message}"); + return false; + } + } + + private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, out string stderr) + { + stdout = null; + stderr = null; + + string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); + string uvPath = BuildUvPathFromUvx(uvxPath); + + if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) + { + return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000); + } + + string command = $"{uvPath} {args}"; + string extraPathPrepend = GetPlatformSpecificPathPrepend(); + + if (Application.platform == RuntimePlatform.WindowsEditor) + { + return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; + + if (!string.IsNullOrEmpty(shell) && File.Exists(shell)) + { + string escaped = command.Replace("\"", "\\\""); + return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); + } + + private string BuildUvPathFromUvx(string uvxPath) + { + return _commandBuilder.BuildUvPathFromUvx(uvxPath); + } + + private string GetPlatformSpecificPathPrepend() + { + return _commandBuilder.GetPlatformSpecificPathPrepend(); + } + + /// + /// Start the local HTTP server in a separate terminal window. + /// Stops any existing server on the port and clears the uvx cache first. + /// + public bool StartLocalHttpServer() + { + /// Clean stale Python build artifacts when using a local dev server path + AssetPathUtility.CleanLocalServerBuildArtifacts(); + + if (!TryGetLocalHttpServerCommandParts(out _, out _, out var displayCommand, out var error)) + { + EditorUtility.DisplayDialog( + "Cannot Start HTTP Server", + error ?? "The server command could not be constructed with the current settings.", + "OK"); + return false; + } + + // First, try to stop any existing server (quietly; we'll only warn if the port remains occupied). + StopLocalHttpServerInternal(quiet: true); + + // If the port is still occupied, don't start and explain why (avoid confusing "refusing to stop" warnings). + try + { + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) && uri.Port > 0) + { + var remaining = GetListeningProcessIdsForPort(uri.Port); + if (remaining.Count > 0) + { + EditorUtility.DisplayDialog( + "Port In Use", + $"Cannot start the local HTTP server because port {uri.Port} is already in use by PID(s): " + + $"{string.Join(", ", remaining)}\n\n" + + "MCP For Unity will not terminate unrelated processes. Stop the owning process manually or change the HTTP URL.", + "OK"); + return false; + } + } + } + catch { } + + // Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command. + + // Create a per-launch token + pidfile path so Stop can be deterministic without relying on port/PID heuristics. + string baseUrlForPid = HttpEndpointUtility.GetLocalBaseUrl(); + Uri.TryCreate(baseUrlForPid, UriKind.Absolute, out var uriForPid); + int portForPid = uriForPid?.Port ?? 0; + string instanceToken = Guid.NewGuid().ToString("N"); + string pidFilePath = portForPid > 0 ? GetLocalHttpServerPidFilePath(portForPid) : null; + + string launchCommand = displayCommand; + if (!string.IsNullOrEmpty(pidFilePath)) + { + launchCommand = $"{displayCommand} --pidfile {QuoteIfNeeded(pidFilePath)} --unity-instance-token {instanceToken}"; + } + + if (EditorUtility.DisplayDialog( + "Start Local HTTP Server", + $"This will start the MCP server in HTTP mode in a new terminal window:\n\n{launchCommand}\n\n" + + "Continue?", + "Start Server", + "Cancel")) + { + try + { + // Clear any stale handshake state from prior launches. + ClearLocalServerPidTracking(); + + // Best-effort: delete stale pidfile if it exists. + try + { + if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath)) + { + DeletePidFile(pidFilePath); + } + } + catch { } + + // Launch the server in a new terminal window (keeps user-visible logs). + var startInfo = CreateTerminalProcessStartInfo(launchCommand); + System.Diagnostics.Process.Start(startInfo); + if (!string.IsNullOrEmpty(pidFilePath)) + { + StoreLocalHttpServerHandshake(pidFilePath, instanceToken); + } + McpLog.Info($"Started local HTTP server in terminal: {launchCommand}"); + return true; + } + catch (Exception ex) + { + McpLog.Error($"Failed to start server: {ex.Message}"); + EditorUtility.DisplayDialog( + "Error", + $"Failed to start server: {ex.Message}", + "OK"); + return false; + } + } + + return false; + } + + /// + /// Stop the local HTTP server by finding the process listening on the configured port + /// + public bool StopLocalHttpServer() + { + return StopLocalHttpServerInternal(quiet: false); + } + + public bool StopManagedLocalHttpServer() + { + if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _)) + { + return false; + } + + int port = 0; + if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0) + { + string baseUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (IsLocalUrl(baseUrl) + && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) + && uri.Port > 0) + { + port = uri.Port; + } + } + + if (port <= 0) + { + return false; + } + + return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true); + } + + public bool IsLocalHttpServerRunning() + { + try + { + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + return false; + } + + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0) + { + return false; + } + + int port = uri.Port; + + // Handshake path: if we have a pidfile+token and the PID is still the listener, treat as running. + if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken) + && TryReadPidFromPidFile(pidFilePath, out var pidFromFile) + && pidFromFile > 0) + { + var pidsNow = GetListeningProcessIdsForPort(port); + if (pidsNow.Contains(pidFromFile)) + { + return true; + } + } + + var pids = GetListeningProcessIdsForPort(port); + if (pids.Count == 0) + { + return false; + } + + // Strong signal: stored PID is still the listener. + if (TryGetStoredLocalServerPid(port, out int storedPid) && storedPid > 0) + { + if (pids.Contains(storedPid)) + { + return true; + } + } + + // Best-effort: if anything listening looks like our server, treat as running. + foreach (var pid in pids) + { + if (pid <= 0) continue; + if (LooksLikeMcpServerProcess(pid)) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + + public bool IsLocalHttpServerReachable() + { + try + { + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + return false; + } + + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0) + { + return false; + } + + return TryConnectToLocalPort(uri.Host, uri.Port, timeoutMs: 50); + } + catch + { + return false; + } + } + + private static bool TryConnectToLocalPort(string host, int port, int timeoutMs) + { + try + { + if (string.IsNullOrEmpty(host)) + { + host = "127.0.0.1"; + } + + var hosts = new HashSet(StringComparer.OrdinalIgnoreCase) { host }; + if (host == "localhost" || host == "0.0.0.0") + { + hosts.Add("127.0.0.1"); + } + if (host == "::" || host == "0:0:0:0:0:0:0:0") + { + hosts.Add("::1"); + } + + foreach (var target in hosts) + { + try + { + using (var client = new TcpClient()) + { + var connectTask = client.ConnectAsync(target, port); + if (connectTask.Wait(timeoutMs) && client.Connected) + { + return true; + } + } + } + catch + { + // Ignore per-host failures. + } + } + } + catch + { + // Ignore probe failures and treat as unreachable. + } + + return false; + } + + private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, bool allowNonLocalUrl = false) + { + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + if (!allowNonLocalUrl && !IsLocalUrl(httpUrl)) + { + if (!quiet) + { + McpLog.Warn("Cannot stop server: URL is not local."); + } + return false; + } + + try + { + int port = 0; + if (portOverride.HasValue) + { + port = portOverride.Value; + } + else + { + var uri = new Uri(httpUrl); + port = uri.Port; + } + + if (port <= 0) + { + if (!quiet) + { + McpLog.Warn("Cannot stop server: Invalid port."); + } + return false; + } + + // Guardrails: + // - Never terminate the Unity Editor process. + // - Only terminate processes that look like the MCP server (uv/uvx/python running mcp-for-unity). + // This prevents accidental termination of unrelated services (including Unity itself). + int unityPid = GetCurrentProcessIdSafe(); + bool stoppedAny = false; + + // Preferred deterministic stop path: if we have a pidfile+token from a Unity-managed launch, + // validate and terminate exactly that PID. + if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken)) + { + // Prefer deterministic stop when Unity started the server (pidfile+token). + // If the pidfile isn't available yet (fast quit after start), we can optionally fall back + // to port-based heuristics when a port override was supplied (managed-stop path). + if (!TryReadPidFromPidFile(pidFilePath, out var pidFromFile) || pidFromFile <= 0) + { + if (!portOverride.HasValue) + { + if (!quiet) + { + McpLog.Warn( + $"Cannot stop local HTTP server on port {port}: pidfile not available yet at '{pidFilePath}'. " + + "If you just started the server, wait a moment and try again."); + } + return false; + } + + // Managed-stop fallback: proceed with port-based heuristics below. + // We intentionally do NOT clear handshake state here; it will be cleared if we successfully + // stop a server process and/or the port is freed. + } + else + { + // Never kill Unity/Hub. + if (unityPid > 0 && pidFromFile == unityPid) + { + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: pidfile PID {pidFromFile} is the Unity Editor process."); + } + } + else + { + var listeners = GetListeningProcessIdsForPort(port); + if (listeners.Count == 0) + { + // Nothing is listening anymore; clear stale handshake state. + try { DeletePidFile(pidFilePath); } catch { } + ClearLocalServerPidTracking(); + if (!quiet) + { + McpLog.Info($"No process found listening on port {port}"); + } + return false; + } + bool pidIsListener = listeners.Contains(pidFromFile); + bool tokenQueryOk = TryProcessCommandLineContainsInstanceToken(pidFromFile, instanceToken, out bool tokenMatches); + bool allowKill; + if (tokenQueryOk) + { + allowKill = tokenMatches; + } + else + { + // If token validation is unavailable (e.g. Windows CIM permission issues), + // fall back to a stricter heuristic: only allow stop if the PID still looks like our server. + allowKill = LooksLikeMcpServerProcess(pidFromFile); + } + + if (pidIsListener && allowKill) + { + if (TerminateProcess(pidFromFile)) + { + stoppedAny = true; + try { DeletePidFile(pidFilePath); } catch { } + ClearLocalServerPidTracking(); + if (!quiet) + { + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pidFromFile})"); + } + return true; + } + if (!quiet) + { + McpLog.Warn($"Failed to terminate local HTTP server on port {port} (PID: {pidFromFile})."); + } + return false; + } + if (!quiet) + { + McpLog.Warn( + $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " + + $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk})."); + } + return false; + } + } + } + + var pids = GetListeningProcessIdsForPort(port); + if (pids.Count == 0) + { + if (stoppedAny) + { + // We stopped what Unity started; the port is now free. + if (!quiet) + { + McpLog.Info($"Stopped local HTTP server on port {port}"); + } + ClearLocalServerPidTracking(); + return true; + } + + if (!quiet) + { + McpLog.Info($"No process found listening on port {port}"); + } + ClearLocalServerPidTracking(); + return false; + } + + // Prefer killing the PID that we previously observed binding this port (if still valid). + if (TryGetStoredLocalServerPid(port, out int storedPid)) + { + if (pids.Contains(storedPid)) + { + string expectedHash = string.Empty; + expectedHash = GetStoredArgsHash(); + + // Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs), + // fall back to a looser check to avoid leaving orphaned servers after domain reload. + if (TryGetUnixProcessArgs(storedPid, out var storedArgsLowerNow)) + { + // Never kill Unity/Hub. + // Note: "mcp-for-unity" includes "unity", so detect MCP indicators first. + bool storedMentionsMcp = storedArgsLowerNow.Contains("mcp-for-unity") + || storedArgsLowerNow.Contains("mcp_for_unity") + || storedArgsLowerNow.Contains("mcpforunity"); + if (storedArgsLowerNow.Contains("unityhub") + || storedArgsLowerNow.Contains("unity hub") + || (storedArgsLowerNow.Contains("unity") && !storedMentionsMcp)) + { + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} appears to be a Unity process."); + } + } + else + { + bool allowKill = false; + if (!string.IsNullOrEmpty(expectedHash)) + { + allowKill = string.Equals(expectedHash, ComputeShortHash(storedArgsLowerNow), StringComparison.OrdinalIgnoreCase); + } + else + { + // Older versions didn't store a fingerprint; accept common server indicators. + allowKill = storedArgsLowerNow.Contains("uvicorn") + || storedArgsLowerNow.Contains("fastmcp") + || storedArgsLowerNow.Contains("mcpforunity") + || storedArgsLowerNow.Contains("mcp-for-unity") + || storedArgsLowerNow.Contains("mcp_for_unity") + || storedArgsLowerNow.Contains("uvx") + || storedArgsLowerNow.Contains("python"); + } + + if (allowKill && TerminateProcess(storedPid)) + { + if (!quiet) + { + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})"); + } + stoppedAny = true; + ClearLocalServerPidTracking(); + // Refresh the PID list to avoid double-work. + pids = GetListeningProcessIdsForPort(port); + } + else if (!allowKill && !quiet) + { + McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} did not match expected server fingerprint."); + } + } + } + } + else + { + // Stale PID (no longer listening). Clear. + ClearLocalServerPidTracking(); + } + } + + foreach (var pid in pids) + { + if (pid <= 0) continue; + if (unityPid > 0 && pid == unityPid) + { + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid})."); + } + continue; + } + + if (!LooksLikeMcpServerProcess(pid)) + { + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity."); + } + continue; + } + + if (TerminateProcess(pid)) + { + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})"); + stoppedAny = true; + } + else + { + if (!quiet) + { + McpLog.Warn($"Failed to stop process PID {pid} on port {port}"); + } + } + } + + if (stoppedAny) + { + ClearLocalServerPidTracking(); + } + return stoppedAny; + } + catch (Exception ex) + { + if (!quiet) + { + McpLog.Error($"Failed to stop server: {ex.Message}"); + } + return false; + } + } + + private bool TryGetUnixProcessArgs(int pid, out string argsLower) + { + return _processDetector.TryGetProcessCommandLine(pid, out argsLower); + } + + private bool TryGetPortFromPidFilePath(string pidFilePath, out int port) + { + return _pidFileManager.TryGetPortFromPidFilePath(pidFilePath, out port); + } + + private void DeletePidFile(string pidFilePath) + { + _pidFileManager.DeletePidFile(pidFilePath); + } + + private List GetListeningProcessIdsForPort(int port) + { + return _processDetector.GetListeningProcessIdsForPort(port); + } + + private int GetCurrentProcessIdSafe() + { + return _processDetector.GetCurrentProcessId(); + } + + private bool LooksLikeMcpServerProcess(int pid) + { + return _processDetector.LooksLikeMcpServerProcess(pid); + } + + private bool TerminateProcess(int pid) + { + return _processTerminator.Terminate(pid); + } + + /// + /// Attempts to build the command used for starting the local HTTP server + /// + public bool TryGetLocalHttpServerCommand(out string command, out string error) + { + command = null; + error = null; + if (!TryGetLocalHttpServerCommandParts(out var fileName, out var args, out var displayCommand, out error)) + { + return false; + } + + // Maintain existing behavior: return a single command string suitable for display/copy. + command = displayCommand; + return true; + } + + private bool TryGetLocalHttpServerCommandParts(out string fileName, out string arguments, out string displayCommand, out string error) + { + return _commandBuilder.TryBuildCommand(out fileName, out arguments, out displayCommand, out error); + } + + /// + /// Check if the configured HTTP URL is a local address + /// + public bool IsLocalUrl() + { + string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); + return IsLocalUrl(httpUrl); + } + + /// + /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0) + /// + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url)) return false; + + try + { + var uri = new Uri(url); + string host = uri.Host.ToLower(); + return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; + } + catch + { + return false; + } + } + + /// + /// Check if the local HTTP server can be started + /// + public bool CanStartLocalServer() + { + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + return useHttpTransport && IsLocalUrl(); + } + + private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) + { + return _terminalLauncher.CreateTerminalProcessStartInfo(command); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs.meta b/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs.meta new file mode 100644 index 0000000..f5af4a1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ServerManagementService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8e60df35c5a76462d8aaa8078da86d75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/ServerManagementService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs b/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs new file mode 100644 index 0000000..a7c5f39 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs @@ -0,0 +1,116 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Services.Transport.Transports; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Ensures the legacy stdio bridge resumes after domain reloads, mirroring the HTTP handler. + /// + [InitializeOnLoad] + internal static class StdioBridgeReloadHandler + { + static StdioBridgeReloadHandler() + { + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + } + + private static void OnBeforeAssemblyReload() + { + try + { + // Only persist resume intent when stdio is the active transport and the bridge is running. + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + // Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost + // bypassing TransportManager state. + bool tmRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio); + bool hostRunning = StdioBridgeHost.IsRunning; + bool isRunning = tmRunning || hostRunning; + bool shouldResume = !useHttp && isRunning; + + if (shouldResume) + { + EditorPrefs.SetBool(EditorPrefKeys.ResumeStdioAfterReload, true); + + // Stop only the stdio bridge; leave HTTP untouched if it is running concurrently. + var stopTask = MCPServiceLocator.TransportManager.StopAsync(TransportMode.Stdio); + + // Wait for stop to complete (which deletes the status file) + try { stopTask.Wait(500); } catch { } + + // Write reloading status so clients don't think we vanished + StdioBridgeHost.WriteHeartbeat(true, "reloading"); + } + else + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to persist stdio reload flag: {ex.Message}"); + } + } + + private static void OnAfterAssemblyReload() + { + bool resume = false; + try + { + bool resumeFlag = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false); + bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport; + resume = resumeFlag && !useHttp; + + // If we're not going to resume, clear the flag immediately to avoid stuck "Resuming..." state + if (!resume) + { + EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); + } + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read stdio reload flag: {ex.Message}"); + } + + if (!resume) + { + return; + } + + // Restart via TransportManager so state stays in sync; if it fails (port busy), rely on UI to retry. + TryStartBridgeImmediate(); + } + + private static void TryStartBridgeImmediate() + { + var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio); + startTask.ContinueWith(t => + { + // Clear the flag after attempting to start (success or failure). + // This prevents getting stuck in "Resuming..." state. + // We do this synchronously on the continuation thread - it's safe because + // EditorPrefs operations are thread-safe and any new reload will set the flag + // fresh in OnBeforeAssemblyReload before we get here. + try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { } + + if (t.IsFaulted) + { + var baseEx = t.Exception?.GetBaseException(); + McpLog.Warn($"Failed to resume stdio bridge after reload: {baseEx?.Message}"); + return; + } + if (!t.Result) + { + McpLog.Warn("Failed to resume stdio bridge after domain reload"); + return; + } + + MCPForUnity.Editor.Windows.MCPForUnityEditorWindow.RequestHealthVerification(); + }, System.Threading.Tasks.TaskScheduler.Default); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs.meta b/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs.meta new file mode 100644 index 0000000..5efc355 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6e603c72a87974cf5b495cd683165fbf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/TestJobManager.cs b/Assets/MCPForUnity/Editor/Services/TestJobManager.cs new file mode 100644 index 0000000..bf2ffec --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestJobManager.cs @@ -0,0 +1,673 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using UnityEditor; +using UnityEditorInternal; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Services +{ + internal enum TestJobStatus + { + Running, + Succeeded, + Failed + } + + internal sealed class TestJobFailure + { + public string FullName { get; set; } + public string Message { get; set; } + } + + internal sealed class TestJob + { + public string JobId { get; set; } + public TestJobStatus Status { get; set; } + public string Mode { get; set; } + public long StartedUnixMs { get; set; } + public long? FinishedUnixMs { get; set; } + public long LastUpdateUnixMs { get; set; } + public int? TotalTests { get; set; } + public int CompletedTests { get; set; } + public string CurrentTestFullName { get; set; } + public long? CurrentTestStartedUnixMs { get; set; } + public string LastFinishedTestFullName { get; set; } + public long? LastFinishedUnixMs { get; set; } + public List FailuresSoFar { get; set; } + public string Error { get; set; } + public TestRunResult Result { get; set; } + } + + /// + /// Tracks async test jobs started via MCP tools. This is not intended to capture manual Test Runner UI runs. + /// + internal static class TestJobManager + { + // Keep this small to avoid ballooning payloads during polling. + private const int FailureCap = 25; + private const long StuckThresholdMs = 60_000; + private const long InitializationTimeoutMs = 15_000; // 15 seconds to call OnRunStarted, else fail + private const int MaxJobsToKeep = 10; + private const long MinPersistIntervalMs = 1000; // Throttle persistence to reduce overhead + + // SessionState survives domain reloads within the same Unity Editor session. + private const string SessionKeyJobs = "MCPForUnity.TestJobsV1"; + private const string SessionKeyCurrentJobId = "MCPForUnity.CurrentTestJobIdV1"; + + private static readonly object LockObj = new(); + private static readonly Dictionary Jobs = new(); + private static string _currentJobId; + private static long _lastPersistUnixMs; + + static TestJobManager() + { + // Restore after domain reloads (e.g., compilation while a job is running). + TryRestoreFromSessionState(); + } + + public static string CurrentJobId + { + get { lock (LockObj) return _currentJobId; } + } + + public static bool HasRunningJob + { + get + { + lock (LockObj) + { + return !string.IsNullOrEmpty(_currentJobId); + } + } + } + + /// + /// Force-clears any stuck or orphaned test job. Call this when tests get stuck due to + /// assembly reloads or other interruptions. + /// + /// True if a job was cleared, false if no running job exists. + public static bool ClearStuckJob() + { + bool cleared = false; + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId)) + { + return false; + } + + if (Jobs.TryGetValue(_currentJobId, out var job) && job.Status == TestJobStatus.Running) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + job.Status = TestJobStatus.Failed; + job.Error = "Job cleared manually (stuck or orphaned)"; + job.FinishedUnixMs = now; + job.LastUpdateUnixMs = now; + McpLog.Warn($"[TestJobManager] Manually cleared stuck job {_currentJobId}"); + cleared = true; + } + + _currentJobId = null; + } + PersistToSessionState(force: true); + return cleared; + } + + private sealed class PersistedState + { + public string current_job_id { get; set; } + public List jobs { get; set; } + } + + private sealed class PersistedJob + { + public string job_id { get; set; } + public string status { get; set; } + public string mode { get; set; } + public long started_unix_ms { get; set; } + public long? finished_unix_ms { get; set; } + public long last_update_unix_ms { get; set; } + public int? total_tests { get; set; } + public int completed_tests { get; set; } + public string current_test_full_name { get; set; } + public long? current_test_started_unix_ms { get; set; } + public string last_finished_test_full_name { get; set; } + public long? last_finished_unix_ms { get; set; } + public List failures_so_far { get; set; } + public string error { get; set; } + } + + private static TestJobStatus ParseStatus(string status) + { + if (string.IsNullOrWhiteSpace(status)) + { + return TestJobStatus.Running; + } + + string s = status.Trim().ToLowerInvariant(); + return s switch + { + "succeeded" => TestJobStatus.Succeeded, + "failed" => TestJobStatus.Failed, + _ => TestJobStatus.Running + }; + } + + private static void TryRestoreFromSessionState() + { + try + { + string json = SessionState.GetString(SessionKeyJobs, string.Empty); + if (string.IsNullOrWhiteSpace(json)) + { + var legacy = SessionState.GetString(SessionKeyCurrentJobId, string.Empty); + _currentJobId = string.IsNullOrWhiteSpace(legacy) ? null : legacy; + return; + } + + var state = JsonConvert.DeserializeObject(json); + if (state?.jobs == null) + { + return; + } + + lock (LockObj) + { + Jobs.Clear(); + foreach (var pj in state.jobs) + { + if (pj == null || string.IsNullOrWhiteSpace(pj.job_id)) + { + continue; + } + + Jobs[pj.job_id] = new TestJob + { + JobId = pj.job_id, + Status = ParseStatus(pj.status), + Mode = pj.mode, + StartedUnixMs = pj.started_unix_ms, + FinishedUnixMs = pj.finished_unix_ms, + LastUpdateUnixMs = pj.last_update_unix_ms, + TotalTests = pj.total_tests, + CompletedTests = pj.completed_tests, + CurrentTestFullName = pj.current_test_full_name, + CurrentTestStartedUnixMs = pj.current_test_started_unix_ms, + LastFinishedTestFullName = pj.last_finished_test_full_name, + LastFinishedUnixMs = pj.last_finished_unix_ms, + FailuresSoFar = pj.failures_so_far ?? new List(), + Error = pj.error, + // Intentionally not persisted to avoid ballooning SessionState. + Result = null + }; + } + + _currentJobId = string.IsNullOrWhiteSpace(state.current_job_id) ? null : state.current_job_id; + if (!string.IsNullOrEmpty(_currentJobId) && !Jobs.ContainsKey(_currentJobId)) + { + _currentJobId = null; + } + + // Detect and clean up stale "running" jobs that were orphaned by domain reload. + // After a domain reload, TestRunStatus resets to not-running, but _currentJobId + // may still be set. If the job hasn't been updated recently, it's likely orphaned. + if (!string.IsNullOrEmpty(_currentJobId) && Jobs.TryGetValue(_currentJobId, out var currentJob)) + { + if (currentJob.Status == TestJobStatus.Running) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long staleCutoffMs = 5 * 60 * 1000; // 5 minutes + if (now - currentJob.LastUpdateUnixMs > staleCutoffMs) + { + McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)"); + currentJob.Status = TestJobStatus.Failed; + currentJob.Error = "Job orphaned after domain reload"; + currentJob.FinishedUnixMs = now; + _currentJobId = null; + } + } + } + } + } + catch (Exception ex) + { + // Restoration is best-effort; never block editor load. + McpLog.Warn($"[TestJobManager] Failed to restore SessionState: {ex.Message}"); + } + } + + private static void PersistToSessionState(bool force = false) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Throttle non-critical updates to reduce overhead during large test runs + if (!force && (now - _lastPersistUnixMs) < MinPersistIntervalMs) + { + return; + } + + try + { + PersistedState snapshot; + lock (LockObj) + { + var jobs = Jobs.Values + .OrderByDescending(j => j.LastUpdateUnixMs) + .Take(MaxJobsToKeep) + .Select(j => new PersistedJob + { + job_id = j.JobId, + status = j.Status.ToString().ToLowerInvariant(), + mode = j.Mode, + started_unix_ms = j.StartedUnixMs, + finished_unix_ms = j.FinishedUnixMs, + last_update_unix_ms = j.LastUpdateUnixMs, + total_tests = j.TotalTests, + completed_tests = j.CompletedTests, + current_test_full_name = j.CurrentTestFullName, + current_test_started_unix_ms = j.CurrentTestStartedUnixMs, + last_finished_test_full_name = j.LastFinishedTestFullName, + last_finished_unix_ms = j.LastFinishedUnixMs, + failures_so_far = (j.FailuresSoFar ?? new List()).Take(FailureCap).ToList(), + error = j.Error + }) + .ToList(); + + snapshot = new PersistedState + { + current_job_id = _currentJobId, + jobs = jobs + }; + } + + SessionState.SetString(SessionKeyCurrentJobId, snapshot.current_job_id ?? string.Empty); + SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot)); + _lastPersistUnixMs = now; + } + catch (Exception ex) + { + McpLog.Warn($"[TestJobManager] Failed to persist SessionState: {ex.Message}"); + } + } + + public static string StartJob(TestMode mode, TestFilterOptions filterOptions = null) + { + string jobId = Guid.NewGuid().ToString("N"); + long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + string modeStr = mode.ToString(); + + var job = new TestJob + { + JobId = jobId, + Status = TestJobStatus.Running, + Mode = modeStr, + StartedUnixMs = started, + FinishedUnixMs = null, + LastUpdateUnixMs = started, + TotalTests = null, + CompletedTests = 0, + CurrentTestFullName = null, + CurrentTestStartedUnixMs = null, + LastFinishedTestFullName = null, + LastFinishedUnixMs = null, + FailuresSoFar = new List(), + Error = null, + Result = null + }; + + // Single lock scope for check-and-set to avoid TOCTOU race + lock (LockObj) + { + if (!string.IsNullOrEmpty(_currentJobId)) + { + throw new InvalidOperationException("A Unity test run is already in progress."); + } + Jobs[jobId] = job; + _currentJobId = jobId; + } + PersistToSessionState(force: true); + + // Kick the run (must be called on main thread; our command handlers already run there). + Task task = MCPServiceLocator.Tests.RunTestsAsync(mode, filterOptions); + + void FinalizeJob(Action finalize) + { + // Ensure state mutation happens on main thread to avoid Unity API surprises. + EditorApplication.delayCall += () => + { + try { finalize(); } + catch (Exception ex) { McpLog.Error($"[TestJobManager] Finalize failed: {ex.Message}\n{ex.StackTrace}"); } + }; + } + + task.ContinueWith(t => + { + // NOTE: We now finalize jobs deterministically from the TestRunnerService RunFinished callback. + // This continuation is retained as a safety net in case RunFinished is not delivered. + FinalizeJob(() => FinalizeFromTask(jobId, t)); + }, TaskScheduler.Default); + + return jobId; + } + + public static void FinalizeCurrentJobFromRunFinished(TestRunResult resultPayload) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + + job.LastUpdateUnixMs = now; + job.FinishedUnixMs = now; + job.Status = resultPayload != null && resultPayload.Failed > 0 + ? TestJobStatus.Failed + : TestJobStatus.Succeeded; + job.Error = null; + job.Result = resultPayload; + job.CurrentTestFullName = null; + _currentJobId = null; + } + PersistToSessionState(force: true); + } + + public static void OnRunStarted(int? totalTests) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + + job.LastUpdateUnixMs = now; + job.TotalTests = totalTests; + job.CompletedTests = 0; + job.CurrentTestFullName = null; + job.CurrentTestStartedUnixMs = null; + job.LastFinishedTestFullName = null; + job.LastFinishedUnixMs = null; + job.FailuresSoFar ??= new List(); + job.FailuresSoFar.Clear(); + } + PersistToSessionState(force: true); + } + + public static void OnTestStarted(string testFullName) + { + if (string.IsNullOrWhiteSpace(testFullName)) + { + return; + } + + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + + job.LastUpdateUnixMs = now; + job.CurrentTestFullName = testFullName; + job.CurrentTestStartedUnixMs = now; + } + PersistToSessionState(); + } + + public static void OnLeafTestFinished(string testFullName, bool isFailure, string message) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + + job.LastUpdateUnixMs = now; + job.CompletedTests = Math.Max(0, job.CompletedTests + 1); + job.LastFinishedTestFullName = testFullName; + job.LastFinishedUnixMs = now; + + if (isFailure) + { + job.FailuresSoFar ??= new List(); + if (job.FailuresSoFar.Count < FailureCap) + { + job.FailuresSoFar.Add(new TestJobFailure + { + FullName = testFullName, + Message = string.IsNullOrWhiteSpace(message) ? "Test failed" : message + }); + } + } + } + PersistToSessionState(); + } + + public static void OnRunFinished() + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + lock (LockObj) + { + if (string.IsNullOrEmpty(_currentJobId) || !Jobs.TryGetValue(_currentJobId, out var job)) + { + return; + } + + job.LastUpdateUnixMs = now; + job.CurrentTestFullName = null; + } + PersistToSessionState(force: true); + } + + internal static TestJob GetJob(string jobId) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + return null; + } + + TestJob jobToReturn = null; + bool shouldPersist = false; + lock (LockObj) + { + if (!Jobs.TryGetValue(jobId, out var job)) + { + return null; + } + + // Check if job is stuck in "running" state without having called OnRunStarted (TotalTests still null). + // This happens when tests fail to initialize (e.g., unsaved scene, compilation issues). + // After 15 seconds without initialization, auto-fail the job to prevent hanging. + if (job.Status == TestJobStatus.Running && job.TotalTests == null) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (!EditorApplication.isCompiling && !EditorApplication.isUpdating && now - job.StartedUnixMs > InitializationTimeoutMs) + { + McpLog.Warn($"[TestJobManager] Job {jobId} failed to initialize within {InitializationTimeoutMs}ms, auto-failing"); + job.Status = TestJobStatus.Failed; + job.Error = "Test job failed to initialize (tests did not start within timeout)"; + job.FinishedUnixMs = now; + job.LastUpdateUnixMs = now; + if (_currentJobId == jobId) + { + _currentJobId = null; + } + shouldPersist = true; + } + } + + jobToReturn = job; + } + + if (shouldPersist) + { + PersistToSessionState(force: true); + } + return jobToReturn; + } + + internal static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests) + { + if (job == null) + { + return null; + } + + object resultPayload = null; + if (job.Status == TestJobStatus.Succeeded && job.Result != null) + { + resultPayload = job.Result.ToSerializable(job.Mode, includeDetails, includeFailedTests); + } + + return new + { + job_id = job.JobId, + status = job.Status.ToString().ToLowerInvariant(), + mode = job.Mode, + started_unix_ms = job.StartedUnixMs, + finished_unix_ms = job.FinishedUnixMs, + last_update_unix_ms = job.LastUpdateUnixMs, + progress = new + { + completed = job.CompletedTests, + total = job.TotalTests, + current_test_full_name = job.CurrentTestFullName, + current_test_started_unix_ms = job.CurrentTestStartedUnixMs, + last_finished_test_full_name = job.LastFinishedTestFullName, + last_finished_unix_ms = job.LastFinishedUnixMs, + stuck_suspected = IsStuck(job), + editor_is_focused = InternalEditorUtility.isApplicationActive, + blocked_reason = GetBlockedReason(job), + failures_so_far = BuildFailuresPayload(job.FailuresSoFar), + failures_capped = (job.FailuresSoFar != null && job.FailuresSoFar.Count >= FailureCap) + }, + error = job.Error, + result = resultPayload + }; + } + + private static string GetBlockedReason(TestJob job) + { + if (job == null || job.Status != TestJobStatus.Running) + { + return null; + } + + if (!IsStuck(job)) + { + return null; + } + + // This matches the real-world symptom you observed: background Unity can get heavily throttled by OS/Editor. + if (!InternalEditorUtility.isApplicationActive) + { + return "editor_unfocused"; + } + + if (EditorApplication.isCompiling) + { + return "compiling"; + } + + if (EditorApplication.isUpdating) + { + return "asset_import"; + } + + return "unknown"; + } + + private static bool IsStuck(TestJob job) + { + if (job == null || job.Status != TestJobStatus.Running) + { + return false; + } + + if (string.IsNullOrWhiteSpace(job.CurrentTestFullName) || !job.CurrentTestStartedUnixMs.HasValue) + { + return false; + } + + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return (now - job.CurrentTestStartedUnixMs.Value) > StuckThresholdMs; + } + + private static object[] BuildFailuresPayload(List failures) + { + if (failures == null || failures.Count == 0) + { + return Array.Empty(); + } + + var list = new object[failures.Count]; + for (int i = 0; i < failures.Count; i++) + { + var f = failures[i]; + list[i] = new { full_name = f?.FullName, message = f?.Message }; + } + return list; + } + + private static void FinalizeFromTask(string jobId, Task task) + { + lock (LockObj) + { + if (!Jobs.TryGetValue(jobId, out var existing)) + { + if (_currentJobId == jobId) _currentJobId = null; + return; + } + + // If RunFinished already finalized the job, do nothing. + if (existing.Status != TestJobStatus.Running) + { + if (_currentJobId == jobId) _currentJobId = null; + return; + } + + existing.LastUpdateUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + existing.FinishedUnixMs = existing.LastUpdateUnixMs; + + if (task.IsFaulted) + { + existing.Status = TestJobStatus.Failed; + existing.Error = task.Exception?.GetBaseException()?.Message ?? "Unknown test job failure"; + existing.Result = null; + } + else if (task.IsCanceled) + { + existing.Status = TestJobStatus.Failed; + existing.Error = "Test job canceled"; + existing.Result = null; + } + else + { + var result = task.Result; + existing.Status = result != null && result.Failed > 0 + ? TestJobStatus.Failed + : TestJobStatus.Succeeded; + existing.Error = null; + existing.Result = result; + } + + if (_currentJobId == jobId) + { + _currentJobId = null; + } + } + PersistToSessionState(force: true); + } + } +} + diff --git a/Assets/MCPForUnity/Editor/Services/TestJobManager.cs.meta b/Assets/MCPForUnity/Editor/Services/TestJobManager.cs.meta new file mode 100644 index 0000000..1b08674 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestJobManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2d7a9b8c0e1f4a6b9c3d2e1f0a9b8c7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/TestJobManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs b/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs new file mode 100644 index 0000000..da3ae6c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs @@ -0,0 +1,62 @@ +using System; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Thread-safe, minimal shared status for Unity Test Runner execution. + /// Used by editor readiness snapshots so callers can avoid starting overlapping runs. + /// + internal static class TestRunStatus + { + private static readonly object LockObj = new(); + + private static bool _isRunning; + private static TestMode? _mode; + private static long? _startedUnixMs; + private static long? _finishedUnixMs; + + public static bool IsRunning + { + get { lock (LockObj) return _isRunning; } + } + + public static TestMode? Mode + { + get { lock (LockObj) return _mode; } + } + + public static long? StartedUnixMs + { + get { lock (LockObj) return _startedUnixMs; } + } + + public static long? FinishedUnixMs + { + get { lock (LockObj) return _finishedUnixMs; } + } + + public static void MarkStarted(TestMode mode) + { + lock (LockObj) + { + _isRunning = true; + _mode = mode; + _startedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _finishedUnixMs = null; + } + } + + public static void MarkFinished() + { + lock (LockObj) + { + _isRunning = false; + _finishedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _mode = null; + } + } + } +} + + diff --git a/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs.meta b/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs.meta new file mode 100644 index 0000000..eae76f5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunStatus.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b3d140c288f6e4b6aa2b7e8181a09c1e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/TestRunStatus.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs b/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs new file mode 100644 index 0000000..ddcfe87 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs @@ -0,0 +1,150 @@ +// TestRunnerNoThrottle.cs +// Sets Unity Editor to "No Throttling" mode during test runs. +// This helps tests that don't trigger compilation run smoothly in the background. +// Note: Tests that trigger mid-run compilation may still stall due to OS-level throttling. + +using System; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Automatically sets the editor to "No Throttling" mode during test runs. + /// + /// This helps prevent background stalls for normal tests. However, tests that trigger + /// script compilation mid-run may still stall because: + /// - Internal Unity coroutine waits rely on editor ticks + /// - OS-level throttling affects the main thread when Unity is backgrounded + /// - No amount of internal nudging can overcome OS thread scheduling + /// + /// The MCP workflow is unaffected because socket messages provide external stimulus + /// that wakes Unity's main thread. + /// + [InitializeOnLoad] + public static class TestRunnerNoThrottle + { + private const string ApplicationIdleTimeKey = "ApplicationIdleTime"; + private const string InteractionModeKey = "InteractionMode"; + + // SessionState keys to persist across domain reload + private const string SessionKey_TestRunActive = "TestRunnerNoThrottle_TestRunActive"; + private const string SessionKey_PrevIdleTime = "TestRunnerNoThrottle_PrevIdleTime"; + private const string SessionKey_PrevInteractionMode = "TestRunnerNoThrottle_PrevInteractionMode"; + private const string SessionKey_SettingsCaptured = "TestRunnerNoThrottle_SettingsCaptured"; + + // Keep reference to avoid GC and set HideFlags to avoid serialization issues + private static TestRunnerApi _api; + + static TestRunnerNoThrottle() + { + try + { + _api = ScriptableObject.CreateInstance(); + _api.hideFlags = HideFlags.HideAndDontSave; + _api.RegisterCallbacks(new TestCallbacks()); + + // Check if recovering from domain reload during an active test run + if (IsTestRunActive()) + { + McpLog.Info("[TestRunnerNoThrottle] Recovered from domain reload - reapplying No Throttling."); + ApplyNoThrottling(); + } + } + catch (Exception e) + { + McpLog.Warn($"[TestRunnerNoThrottle] Failed to register callbacks: {e}"); + } + } + + #region State Persistence + + private static bool IsTestRunActive() => SessionState.GetBool(SessionKey_TestRunActive, false); + private static void SetTestRunActive(bool active) => SessionState.SetBool(SessionKey_TestRunActive, active); + private static bool AreSettingsCaptured() => SessionState.GetBool(SessionKey_SettingsCaptured, false); + private static void SetSettingsCaptured(bool captured) => SessionState.SetBool(SessionKey_SettingsCaptured, captured); + private static int GetPrevIdleTime() => SessionState.GetInt(SessionKey_PrevIdleTime, 4); + private static void SetPrevIdleTime(int value) => SessionState.SetInt(SessionKey_PrevIdleTime, value); + private static int GetPrevInteractionMode() => SessionState.GetInt(SessionKey_PrevInteractionMode, 0); + private static void SetPrevInteractionMode(int value) => SessionState.SetInt(SessionKey_PrevInteractionMode, value); + + #endregion + + /// + /// Apply no-throttling preemptively before tests start. + /// Call this before Execute() for PlayMode tests to ensure Unity isn't throttled + /// during the Play mode transition (before RunStarted fires). + /// + public static void ApplyNoThrottlingPreemptive() + { + SetTestRunActive(true); + ApplyNoThrottling(); + } + + private static void ApplyNoThrottling() + { + if (!AreSettingsCaptured()) + { + SetPrevIdleTime(EditorPrefs.GetInt(ApplicationIdleTimeKey, 4)); + SetPrevInteractionMode(EditorPrefs.GetInt(InteractionModeKey, 0)); + SetSettingsCaptured(true); + } + + // 0ms idle + InteractionMode=1 (No Throttling) + EditorPrefs.SetInt(ApplicationIdleTimeKey, 0); + EditorPrefs.SetInt(InteractionModeKey, 1); + + ForceEditorToApplyInteractionPrefs(); + McpLog.Info("[TestRunnerNoThrottle] Applied No Throttling for test run."); + } + + private static void RestoreThrottling() + { + if (!AreSettingsCaptured()) return; + + EditorPrefs.SetInt(ApplicationIdleTimeKey, GetPrevIdleTime()); + EditorPrefs.SetInt(InteractionModeKey, GetPrevInteractionMode()); + ForceEditorToApplyInteractionPrefs(); + + SetSettingsCaptured(false); + SetTestRunActive(false); + McpLog.Info("[TestRunnerNoThrottle] Restored Interaction Mode after test run."); + } + + private static void ForceEditorToApplyInteractionPrefs() + { + try + { + var method = typeof(EditorApplication).GetMethod( + "UpdateInteractionModeSettings", + BindingFlags.Static | BindingFlags.NonPublic + ); + method?.Invoke(null, null); + } + catch + { + // Ignore reflection errors + } + } + + private sealed class TestCallbacks : ICallbacks + { + public void RunStarted(ITestAdaptor testsToRun) + { + SetTestRunActive(true); + ApplyNoThrottling(); + } + + public void RunFinished(ITestResultAdaptor result) + { + RestoreThrottling(); + } + + public void TestStarted(ITestAdaptor test) { } + public void TestFinished(ITestResultAdaptor result) { } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs.meta b/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs.meta new file mode 100644 index 0000000..638c4bb --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 07a60b029782d464a9506fa520d2a8c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/TestRunnerNoThrottle.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs b/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs new file mode 100644 index 0000000..374715b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs @@ -0,0 +1,619 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditor.TestTools.TestRunner.Api; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Services +{ + /// + /// Concrete implementation of . + /// Coordinates Unity Test Runner operations and produces structured results. + /// + internal sealed class TestRunnerService : ITestRunnerService, ICallbacks, IDisposable + { + private static readonly TestMode[] AllModes = { TestMode.EditMode, TestMode.PlayMode }; + + private readonly TestRunnerApi _testRunnerApi; + private readonly SemaphoreSlim _operationLock = new SemaphoreSlim(1, 1); + private readonly List _leafResults = new List(); + private TaskCompletionSource _runCompletionSource; + + public TestRunnerService() + { + _testRunnerApi = ScriptableObject.CreateInstance(); + _testRunnerApi.RegisterCallbacks(this); + } + + public async Task>> GetTestsAsync(TestMode? mode) + { + await _operationLock.WaitAsync().ConfigureAwait(true); + try + { + var modes = mode.HasValue ? new[] { mode.Value } : AllModes; + + var results = new List>(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var m in modes) + { + var root = await RetrieveTestRootAsync(m).ConfigureAwait(true); + if (root != null) + { + CollectFromNode(root, m, results, seen, new List()); + } + } + + return results; + } + finally + { + _operationLock.Release(); + } + } + + public async Task RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null) + { + await _operationLock.WaitAsync().ConfigureAwait(true); + Task runTask; + bool adjustedPlayModeOptions = false; + bool originalEnterPlayModeOptionsEnabled = false; + EnterPlayModeOptions originalEnterPlayModeOptions = EnterPlayModeOptions.None; + try + { + if (_runCompletionSource != null && !_runCompletionSource.Task.IsCompleted) + { + throw new InvalidOperationException("A Unity test run is already in progress."); + } + + if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode) + { + throw new InvalidOperationException("Cannot start a test run while the Editor is in or entering Play Mode. Stop Play Mode and try again."); + } + + if (mode == TestMode.PlayMode) + { + // PlayMode runs transition the editor into play across multiple update ticks. Unity's + // built-in pipeline schedules SaveModifiedSceneTask early, but that task uses + // EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo which throws once play mode is + // active. To minimize that window we pre-save dirty scenes and disable domain reload (so the + // MCP bridge stays alive). We do NOT force runSynchronously here because that can freeze the + // editor in some projects. If the TestRunner still hits the save task after entering play, the + // run can fail; in that case, rerun from a clean Edit Mode state. + adjustedPlayModeOptions = EnsurePlayModeRunsWithoutDomainReload( + out originalEnterPlayModeOptionsEnabled, + out originalEnterPlayModeOptions); + } + + _leafResults.Clear(); + _runCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + // Mark running immediately so readiness snapshots reflect the busy state even before callbacks fire. + TestRunStatus.MarkStarted(mode); + + var filter = new Filter + { + testMode = mode, + testNames = filterOptions?.TestNames, + groupNames = filterOptions?.GroupNames, + categoryNames = filterOptions?.CategoryNames, + assemblyNames = filterOptions?.AssemblyNames + }; + var settings = new ExecutionSettings(filter); + + // Save dirty scenes for all test modes to prevent modal dialogs blocking MCP + // (Issue #525: EditMode tests were blocked by save dialog) + SaveDirtyScenesIfNeeded(); + + // Apply no-throttling preemptively for PlayMode tests. This ensures Unity + // isn't throttled during the Play mode transition (which requires multiple + // editor frames). Without this, unfocused Unity may never reach RunStarted + // where throttling would normally be disabled. + if (mode == TestMode.PlayMode) + { + TestRunnerNoThrottle.ApplyNoThrottlingPreemptive(); + } + + _testRunnerApi.Execute(settings); + + runTask = _runCompletionSource.Task; + } + catch + { + // Ensure the status is cleared if we failed to start the run. + TestRunStatus.MarkFinished(); + if (adjustedPlayModeOptions) + { + RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions); + } + + _operationLock.Release(); + throw; + } + + try + { + return await runTask.ConfigureAwait(true); + } + finally + { + if (adjustedPlayModeOptions) + { + RestoreEnterPlayModeOptions(originalEnterPlayModeOptionsEnabled, originalEnterPlayModeOptions); + } + + _operationLock.Release(); + } + } + + public void Dispose() + { + try + { + _testRunnerApi?.UnregisterCallbacks(this); + } + catch + { + // Ignore cleanup errors + } + + if (_testRunnerApi != null) + { + ScriptableObject.DestroyImmediate(_testRunnerApi); + } + + _operationLock.Dispose(); + } + + #region TestRunnerApi callbacks + + public void RunStarted(ITestAdaptor testsToRun) + { + _leafResults.Clear(); + try + { + // Best-effort progress info for async polling (avoid heavy payloads). + int? total = null; + if (testsToRun != null) + { + total = CountLeafTests(testsToRun); + } + TestJobManager.OnRunStarted(total); + } + catch + { + TestJobManager.OnRunStarted(null); + } + } + + public void RunFinished(ITestResultAdaptor result) + { + // Always create payload and clean up job state, even if _runCompletionSource is null. + // This handles domain reload scenarios (e.g., PlayMode tests) where the TestRunnerService + // is recreated and _runCompletionSource is lost, but TestJobManager state persists via + // SessionState and the Test Runner still delivers the RunFinished callback. + var payload = TestRunResult.Create(result, _leafResults); + + // Clean up state regardless of _runCompletionSource - these methods safely handle + // the case where no MCP job exists (e.g., manual test runs via Unity UI). + TestRunStatus.MarkFinished(); + TestJobManager.OnRunFinished(); + TestJobManager.FinalizeCurrentJobFromRunFinished(payload); + + // Report result to awaiting caller if we have a completion source + if (_runCompletionSource != null) + { + _runCompletionSource.TrySetResult(payload); + _runCompletionSource = null; + } + } + + public void TestStarted(ITestAdaptor test) + { + try + { + // Prefer FullName for uniqueness; fall back to Name. + string fullName = test?.FullName; + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = test?.Name; + } + TestJobManager.OnTestStarted(fullName); + } + catch + { + // ignore + } + } + + public void TestFinished(ITestResultAdaptor result) + { + if (result == null) + { + return; + } + + if (!result.HasChildren) + { + _leafResults.Add(result); + try + { + string fullName = result.Test?.FullName; + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = result.Test?.Name; + } + + bool isFailure = false; + string message = null; + try + { + // NUnit outcomes are strings in the adaptor; keep it simple. + string outcome = result.ResultState; + if (!string.IsNullOrWhiteSpace(outcome)) + { + var o = outcome.Trim().ToLowerInvariant(); + isFailure = o.Contains("failed") || o.Contains("error"); + } + message = result.Message; + } + catch + { + // ignore adaptor quirks + } + + TestJobManager.OnLeafTestFinished(fullName, isFailure, message); + } + catch + { + // ignore + } + } + } + + #endregion + + private static int CountLeafTests(ITestAdaptor node) + { + if (node == null) + { + return 0; + } + + if (!node.HasChildren) + { + return 1; + } + + int total = 0; + try + { + foreach (var child in node.Children) + { + total += CountLeafTests(child); + } + } + catch + { + // If Unity changes the adaptor behavior, treat it as "unknown total". + return 0; + } + + return total; + } + + private static bool EnsurePlayModeRunsWithoutDomainReload( + out bool originalEnterPlayModeOptionsEnabled, + out EnterPlayModeOptions originalEnterPlayModeOptions) + { + originalEnterPlayModeOptionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + originalEnterPlayModeOptions = EditorSettings.enterPlayModeOptions; + + // When Play Mode triggers a domain reload, the MCP connection is torn down and the pending + // test run response never makes it back to the caller. To keep the bridge alive for this + // invocation, temporarily enable Enter Play Mode Options with domain reload disabled. + bool domainReloadDisabled = (originalEnterPlayModeOptions & EnterPlayModeOptions.DisableDomainReload) != 0; + bool needsChange = !originalEnterPlayModeOptionsEnabled || !domainReloadDisabled; + if (!needsChange) + { + return false; + } + + var desired = originalEnterPlayModeOptions | EnterPlayModeOptions.DisableDomainReload; + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = desired; + return true; + } + + private static void RestoreEnterPlayModeOptions(bool originalEnabled, EnterPlayModeOptions originalOptions) + { + EditorSettings.enterPlayModeOptions = originalOptions; + EditorSettings.enterPlayModeOptionsEnabled = originalEnabled; + } + + private static void SaveDirtyScenesIfNeeded() + { + int sceneCount = SceneManager.sceneCount; + for (int i = 0; i < sceneCount; i++) + { + var scene = SceneManager.GetSceneAt(i); + if (scene.isDirty) + { + if (string.IsNullOrEmpty(scene.path)) + { + McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running tests."); + continue; + } + try + { + EditorSceneManager.SaveScene(scene); + } + catch (Exception ex) + { + McpLog.Warn($"[TestRunnerService] Failed to save dirty scene '{scene.name}': {ex.Message}"); + } + } + } + } + + #region Test list helpers + + private async Task RetrieveTestRootAsync(TestMode mode) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _testRunnerApi.RetrieveTestList(mode, root => + { + tcs.TrySetResult(root); + }); + + // Ensure the editor pumps at least one additional update in case the window is unfocused. + EditorApplication.QueuePlayerLoopUpdate(); + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(true); + if (completed != tcs.Task) + { + McpLog.Warn($"[TestRunnerService] Timeout waiting for test retrieval callback for {mode}"); + return null; + } + + try + { + return await tcs.Task.ConfigureAwait(true); + } + catch (Exception ex) + { + McpLog.Error($"[TestRunnerService] Error retrieving tests for {mode}: {ex.Message}\n{ex.StackTrace}"); + return null; + } + } + + private static void CollectFromNode( + ITestAdaptor node, + TestMode mode, + List> output, + HashSet seen, + List path) + { + if (node == null) + { + return; + } + + bool hasName = !string.IsNullOrEmpty(node.Name); + if (hasName) + { + path.Add(node.Name); + } + + bool hasChildren = node.HasChildren && node.Children != null; + + if (!hasChildren) + { + string fullName = string.IsNullOrEmpty(node.FullName) ? node.Name ?? string.Empty : node.FullName; + string key = $"{mode}:{fullName}"; + + if (!string.IsNullOrEmpty(fullName) && seen.Add(key)) + { + string computedPath = path.Count > 0 ? string.Join("/", path) : fullName; + output.Add(new Dictionary + { + ["name"] = node.Name ?? fullName, + ["full_name"] = fullName, + ["path"] = computedPath, + ["mode"] = mode.ToString(), + }); + } + } + else if (node.Children != null) + { + foreach (var child in node.Children) + { + CollectFromNode(child, mode, output, seen, path); + } + } + + if (hasName && path.Count > 0) + { + path.RemoveAt(path.Count - 1); + } + } + + #endregion + } + + /// + /// Summary of a Unity test run. + /// + public sealed class TestRunResult + { + internal TestRunResult(TestRunSummary summary, IReadOnlyList results) + { + Summary = summary; + Results = results; + } + + public TestRunSummary Summary { get; } + public IReadOnlyList Results { get; } + + public int Total => Summary.Total; + public int Passed => Summary.Passed; + public int Failed => Summary.Failed; + public int Skipped => Summary.Skipped; + + public object ToSerializable(string mode, bool includeDetails = false, bool includeFailedTests = false) + { + // Determine which results to include + IEnumerable resultsToSerialize; + if (includeDetails) + { + // Include all test results + resultsToSerialize = Results.Select(r => r.ToSerializable()); + } + else if (includeFailedTests) + { + // Include only failed and skipped tests + resultsToSerialize = Results + .Where(r => !string.Equals(r.State, "Passed", StringComparison.OrdinalIgnoreCase)) + .Select(r => r.ToSerializable()); + } + else + { + // No individual test results + resultsToSerialize = null; + } + + return new + { + mode, + summary = Summary.ToSerializable(), + results = resultsToSerialize?.ToList(), + }; + } + + internal static TestRunResult Create(ITestResultAdaptor summary, IReadOnlyList tests) + { + var materializedTests = tests.Select(TestRunTestResult.FromAdaptor).ToList(); + + int passed = summary?.PassCount + ?? materializedTests.Count(t => string.Equals(t.State, "Passed", StringComparison.OrdinalIgnoreCase)); + int failed = summary?.FailCount + ?? materializedTests.Count(t => string.Equals(t.State, "Failed", StringComparison.OrdinalIgnoreCase)); + int skipped = summary?.SkipCount + ?? materializedTests.Count(t => string.Equals(t.State, "Skipped", StringComparison.OrdinalIgnoreCase)); + + double duration = summary?.Duration + ?? materializedTests.Sum(t => t.DurationSeconds); + + int total = summary != null ? passed + failed + skipped : materializedTests.Count; + + var summaryPayload = new TestRunSummary( + total, + passed, + failed, + skipped, + duration, + summary?.ResultState ?? "Unknown"); + + return new TestRunResult(summaryPayload, materializedTests); + } + } + + public sealed class TestRunSummary + { + internal TestRunSummary(int total, int passed, int failed, int skipped, double durationSeconds, string resultState) + { + Total = total; + Passed = passed; + Failed = failed; + Skipped = skipped; + DurationSeconds = durationSeconds; + ResultState = resultState; + } + + public int Total { get; } + public int Passed { get; } + public int Failed { get; } + public int Skipped { get; } + public double DurationSeconds { get; } + public string ResultState { get; } + + internal object ToSerializable() + { + return new + { + total = Total, + passed = Passed, + failed = Failed, + skipped = Skipped, + durationSeconds = DurationSeconds, + resultState = ResultState, + }; + } + } + + public sealed class TestRunTestResult + { + internal TestRunTestResult( + string name, + string fullName, + string state, + double durationSeconds, + string message, + string stackTrace, + string output) + { + Name = name; + FullName = fullName; + State = state; + DurationSeconds = durationSeconds; + Message = message; + StackTrace = stackTrace; + Output = output; + } + + public string Name { get; } + public string FullName { get; } + public string State { get; } + public double DurationSeconds { get; } + public string Message { get; } + public string StackTrace { get; } + public string Output { get; } + + internal object ToSerializable() + { + return new + { + name = Name, + fullName = FullName, + state = State, + durationSeconds = DurationSeconds, + message = Message, + stackTrace = StackTrace, + output = Output, + }; + } + + internal static TestRunTestResult FromAdaptor(ITestResultAdaptor adaptor) + { + if (adaptor == null) + { + return new TestRunTestResult(string.Empty, string.Empty, "Unknown", 0.0, string.Empty, string.Empty, string.Empty); + } + + return new TestRunTestResult( + adaptor.Name, + adaptor.FullName, + adaptor.ResultState, + adaptor.Duration, + adaptor.Message, + adaptor.StackTrace, + adaptor.Output); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs.meta b/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs.meta new file mode 100644 index 0000000..afb5bc2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/TestRunnerService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 18db1e25b13e14b0b9b186c751e397d0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/TestRunnerService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs b/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs new file mode 100644 index 0000000..b5b86c0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using UnityEditor; + +namespace MCPForUnity.Editor.Services +{ + public class ToolDiscoveryService : IToolDiscoveryService + { + private Dictionary _cachedTools; + + + public List DiscoverAllTools() + { + if (_cachedTools != null) + { + return _cachedTools.Values.ToList(); + } + + _cachedTools = new Dictionary(); + + var toolTypes = TypeCache.GetTypesWithAttribute(); + foreach (var type in toolTypes) + { + McpForUnityToolAttribute toolAttr; + try + { + toolAttr = type.GetCustomAttribute(); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to read [McpForUnityTool] for {type.FullName}: {ex.Message}"); + continue; + } + + if (toolAttr == null) + { + continue; + } + + var metadata = ExtractToolMetadata(type, toolAttr); + if (metadata != null) + { + if (_cachedTools.ContainsKey(metadata.Name)) + { + McpLog.Warn($"Duplicate tool name '{metadata.Name}' from {type.FullName}; overwriting previous registration."); + } + _cachedTools[metadata.Name] = metadata; + EnsurePreferenceInitialized(metadata); + } + } + + McpLog.Info($"Discovered {_cachedTools.Count} MCP tools via reflection", false); + return _cachedTools.Values.ToList(); + } + + public ToolMetadata GetToolMetadata(string toolName) + { + if (_cachedTools == null) + { + DiscoverAllTools(); + } + + return _cachedTools.TryGetValue(toolName, out var metadata) ? metadata : null; + } + + public List GetEnabledTools() + { + return DiscoverAllTools() + .Where(tool => IsToolEnabled(tool.Name)) + .ToList(); + } + + public bool IsToolEnabled(string toolName) + { + if (string.IsNullOrEmpty(toolName)) + { + return false; + } + + string key = GetToolPreferenceKey(toolName); + if (EditorPrefs.HasKey(key)) + { + return EditorPrefs.GetBool(key, true); + } + + var metadata = GetToolMetadata(toolName); + return metadata?.AutoRegister ?? false; + } + + public void SetToolEnabled(string toolName, bool enabled) + { + if (string.IsNullOrEmpty(toolName)) + { + return; + } + + string key = GetToolPreferenceKey(toolName); + EditorPrefs.SetBool(key, enabled); + } + + private ToolMetadata ExtractToolMetadata(Type type, McpForUnityToolAttribute toolAttr) + { + try + { + // Get tool name + string toolName = toolAttr.Name; + if (string.IsNullOrEmpty(toolName)) + { + // Derive from class name: CaptureScreenshotTool -> capture_screenshot + toolName = ConvertToSnakeCase(type.Name.Replace("Tool", "")); + } + + // Get description + string description = toolAttr.Description ?? $"Tool: {toolName}"; + + // Extract parameters + var parameters = ExtractParameters(type); + + var metadata = new ToolMetadata + { + Name = toolName, + Description = description, + StructuredOutput = toolAttr.StructuredOutput, + Parameters = parameters, + ClassName = type.Name, + Namespace = type.Namespace ?? "", + AssemblyName = type.Assembly.GetName().Name, + AutoRegister = toolAttr.AutoRegister, + RequiresPolling = toolAttr.RequiresPolling, + PollAction = string.IsNullOrEmpty(toolAttr.PollAction) ? "status" : toolAttr.PollAction + }; + + metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType( + type, metadata.AssemblyName, "MCPForUnity.Editor.Tools"); + + return metadata; + + } + catch (Exception ex) + { + McpLog.Error($"Failed to extract metadata for {type.Name}: {ex.Message}"); + return null; + } + } + + private List ExtractParameters(Type type) + { + var parameters = new List(); + + // Look for nested Parameters class + var parametersType = type.GetNestedType("Parameters"); + if (parametersType == null) + { + return parameters; + } + + // Get all properties with [ToolParameter] + var properties = parametersType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var paramAttr = prop.GetCustomAttribute(); + if (paramAttr == null) + continue; + + string paramName = prop.Name; + string paramType = GetParameterType(prop.PropertyType); + + parameters.Add(new ParameterMetadata + { + Name = paramName, + Description = paramAttr.Description, + Type = paramType, + Required = paramAttr.Required, + DefaultValue = paramAttr.DefaultValue + }); + } + + return parameters; + } + + private string GetParameterType(Type type) + { + // Handle nullable types + if (Nullable.GetUnderlyingType(type) != null) + { + type = Nullable.GetUnderlyingType(type); + } + + // Map C# types to JSON schema types + if (type == typeof(string)) + return "string"; + if (type == typeof(int) || type == typeof(long)) + return "integer"; + if (type == typeof(float) || type == typeof(double)) + return "number"; + if (type == typeof(bool)) + return "boolean"; + if (type.IsArray || typeof(System.Collections.IEnumerable).IsAssignableFrom(type)) + return "array"; + + return "object"; + } + + private string ConvertToSnakeCase(string input) => StringCaseUtility.ToSnakeCase(input); + + public void InvalidateCache() + { + _cachedTools = null; + } + + private void EnsurePreferenceInitialized(ToolMetadata metadata) + { + if (metadata == null || string.IsNullOrEmpty(metadata.Name)) + { + return; + } + + string key = GetToolPreferenceKey(metadata.Name); + if (!EditorPrefs.HasKey(key)) + { + bool defaultValue = metadata.AutoRegister || metadata.IsBuiltIn; + EditorPrefs.SetBool(key, defaultValue); + return; + } + + if (metadata.IsBuiltIn && !metadata.AutoRegister) + { + bool currentValue = EditorPrefs.GetBool(key, metadata.AutoRegister); + if (currentValue == metadata.AutoRegister) + { + EditorPrefs.SetBool(key, true); + } + } + } + + private static string GetToolPreferenceKey(string toolName) + { + return EditorPrefKeys.ToolEnabledPrefix + toolName; + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta b/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta new file mode 100644 index 0000000..c8dcba1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ec81a561be4c14c9cb243855d3273a94 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/ToolDiscoveryService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport.meta b/Assets/MCPForUnity/Editor/Services/Transport.meta new file mode 100644 index 0000000..58fe0d7 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d189635a5d364f55a810203798c09ba +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs b/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs new file mode 100644 index 0000000..3d8584f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Abstraction for MCP transport implementations (e.g. WebSocket push, stdio). + /// + public interface IMcpTransportClient + { + bool IsConnected { get; } + string TransportName { get; } + TransportState State { get; } + + Task StartAsync(); + Task StopAsync(); + Task VerifyAsync(); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta new file mode 100644 index 0000000..56a9007 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 042446a50a4744170bb294acf827376f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/IMcpTransportClient.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs new file mode 100644 index 0000000..447db69 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Centralised command execution pipeline shared by all transport implementations. + /// Guarantees that MCP commands are executed on the Unity main thread while preserving + /// the legacy response format expected by the server. + /// + [InitializeOnLoad] + internal static class TransportCommandDispatcher + { + private static SynchronizationContext _mainThreadContext; + private static int _mainThreadId; + private static int _processingFlag; + + private sealed class PendingCommand + { + public PendingCommand( + string commandJson, + TaskCompletionSource completionSource, + CancellationToken cancellationToken, + CancellationTokenRegistration registration) + { + CommandJson = commandJson; + CompletionSource = completionSource; + CancellationToken = cancellationToken; + CancellationRegistration = registration; + QueuedAt = DateTime.UtcNow; + } + + public string CommandJson { get; } + public TaskCompletionSource CompletionSource { get; } + public CancellationToken CancellationToken { get; } + public CancellationTokenRegistration CancellationRegistration { get; } + public bool IsExecuting { get; set; } + public DateTime QueuedAt { get; } + + public void Dispose() + { + CancellationRegistration.Dispose(); + } + + public void TrySetResult(string payload) + { + CompletionSource.TrySetResult(payload); + } + + public void TrySetCanceled() + { + CompletionSource.TrySetCanceled(CancellationToken); + } + } + + private static readonly Dictionary Pending = new(); + private static readonly object PendingLock = new(); + private static bool updateHooked; + private static bool initialised; + + static TransportCommandDispatcher() + { + // Ensure this runs on the Unity main thread at editor load. + _mainThreadContext = SynchronizationContext.Current; + _mainThreadId = Thread.CurrentThread.ManagedThreadId; + + EnsureInitialised(); + + // Always keep the update hook installed so commands arriving from background + // websocket tasks don't depend on a background-thread event subscription. + if (!updateHooked) + { + updateHooked = true; + EditorApplication.update += ProcessQueue; + } + } + + /// + /// Schedule a command for execution on the Unity main thread and await its JSON response. + /// + public static Task ExecuteCommandJsonAsync(string commandJson, CancellationToken cancellationToken) + { + if (commandJson is null) + { + throw new ArgumentNullException(nameof(commandJson)); + } + + EnsureInitialised(); + + var id = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var registration = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => CancelPending(id, cancellationToken)) + : default; + + var pending = new PendingCommand(commandJson, tcs, cancellationToken, registration); + + lock (PendingLock) + { + Pending[id] = pending; + } + + // Proactively wake up the main thread execution loop. This improves responsiveness + // in scenarios where EditorApplication.update is throttled or temporarily not firing + // (e.g., Unity unfocused, compiling, or during domain reload transitions). + RequestMainThreadPump(); + + return tcs.Task; + } + + internal static Task RunOnMainThreadAsync(Func func, CancellationToken cancellationToken) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var registration = cancellationToken.CanBeCanceled + ? cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)) + : default; + + void Invoke() + { + try + { + if (tcs.Task.IsCompleted) + { + return; + } + + var result = func(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + finally + { + registration.Dispose(); + } + } + + // Best-effort nudge: if we're posting from a background thread (e.g., websocket receive), + // encourage Unity to run a loop iteration so the posted callback can execute even when unfocused. + try { EditorApplication.QueuePlayerLoopUpdate(); } catch { } + + if (_mainThreadContext != null && Thread.CurrentThread.ManagedThreadId != _mainThreadId) + { + _mainThreadContext.Post(_ => Invoke(), null); + return tcs.Task; + } + + Invoke(); + return tcs.Task; + } + + private static void RequestMainThreadPump() + { + void Pump() + { + try + { + // Hint Unity to run a loop iteration soon. + EditorApplication.QueuePlayerLoopUpdate(); + } + catch + { + // Best-effort only. + } + + ProcessQueue(); + } + + if (_mainThreadContext != null && Thread.CurrentThread.ManagedThreadId != _mainThreadId) + { + _mainThreadContext.Post(_ => Pump(), null); + return; + } + + Pump(); + } + + private static void EnsureInitialised() + { + if (initialised) + { + return; + } + + CommandRegistry.Initialize(); + initialised = true; + } + + private static void HookUpdate() + { + // Deprecated: we keep the update hook installed permanently (see static ctor). + if (updateHooked) return; + updateHooked = true; + EditorApplication.update += ProcessQueue; + } + + private static void UnhookUpdateIfIdle() + { + // Intentionally no-op: keep update hook installed so background commands always process. + // This avoids "must focus Unity to re-establish contact" edge cases. + return; + } + + private static void ProcessQueue() + { + if (Interlocked.Exchange(ref _processingFlag, 1) == 1) + { + return; + } + + try + { + List<(string id, PendingCommand pending)> ready; + + lock (PendingLock) + { + // Early exit inside lock to prevent per-frame List allocations (GitHub issue #577) + if (Pending.Count == 0) + { + return; + } + + ready = new List<(string, PendingCommand)>(Pending.Count); + foreach (var kvp in Pending) + { + if (kvp.Value.IsExecuting) + { + continue; + } + + kvp.Value.IsExecuting = true; + ready.Add((kvp.Key, kvp.Value)); + } + + if (ready.Count == 0) + { + UnhookUpdateIfIdle(); + return; + } + } + + foreach (var (id, pending) in ready) + { + ProcessCommand(id, pending); + } + } + finally + { + Interlocked.Exchange(ref _processingFlag, 0); + } + } + + private static void ProcessCommand(string id, PendingCommand pending) + { + if (pending.CancellationToken.IsCancellationRequested) + { + RemovePending(id, pending); + pending.TrySetCanceled(); + return; + } + + string commandText = pending.CommandJson?.Trim(); + if (string.IsNullOrEmpty(commandText)) + { + pending.TrySetResult(SerializeError("Empty command received")); + RemovePending(id, pending); + return; + } + + if (string.Equals(commandText, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText + }; + pending.TrySetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + RemovePending(id, pending); + return; + } + + try + { + var command = JsonConvert.DeserializeObject(commandText); + if (command == null) + { + pending.TrySetResult(SerializeError("Command deserialized to null", "Unknown", commandText)); + RemovePending(id, pending); + return; + } + + if (string.IsNullOrWhiteSpace(command.type)) + { + pending.TrySetResult(SerializeError("Command type cannot be empty")); + RemovePending(id, pending); + return; + } + + if (string.Equals(command.type, "ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" } + }; + pending.TrySetResult(JsonConvert.SerializeObject(pingResponse)); + RemovePending(id, pending); + return; + } + + var parameters = command.@params ?? new JObject(); + + // Block execution of disabled resources + var resourceMeta = MCPServiceLocator.ResourceDiscovery.GetResourceMetadata(command.type); + if (resourceMeta != null && !MCPServiceLocator.ResourceDiscovery.IsResourceEnabled(command.type)) + { + pending.TrySetResult(SerializeError( + $"Resource '{command.type}' is disabled in the Unity Editor.")); + RemovePending(id, pending); + return; + } + + // Block execution of disabled tools + var toolMeta = MCPServiceLocator.ToolDiscovery.GetToolMetadata(command.type); + if (toolMeta != null && !MCPServiceLocator.ToolDiscovery.IsToolEnabled(command.type)) + { + pending.TrySetResult(SerializeError( + $"Tool '{command.type}' is disabled in the Unity Editor.")); + RemovePending(id, pending); + return; + } + + var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource); + + if (result == null) + { + // Async command – cleanup after completion on next editor frame to preserve order. + pending.CompletionSource.Task.ContinueWith(_ => + { + EditorApplication.delayCall += () => RemovePending(id, pending); + }, TaskScheduler.Default); + return; + } + + var response = new { status = "success", result }; + pending.TrySetResult(JsonConvert.SerializeObject(response)); + RemovePending(id, pending); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + pending.TrySetResult(SerializeError(ex.Message, "Unknown (error during processing)", ex.StackTrace)); + RemovePending(id, pending); + } + } + + private static void CancelPending(string id, CancellationToken token) + { + PendingCommand pending = null; + lock (PendingLock) + { + if (Pending.Remove(id, out pending)) + { + UnhookUpdateIfIdle(); + } + } + + pending?.TrySetCanceled(); + pending?.Dispose(); + } + + private static void RemovePending(string id, PendingCommand pending) + { + lock (PendingLock) + { + Pending.Remove(id); + UnhookUpdateIfIdle(); + } + + pending.Dispose(); + } + + private static string SerializeError(string message, string commandType = null, string stackTrace = null) + { + var errorResponse = new + { + status = "error", + error = message, + command = commandType ?? "Unknown", + stackTrace + }; + return JsonConvert.SerializeObject(errorResponse); + } + + private static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ((text.StartsWith("{") && text.EndsWith("}")) || (text.StartsWith("[") && text.EndsWith("]"))) + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta new file mode 100644 index 0000000..f8dae48 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 27407cc9c1ea0412d80b9f8964a5a29d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs b/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs new file mode 100644 index 0000000..1204e70 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs @@ -0,0 +1,152 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport.Transports; + +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Coordinates the active transport client and exposes lifecycle helpers. + /// + public class TransportManager + { + private IMcpTransportClient _httpClient; + private IMcpTransportClient _stdioClient; + private TransportState _httpState = TransportState.Disconnected("http"); + private TransportState _stdioState = TransportState.Disconnected("stdio"); + private Func _webSocketFactory; + private Func _stdioFactory; + + public TransportManager() + { + Configure( + () => new WebSocketTransportClient(MCPServiceLocator.ToolDiscovery), + () => new StdioTransportClient()); + } + + public void Configure( + Func webSocketFactory, + Func stdioFactory) + { + _webSocketFactory = webSocketFactory ?? throw new ArgumentNullException(nameof(webSocketFactory)); + _stdioFactory = stdioFactory ?? throw new ArgumentNullException(nameof(stdioFactory)); + } + + private IMcpTransportClient GetOrCreateClient(TransportMode mode) + { + return mode switch + { + TransportMode.Http => _httpClient ??= _webSocketFactory(), + TransportMode.Stdio => _stdioClient ??= _stdioFactory(), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"), + }; + } + + private IMcpTransportClient GetClient(TransportMode mode) + { + return mode switch + { + TransportMode.Http => _httpClient, + TransportMode.Stdio => _stdioClient, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"), + }; + } + + public async Task StartAsync(TransportMode mode) + { + IMcpTransportClient client = GetOrCreateClient(mode); + + bool started = await client.StartAsync(); + if (!started) + { + try + { + await client.StopAsync(); + } + catch (Exception ex) + { + McpLog.Warn($"Error while stopping transport {client.TransportName}: {ex.Message}"); + } + UpdateState(mode, TransportState.Disconnected(client.TransportName, client.State?.Error ?? "Failed to start")); + return false; + } + + UpdateState(mode, client.State ?? TransportState.Connected(client.TransportName)); + return true; + } + + public async Task StopAsync(TransportMode? mode = null) + { + async Task StopClient(IMcpTransportClient client, TransportMode clientMode) + { + if (client == null) return; + try { await client.StopAsync(); } + catch (Exception ex) { McpLog.Warn($"Error while stopping transport {client.TransportName}: {ex.Message}"); } + finally { UpdateState(clientMode, TransportState.Disconnected(client.TransportName)); } + } + + if (mode == null) + { + await StopClient(_httpClient, TransportMode.Http); + await StopClient(_stdioClient, TransportMode.Stdio); + return; + } + + if (mode == TransportMode.Http) + { + await StopClient(_httpClient, TransportMode.Http); + } + else + { + await StopClient(_stdioClient, TransportMode.Stdio); + } + } + + public async Task VerifyAsync(TransportMode mode) + { + IMcpTransportClient client = GetClient(mode); + if (client == null) + { + return false; + } + + bool ok = await client.VerifyAsync(); + var state = client.State ?? TransportState.Disconnected(client.TransportName, "No state reported"); + UpdateState(mode, state); + return ok; + } + + public TransportState GetState(TransportMode mode) + { + return mode switch + { + TransportMode.Http => _httpState, + TransportMode.Stdio => _stdioState, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"), + }; + } + + public bool IsRunning(TransportMode mode) => GetState(mode).IsConnected; + + private void UpdateState(TransportMode mode, TransportState state) + { + switch (mode) + { + case TransportMode.Http: + _httpState = state; + break; + case TransportMode.Stdio: + _stdioState = state; + break; + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported transport mode"); + } + } + } + + public enum TransportMode + { + Http, + Stdio + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta new file mode 100644 index 0000000..9cc4320 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 65fc8ff4c9efb4fc98a0910ba7ca8b02 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/TransportManager.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs b/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs new file mode 100644 index 0000000..7fb6f20 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs @@ -0,0 +1,52 @@ +namespace MCPForUnity.Editor.Services.Transport +{ + /// + /// Lightweight snapshot of a transport's runtime status for editor UI and diagnostics. + /// + public sealed class TransportState + { + public bool IsConnected { get; } + public string TransportName { get; } + public int? Port { get; } + public string SessionId { get; } + public string Details { get; } + public string Error { get; } + + private TransportState( + bool isConnected, + string transportName, + int? port, + string sessionId, + string details, + string error) + { + IsConnected = isConnected; + TransportName = transportName; + Port = port; + SessionId = sessionId; + Details = details; + Error = error; + } + + public static TransportState Connected( + string transportName, + int? port = null, + string sessionId = null, + string details = null) + => new TransportState(true, transportName, port, sessionId, details, null); + + public static TransportState Disconnected( + string transportName, + string error = null, + int? port = null) + => new TransportState(false, transportName, port, null, null, error); + + public TransportState WithError(string error) => new TransportState( + IsConnected, + TransportName, + Port, + SessionId, + Details, + error); + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta new file mode 100644 index 0000000..d89732a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 67ab8e43f6a804698bb5b216cdef0645 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/TransportState.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports.meta b/Assets/MCPForUnity/Editor/Services/Transport/Transports.meta new file mode 100644 index 0000000..878b705 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d467a63b6fad42fa975c731af4b83b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs new file mode 100644 index 0000000..ab3af5d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -0,0 +1,1095 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Services.Transport; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.Prefabs; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + class Outbound + { + public byte[] Payload; + public string Tag; + public int? ReqId; + } + + class QueuedCommand + { + public string CommandJson; + public TaskCompletionSource Tcs; + public bool IsExecuting; + } + + [InitializeOnLoad] + public static class StdioBridgeHost + { + private static TcpListener listener; + private static bool isRunning = false; + private static readonly object lockObj = new(); + private static readonly object startStopLock = new(); + private static readonly object clientsLock = new(); + private static readonly HashSet activeClients = new(); + private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); + private static CancellationTokenSource cts; + private static Task listenerTask; + private static int processingCommands = 0; + private static bool initScheduled = false; + private static bool ensureUpdateHooked = false; + private static bool isStarting = false; + private static double nextStartAt = 0.0f; + private static double nextHeartbeatAt = 0.0f; + private static int heartbeatSeq = 0; + private static Dictionary commandQueue = new(); + private static int mainThreadId; + private static int currentUnityPort = 6400; + private static bool isAutoConnectMode = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; + private const int FrameIOTimeoutMs = 30000; + + private static long _ioSeq = 0; + private static void IoInfo(string s) { McpLog.Info(s, always: false); } + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { return false; } + } + + private static void LogBreadcrumb(string stage) + { + if (IsDebugEnabled()) + { + McpLog.Info($"[{stage}]", always: false); + } + } + + public static bool IsRunning => isRunning; + public static int GetCurrentPort() => currentUnityPort; + public static bool IsAutoConnectMode() => isAutoConnectMode; + + public static void StartAutoConnect() + { + Stop(); + + try + { + currentUnityPort = PortManager.GetPortWithFallback(); + Start(); + isAutoConnectMode = true; + + TelemetryHelper.RecordBridgeStartup(); + } + catch (Exception ex) + { + McpLog.Error($"Auto-connect failed: {ex.Message}"); + TelemetryHelper.RecordBridgeConnection(false, ex.Message); + throw; + } + } + + public static bool FolderExists(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string fullPath = Path.Combine( + Application.dataPath, + path.StartsWith("Assets/") ? path[7..] : path + ); + return Directory.Exists(fullPath); + } + + static StdioBridgeHost() + { + try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } + try + { + var writerThread = new Thread(() => + { + foreach (var item in _outbox.GetConsumingEnumerable()) + { + try + { + long seq = Interlocked.Increment(ref _ioSeq); + IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + sw.Stop(); + IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); + } + } + }) + { IsBackground = true, Name = "MCP-Writer" }; + writerThread.Start(); + } + catch { } + + if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) + { + return; + } + if (ShouldAutoStartBridge()) + { + ScheduleInitRetry(); + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + } + EditorApplication.quitting += Stop; + EditorApplication.playModeStateChanged += _ => + { + if (ShouldAutoStartBridge()) + { + ScheduleInitRetry(); + } + }; + } + + private static void InitializeAfterCompilation() + { + initScheduled = false; + + if (IsCompiling()) + { + ScheduleInitRetry(); + return; + } + + if (!isRunning) + { + Start(); + if (!isRunning) + { + ScheduleInitRetry(); + } + } + } + + private static void ScheduleInitRetry() + { + if (initScheduled) + { + return; + } + initScheduled = true; + nextStartAt = EditorApplication.timeSinceStartup + 0.20f; + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + EditorApplication.delayCall += InitializeAfterCompilation; + } + + private static bool ShouldAutoStartBridge() + { + try + { + bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; + return !useHttpTransport; + } + catch + { + return true; + } + } + + private static void EnsureStartedOnEditorIdle() + { + if (IsCompiling()) + { + return; + } + + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + return; + } + + if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) + { + return; + } + + if (isStarting) + { + return; + } + + isStarting = true; + try + { + Start(); + } + finally + { + isStarting = false; + } + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + } + } + + private static bool IsCompiling() + { + if (EditorApplication.isCompiling) + { + return true; + } + try + { + Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) + { + return (bool)prop.GetValue(null); + } + } + catch { } + return false; + } + + public static void Start() + { + lock (startStopLock) + { + if (isRunning && listener != null) + { + if (IsDebugEnabled()) + { + McpLog.Info($"StdioBridgeHost already running on port {currentUnityPort}"); + } + return; + } + + Stop(); + + try + { + currentUnityPort = PortManager.GetPortWithFallback(); + + LogBreadcrumb("Start"); + + const int maxImmediateRetries = 3; + const int retrySleepMs = 75; + int attempt = 0; + for (; ; ) + { + try + { + listener = CreateConfiguredListener(currentUnityPort); + listener.Start(); + break; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) + { + attempt++; + Thread.Sleep(retrySleepMs); + continue; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) + { + int oldPort = currentUnityPort; + + // Before switching ports, give the old one a brief chance to release if it looks like ours + try + { + if (PortManager.IsPortUsedByMCPForUnity(oldPort)) + { + const int waitStepMs = 100; + int waited = 0; + while (waited < 300 && !PortManager.IsPortAvailable(oldPort)) + { + Thread.Sleep(waitStepMs); + waited += waitStepMs; + } + } + } + catch { } + + currentUnityPort = PortManager.DiscoverNewPort(); + + // Persist the new port so next time we start on this port + try + { + EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, currentUnityPort); + } + catch { } + + if (IsDebugEnabled()) + { + if (currentUnityPort == oldPort) + { + McpLog.Info($"Port {oldPort} became available, proceeding"); + } + else + { + McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}"); + } + } + + listener = CreateConfiguredListener(currentUnityPort); + listener.Start(); + break; + } + } + + isRunning = true; + isAutoConnectMode = false; + string platform = Application.platform.ToString(); + string serverVer = AssetPathUtility.GetPackageVersion(); + McpLog.Info($"StdioBridgeHost started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); + cts = new CancellationTokenSource(); + listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); + CommandRegistry.Initialize(); + EditorApplication.update += ProcessCommands; + try { EditorApplication.quitting -= Stop; } catch { } + try { EditorApplication.quitting += Stop; } catch { } + heartbeatSeq++; + WriteHeartbeat(false, "ready"); + nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; + } + catch (SocketException ex) + { + McpLog.Error($"Failed to start TCP listener: {ex.Message}"); + } + } + } + + private static TcpListener CreateConfiguredListener(int port) + { + var newListener = new TcpListener(IPAddress.Loopback, port); +#if !UNITY_EDITOR_OSX + newListener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#endif +#if UNITY_EDITOR_WIN + try + { + newListener.ExclusiveAddressUse = false; + } + catch { } +#endif + try + { + newListener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + } + return newListener; + } + + public static void Stop() + { + Task toWait = null; + lock (startStopLock) + { + if (!isRunning) + { + return; + } + + try + { + isRunning = false; + + var cancel = cts; + cts = null; + try { cancel?.Cancel(); } catch { } + + try { listener?.Stop(); } catch { } + listener = null; + + toWait = listenerTask; + listenerTask = null; + } + catch (Exception ex) + { + McpLog.Error($"Error stopping StdioBridgeHost: {ex.Message}"); + } + } + + TcpClient[] toClose; + lock (clientsLock) + { + toClose = activeClients.ToArray(); + activeClients.Clear(); + } + foreach (var c in toClose) + { + try { c.Close(); } catch { } + } + + if (toWait != null) + { + try { toWait.Wait(100); } catch { } + } + + try { EditorApplication.update -= ProcessCommands; } catch { } + try { EditorApplication.quitting -= Stop; } catch { } + + try + { + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + if (File.Exists(statusFile)) + { + File.Delete(statusFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}"); + } + } + catch (Exception ex) + { + if (IsDebugEnabled()) McpLog.Warn($"Failed to delete status file: {ex.Message}"); + } + + if (IsDebugEnabled()) McpLog.Info("StdioBridgeHost stopped."); + } + + private static async Task ListenerLoopAsync(CancellationToken token) + { + while (isRunning && !token.IsCancellationRequested) + { + try + { + TcpClient client = await listener.AcceptTcpClientAsync(); + client.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.KeepAlive, + true + ); + + client.ReceiveTimeout = 60000; + + _ = Task.Run(() => HandleClientAsync(client, token), token); + } + catch (ObjectDisposedException) + { + if (!isRunning || token.IsCancellationRequested) + { + break; + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + if (isRunning && !token.IsCancellationRequested) + { + if (IsDebugEnabled()) McpLog.Error($"Listener error: {ex.Message}"); + } + } + } + } + + private static async Task HandleClientAsync(TcpClient client, CancellationToken token) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + lock (clientsLock) { activeClients.Add(client); } + try + { + try + { + if (IsDebugEnabled()) + { + var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; + McpLog.Info($"Client connected {ep}"); + } + } + catch { } + try + { + client.NoDelay = true; + } + catch { } + try + { + string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; + byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); +#else + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); +#endif + if (IsDebugEnabled()) McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); + } + catch (Exception ex) + { + if (IsDebugEnabled()) McpLog.Warn($"Handshake failed: {ex.Message}"); + return; + } + + while (isRunning && !token.IsCancellationRequested) + { + try + { + string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); + + try + { + if (IsDebugEnabled()) + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + McpLog.Info($"recv framed: {preview}", always: false); + } + } + catch { } + string commandId = Guid.NewGuid().ToString(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (commandText.Trim() == "ping") + { + byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( + "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" + ); + await WriteFrameAsync(stream, pingResponseBytes); + continue; + } + + lock (lockObj) + { + commandQueue[commandId] = new QueuedCommand + { + CommandJson = commandText, + Tcs = tcs, + IsExecuting = false + }; + } + + string response; + try + { + using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); + var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); + if (completed == tcs.Task) + { + respCts.Cancel(); + response = tcs.Task.Result; + } + else + { + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + response = JsonConvert.SerializeObject(timeoutResponse); + } + } + catch (Exception ex) + { + var errorResponse = new + { + status = "error", + error = ex.Message, + }; + response = JsonConvert.SerializeObject(errorResponse); + } + + if (IsDebugEnabled()) + { + try { McpLog.Info("[MCP] sending framed response", always: false); } catch { } + } + long seq = Interlocked.Increment(ref _ioSeq); + byte[] responseBytes; + try + { + responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } + + var swDirect = System.Diagnostics.Stopwatch.StartNew(); + try + { + await WriteFrameAsync(stream, responseBytes); + swDirect.Stop(); + IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } + } + catch (Exception ex) + { + string msg = ex.Message ?? string.Empty; + bool isBenign = + msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 + || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 + || ex is IOException; + if (isBenign) + { + if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false); + } + else + { + McpLog.Error($"Client handler error: {msg}"); + } + break; + } + } + } + finally + { + lock (clientsLock) { activeClients.Remove(client); } + } + } + } + + private static async Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + { + byte[] buffer = new byte[count]; + int offset = 0; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (offset < count) + { + int remaining = count - offset; + int remainingTimeout = timeoutMs <= 0 + ? Timeout.Infinite + : timeoutMs - (int)stopwatch.ElapsedMilliseconds; + + if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) + { + throw new IOException("Read timed out"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); + if (remainingTimeout != Timeout.Infinite) + { + cts.CancelAfter(remainingTimeout); + } + + try + { +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); +#endif + if (read == 0) + { + throw new IOException("Connection closed before reading expected bytes"); + } + offset += read; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + throw new IOException("Read timed out"); + } + } + + return buffer; + } + + private static Task WriteFrameAsync(NetworkStream stream, byte[] payload) + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + return WriteFrameAsync(stream, payload, cts.Token); + } + + private static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + if ((ulong)payload.LongLength > MaxFrameBytes) + { + throw new IOException($"Frame too large: {payload.LongLength}"); + } + byte[] header = new byte[8]; + WriteUInt64BigEndian(header, (ulong)payload.LongLength); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); + await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); +#else + await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); +#endif + } + + private static async Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) + { + byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen > MaxFrameBytes) + { + throw new IOException($"Invalid framed length: {payloadLen}"); + } + if (payloadLen == 0UL) + throw new IOException("Zero-length frames are not allowed"); + if (payloadLen > int.MaxValue) + { + throw new IOException("Frame too large for buffer"); + } + int count = (int)payloadLen; + byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); + return System.Text.Encoding.UTF8.GetString(payload); + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + + private static void ProcessCommands() + { + if (!isRunning) return; + if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; + try + { + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + WriteHeartbeat(false); + nextHeartbeatAt = now + 0.5f; + } + + List<(string id, QueuedCommand command)> work; + lock (lockObj) + { + // Early exit inside lock to prevent per-frame List allocations (GitHub issue #577) + if (commandQueue.Count == 0) + { + return; + } + + work = new List<(string, QueuedCommand)>(commandQueue.Count); + foreach (var kvp in commandQueue) + { + var queued = kvp.Value; + if (queued.IsExecuting) continue; + queued.IsExecuting = true; + work.Add((kvp.Key, queued)); + } + } + + foreach (var item in work) + { + string id = item.id; + QueuedCommand queuedCommand = item.command; + string commandText = queuedCommand.CommandJson; + TaskCompletionSource tcs = queuedCommand.Tcs; + + if (string.IsNullOrWhiteSpace(commandText)) + { + var emptyResponse = new + { + status = "error", + error = "Empty command received", + }; + tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + commandText = commandText.Trim(); + if (commandText == "ping") + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 + ? commandText[..50] + "..." + : commandText, + }; + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + ExecuteQueuedCommand(id, commandText, tcs); + } + } + finally + { + Interlocked.Exchange(ref processingCommands, 0); + } + } + + private static void ExecuteQueuedCommand(string commandId, string payload, TaskCompletionSource completionSource) + { + async void Runner() + { + try + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + string response = await TransportCommandDispatcher.ExecuteCommandJsonAsync(payload, cts.Token).ConfigureAwait(true); + completionSource.TrySetResult(response); + } + catch (OperationCanceledException) + { + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(timeoutResponse)); + } + catch (Exception ex) + { + McpLog.Error($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + var response = new + { + status = "error", + error = ex.Message, + receivedText = payload?.Length > 50 + ? payload[..50] + "..." + : payload, + }; + completionSource.TrySetResult(JsonConvert.SerializeObject(response)); + } + finally + { + lock (lockObj) + { + commandQueue.Remove(commandId); + } + } + } + + Runner(); + } + + private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) + { + if (func == null) return null; + try + { + if (mainThreadId == 0) + { + try { return func(); } + catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } + } + try + { + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + return func(); + } + } + catch { } + + object result = null; + Exception captured = null; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EditorApplication.delayCall += () => + { + try + { + result = func(); + } + catch (Exception ex) + { + captured = ex; + } + finally + { + try { tcs.TrySetResult(true); } catch { } + } + }; + + bool completed = tcs.Task.Wait(timeoutMs); + if (!completed) + { + return null; + } + if (captured != null) + { + throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); + } + return result; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); + } + } + + private static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ( + (text.StartsWith("{") && text.EndsWith("}")) + || + (text.StartsWith("[") && text.EndsWith("]")) + ) + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + + + public static void WriteHeartbeat(bool reloading, string reason = null) + { + try + { + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + + string projectName = "Unknown"; + try + { + string projectPath = Application.dataPath; + if (!string.IsNullOrEmpty(projectPath)) + { + projectPath = projectPath.TrimEnd('/', '\\'); + if (projectPath.EndsWith("Assets", StringComparison.OrdinalIgnoreCase)) + { + projectPath = projectPath.Substring(0, projectPath.Length - 6).TrimEnd('/', '\\'); + } + projectName = Path.GetFileName(projectPath); + if (string.IsNullOrEmpty(projectName)) + { + projectName = "Unknown"; + } + } + } + catch { } + + var payload = new + { + unity_port = currentUnityPort, + reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, + project_path = Application.dataPath, + project_name = projectName, + unity_version = Application.unityVersion, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); + } + catch (Exception) + { + } + } + + private static string ComputeProjectHash(string input) + { + try + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new System.Text.StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; + } + catch + { + return "default"; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta new file mode 100644 index 0000000..543a015 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: fd295cefe518e438693c12e9c7f37488 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs new file mode 100644 index 0000000..ea3ed1a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Adapts the existing TCP bridge into the transport abstraction. + /// + public class StdioTransportClient : IMcpTransportClient + { + private TransportState _state = TransportState.Disconnected("stdio"); + + public bool IsConnected => StdioBridgeHost.IsRunning; + public string TransportName => "stdio"; + public TransportState State => _state; + + public Task StartAsync() + { + try + { + StdioBridgeHost.StartAutoConnect(); + _state = TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()); + return Task.FromResult(true); + } + catch (Exception ex) + { + _state = TransportState.Disconnected("stdio", ex.Message); + return Task.FromResult(false); + } + } + + public Task StopAsync() + { + StdioBridgeHost.Stop(); + _state = TransportState.Disconnected("stdio"); + return Task.CompletedTask; + } + + public Task VerifyAsync() + { + bool running = StdioBridgeHost.IsRunning; + _state = running + ? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort()) + : TransportState.Disconnected("stdio", "Bridge not running"); + return Task.FromResult(running); + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta new file mode 100644 index 0000000..cff5c0a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b2743f3468d5f433dbf2220f0838d8d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/Transports/StdioTransportClient.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs b/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs new file mode 100644 index 0000000..b94c083 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs @@ -0,0 +1,741 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Services.Transport; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Services.Transport.Transports +{ + /// + /// Maintains a persistent WebSocket connection to the MCP server plugin hub. + /// Handles registration, keep-alives, and command dispatch back into Unity via + /// . + /// + public class WebSocketTransportClient : IMcpTransportClient, IDisposable + { + private const string TransportDisplayName = "websocket"; + private static readonly TimeSpan[] ReconnectSchedule = + { + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }; + + private static readonly TimeSpan DefaultKeepAliveInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds(30); + + private readonly IToolDiscoveryService _toolDiscoveryService; + private ClientWebSocket _socket; + private CancellationTokenSource _lifecycleCts; + private CancellationTokenSource _connectionCts; + private Task _receiveTask; + private Task _keepAliveTask; + private readonly SemaphoreSlim _sendLock = new(1, 1); + + private Uri _endpointUri; + private string _sessionId; + private string _projectHash; + private string _projectName; + private string _projectPath; + private string _unityVersion; + private TimeSpan _keepAliveInterval = DefaultKeepAliveInterval; + private TimeSpan _socketKeepAliveInterval = DefaultKeepAliveInterval; + private volatile bool _isConnected; + private int _isReconnectingFlag; + private TransportState _state = TransportState.Disconnected(TransportDisplayName, "Transport not started"); + private string _apiKey; + private bool _disposed; + + public WebSocketTransportClient(IToolDiscoveryService toolDiscoveryService = null) + { + _toolDiscoveryService = toolDiscoveryService; + } + + public bool IsConnected => _isConnected; + public string TransportName => TransportDisplayName; + public TransportState State => _state; + + private Task> GetEnabledToolsOnMainThreadAsync(CancellationToken token) + { + return TransportCommandDispatcher.RunOnMainThreadAsync( + () => _toolDiscoveryService?.GetEnabledTools() ?? new List(), + token); + } + + public async Task StartAsync() + { + // Capture identity values on the main thread before any async context switching + _projectName = ProjectIdentityUtility.GetProjectName(); + _projectHash = ProjectIdentityUtility.GetProjectHash(); + _unityVersion = Application.unityVersion; + _apiKey = HttpEndpointUtility.IsRemoteScope() + ? EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty) + : string.Empty; + + // Get project root path (strip /Assets from dataPath) for focus nudging + string dataPath = Application.dataPath; + if (!string.IsNullOrEmpty(dataPath)) + { + string normalized = dataPath.TrimEnd('/', '\\'); + if (string.Equals(System.IO.Path.GetFileName(normalized), "Assets", StringComparison.Ordinal)) + { + _projectPath = System.IO.Path.GetDirectoryName(normalized) ?? normalized; + } + else + { + _projectPath = normalized; // Fallback if path doesn't end with Assets + } + } + + await StopAsync(); + + _lifecycleCts = new CancellationTokenSource(); + _endpointUri = BuildWebSocketUri(HttpEndpointUtility.GetBaseUrl()); + _sessionId = null; + + if (!await EstablishConnectionAsync(_lifecycleCts.Token)) + { + await StopAsync(); + return false; + } + + // State is connected but session ID might be pending until 'registered' message + _state = TransportState.Connected(TransportDisplayName, sessionId: "pending", details: _endpointUri.ToString()); + _isConnected = true; + return true; + } + + public async Task StopAsync() + { + if (_lifecycleCts == null) + { + return; + } + + try + { + _lifecycleCts.Cancel(); + } + catch { } + + await StopConnectionLoopsAsync().ConfigureAwait(false); + + if (_socket != null) + { + try + { + if (_socket.State == WebSocketState.Open || _socket.State == WebSocketState.CloseReceived) + { + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutdown", CancellationToken.None).ConfigureAwait(false); + } + } + catch { } + finally + { + _socket.Dispose(); + _socket = null; + } + } + + _isConnected = false; + _state = TransportState.Disconnected(TransportDisplayName); + + _lifecycleCts.Dispose(); + _lifecycleCts = null; + } + + public async Task VerifyAsync() + { + if (_socket == null || _socket.State != WebSocketState.Open) + { + return false; + } + + if (_lifecycleCts == null) + { + return false; + } + + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_lifecycleCts.Token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + await SendPongAsync(timeoutCts.Token).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Verify ping failed: {ex.Message}"); + return false; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + // Ensure background loops are stopped before disposing shared resources + StopAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Dispose failed to stop cleanly: {ex.Message}"); + } + + _sendLock?.Dispose(); + _socket?.Dispose(); + _lifecycleCts?.Dispose(); + _disposed = true; + } + + private async Task EstablishConnectionAsync(CancellationToken token) + { + await StopConnectionLoopsAsync().ConfigureAwait(false); + + _connectionCts?.Dispose(); + _connectionCts = CancellationTokenSource.CreateLinkedTokenSource(token); + CancellationToken connectionToken = _connectionCts.Token; + + _socket?.Dispose(); + _socket = new ClientWebSocket(); + _socket.Options.KeepAliveInterval = _socketKeepAliveInterval; + + // Add API key header if configured (for remote-hosted mode) + if (!string.IsNullOrEmpty(_apiKey)) + { + _socket.Options.SetRequestHeader(AuthConstants.ApiKeyHeader, _apiKey); + } + + try + { + await _socket.ConnectAsync(_endpointUri, connectionToken).ConfigureAwait(false); + } + catch (Exception ex) + { + string errorMsg = "Connection failed. Check that the server URL is correct, the server is running, and your API key (if required) is valid."; + McpLog.Error($"[WebSocket] {errorMsg} (Detail: {ex.Message})"); + _state = TransportState.Disconnected(TransportDisplayName, errorMsg); + return false; + } + + StartBackgroundLoops(connectionToken); + + try + { + await SendRegisterAsync(connectionToken).ConfigureAwait(false); + } + catch (Exception ex) + { + string regMsg = $"Registration with server failed: {ex.Message}"; + McpLog.Error($"[WebSocket] {regMsg}"); + _state = TransportState.Disconnected(TransportDisplayName, regMsg); + return false; + } + + return true; + } + + /// + /// Stops the connection loops and disposes of the connection CTS. + /// Particularly useful when reconnecting, we want to ensure that background loops are cancelled correctly before starting new oens + /// + /// Whether to await the receive and keep alive tasks before disposing. + private async Task StopConnectionLoopsAsync(bool awaitTasks = true) + { + if (_connectionCts != null && !_connectionCts.IsCancellationRequested) + { + try { _connectionCts.Cancel(); } catch { } + } + + if (_receiveTask != null) + { + if (awaitTasks) + { + try { await _receiveTask.ConfigureAwait(false); } catch { } + _receiveTask = null; + } + else if (_receiveTask.IsCompleted) + { + _receiveTask = null; + } + } + + if (_keepAliveTask != null) + { + if (awaitTasks) + { + try { await _keepAliveTask.ConfigureAwait(false); } catch { } + _keepAliveTask = null; + } + else if (_keepAliveTask.IsCompleted) + { + _keepAliveTask = null; + } + } + + if (_connectionCts != null) + { + _connectionCts.Dispose(); + _connectionCts = null; + } + } + + private void StartBackgroundLoops(CancellationToken token) + { + if ((_receiveTask != null && !_receiveTask.IsCompleted) || (_keepAliveTask != null && !_keepAliveTask.IsCompleted)) + { + return; + } + + _receiveTask = Task.Run(() => ReceiveLoopAsync(token), CancellationToken.None); + _keepAliveTask = Task.Run(() => KeepAliveLoopAsync(token), CancellationToken.None); + } + + private async Task ReceiveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + string message = await ReceiveMessageAsync(token).ConfigureAwait(false); + if (message == null) + { + continue; + } + await HandleMessageAsync(message, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (WebSocketException wse) + { + McpLog.Warn($"[WebSocket] Receive loop error: {wse.Message}"); + await HandleSocketClosureAsync(wse.Message).ConfigureAwait(false); + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Unexpected receive error: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task ReceiveMessageAsync(CancellationToken token) + { + if (_socket == null) + { + return null; + } + + byte[] rentedBuffer = System.Buffers.ArrayPool.Shared.Rent(8192); + var buffer = new ArraySegment(rentedBuffer); + using var ms = new MemoryStream(8192); + + try + { + while (!token.IsCancellationRequested) + { + WebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, token).ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + await HandleSocketClosureAsync(result.CloseStatusDescription ?? "Server closed connection").ConfigureAwait(false); + return null; + } + + if (result.Count > 0) + { + ms.Write(buffer.Array!, buffer.Offset, result.Count); + } + + if (result.EndOfMessage) + { + break; + } + } + + if (ms.Length == 0) + { + return null; + } + + return Encoding.UTF8.GetString(ms.ToArray()); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(rentedBuffer); + } + } + + private async Task HandleMessageAsync(string message, CancellationToken token) + { + JObject payload; + try + { + payload = JObject.Parse(message); + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Invalid JSON payload: {ex.Message}"); + return; + } + + string messageType = payload.Value("type") ?? string.Empty; + + switch (messageType) + { + case "welcome": + ApplyWelcome(payload); + break; + case "registered": + await HandleRegisteredAsync(payload, token).ConfigureAwait(false); + break; + case "execute": + await HandleExecuteAsync(payload, token).ConfigureAwait(false); + break; + case "ping": + await SendPongAsync(token).ConfigureAwait(false); + break; + default: + // No-op for unrecognised types (keep-alives, telemetry, etc.) + break; + } + } + + private void ApplyWelcome(JObject payload) + { + int? keepAliveSeconds = payload.Value("keepAliveInterval"); + if (keepAliveSeconds.HasValue && keepAliveSeconds.Value > 0) + { + _keepAliveInterval = TimeSpan.FromSeconds(keepAliveSeconds.Value); + _socketKeepAliveInterval = _keepAliveInterval; + } + + int? serverTimeoutSeconds = payload.Value("serverTimeout"); + if (serverTimeoutSeconds.HasValue) + { + int sourceSeconds = keepAliveSeconds ?? serverTimeoutSeconds.Value; + int safeSeconds = Math.Max(5, Math.Min(serverTimeoutSeconds.Value, sourceSeconds)); + _socketKeepAliveInterval = TimeSpan.FromSeconds(safeSeconds); + } + } + + private async Task HandleRegisteredAsync(JObject payload, CancellationToken token) + { + string newSessionId = payload.Value("session_id"); + if (!string.IsNullOrEmpty(newSessionId)) + { + _sessionId = newSessionId; + ProjectIdentityUtility.SetSessionId(_sessionId); + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + McpLog.Info($"[WebSocket] Registered with session ID: {_sessionId}", false); + + await SendRegisterToolsAsync(token).ConfigureAwait(false); + } + } + + private async Task SendRegisterToolsAsync(CancellationToken token) + { + if (_toolDiscoveryService == null) return; + + token.ThrowIfCancellationRequested(); + var tools = await GetEnabledToolsOnMainThreadAsync(token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + McpLog.Info($"[WebSocket] Preparing to register {tools.Count} tool(s) with the bridge.", false); + var toolsArray = new JArray(); + + foreach (var tool in tools) + { + var toolObj = new JObject + { + ["name"] = tool.Name, + ["description"] = tool.Description, + ["structured_output"] = tool.StructuredOutput, + ["requires_polling"] = tool.RequiresPolling, + ["poll_action"] = tool.PollAction + }; + + var paramsArray = new JArray(); + if (tool.Parameters != null) + { + foreach (var p in tool.Parameters) + { + paramsArray.Add(new JObject + { + ["name"] = p.Name, + ["description"] = p.Description, + ["type"] = p.Type, + ["required"] = p.Required, + ["default_value"] = p.DefaultValue + }); + } + } + toolObj["parameters"] = paramsArray; + toolsArray.Add(toolObj); + } + + var payload = new JObject + { + ["type"] = "register_tools", + ["tools"] = toolsArray + }; + + await SendJsonAsync(payload, token).ConfigureAwait(false); + McpLog.Info($"[WebSocket] Sent {tools.Count} tools registration", false); + } + + private async Task HandleExecuteAsync(JObject payload, CancellationToken token) + { + string commandId = payload.Value("id"); + string commandName = payload.Value("name"); + JObject parameters = payload.Value("params") ?? new JObject(); + int timeoutSeconds = payload.Value("timeout") ?? (int)DefaultCommandTimeout.TotalSeconds; + + if (string.IsNullOrEmpty(commandId) || string.IsNullOrEmpty(commandName)) + { + McpLog.Warn("[WebSocket] Invalid execute payload (missing id or name)"); + return; + } + + var commandEnvelope = new JObject + { + ["type"] = commandName, + ["params"] = parameters + }; + + string responseJson; + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds))); + responseJson = await TransportCommandDispatcher.ExecuteCommandJsonAsync(commandEnvelope.ToString(Formatting.None), timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = $"Command '{commandName}' timed out after {timeoutSeconds} seconds" + }); + } + catch (Exception ex) + { + responseJson = JsonConvert.SerializeObject(new + { + status = "error", + error = ex.Message + }); + } + + JToken resultToken; + try + { + resultToken = JToken.Parse(responseJson); + } + catch + { + resultToken = new JObject + { + ["status"] = "error", + ["error"] = "Invalid response payload" + }; + } + + var responsePayload = new JObject + { + ["type"] = "command_result", + ["id"] = commandId, + ["result"] = resultToken + }; + + await SendJsonAsync(responsePayload, token).ConfigureAwait(false); + } + + private async Task KeepAliveLoopAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_keepAliveInterval, token).ConfigureAwait(false); + if (_socket == null || _socket.State != WebSocketState.Open) + { + break; + } + await SendPongAsync(token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + McpLog.Warn($"[WebSocket] Keep-alive failed: {ex.Message}"); + await HandleSocketClosureAsync(ex.Message).ConfigureAwait(false); + break; + } + } + } + + private async Task SendRegisterAsync(CancellationToken token) + { + var registerPayload = new JObject + { + ["type"] = "register", + // session_id is now server-authoritative; omitted here or sent as null + ["project_name"] = _projectName, + ["project_hash"] = _projectHash, + ["unity_version"] = _unityVersion, + ["project_path"] = _projectPath + }; + + await SendJsonAsync(registerPayload, token).ConfigureAwait(false); + } + + private Task SendPongAsync(CancellationToken token) + { + var payload = new JObject + { + ["type"] = "pong", + ["session_id"] = _sessionId // Include session ID for server-side tracking + }; + return SendJsonAsync(payload, token); + } + + private async Task SendJsonAsync(JObject payload, CancellationToken token) + { + if (_socket == null) + { + throw new InvalidOperationException("WebSocket is not initialised"); + } + + string json = payload.ToString(Formatting.None); + byte[] bytes = Encoding.UTF8.GetBytes(json); + var buffer = new ArraySegment(bytes); + + await _sendLock.WaitAsync(token).ConfigureAwait(false); + try + { + if (_socket.State != WebSocketState.Open) + { + throw new InvalidOperationException("WebSocket is not open"); + } + + await _socket.SendAsync(buffer, WebSocketMessageType.Text, true, token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + private async Task HandleSocketClosureAsync(string reason) + { + // Capture stack trace for debugging disconnection triggers + var stackTrace = new System.Diagnostics.StackTrace(true); + McpLog.Debug($"[WebSocket] HandleSocketClosureAsync called. Reason: {reason}\nStack trace:\n{stackTrace}"); + + if (_lifecycleCts == null || _lifecycleCts.IsCancellationRequested) + { + return; + } + + if (Interlocked.CompareExchange(ref _isReconnectingFlag, 1, 0) != 0) + { + return; + } + + _isConnected = false; + _state = _state.WithError(reason ?? "Connection closed"); + McpLog.Warn($"[WebSocket] Connection closed: {reason}"); + + await StopConnectionLoopsAsync(awaitTasks: false).ConfigureAwait(false); + + _ = Task.Run(() => AttemptReconnectAsync(_lifecycleCts.Token), CancellationToken.None); + } + + private async Task AttemptReconnectAsync(CancellationToken token) + { + try + { + await StopConnectionLoopsAsync().ConfigureAwait(false); + + foreach (TimeSpan delay in ReconnectSchedule) + { + if (token.IsCancellationRequested) + { + return; + } + + if (delay > TimeSpan.Zero) + { + try { await Task.Delay(delay, token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + + if (await EstablishConnectionAsync(token).ConfigureAwait(false)) + { + _state = TransportState.Connected(TransportDisplayName, sessionId: _sessionId, details: _endpointUri.ToString()); + _isConnected = true; + McpLog.Info("[WebSocket] Reconnected to MCP server", false); + return; + } + } + } + finally + { + Interlocked.Exchange(ref _isReconnectingFlag, 0); + } + + _state = TransportState.Disconnected(TransportDisplayName, "Failed to reconnect"); + } + + private static Uri BuildWebSocketUri(string baseUrl) + { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var httpUri)) + { + throw new InvalidOperationException($"Invalid MCP base URL: {baseUrl}"); + } + + // Replace bind-only addresses with localhost for client connections + // 0.0.0.0 and :: are only valid for server binding, not client connections + string host = httpUri.Host; + if (host == "0.0.0.0" || host == "::") + { + McpLog.Warn($"[WebSocket] Base URL host '{host}' is bind-only; using 'localhost' for client connection."); + host = "localhost"; + } + + var builder = new UriBuilder(httpUri) + { + Scheme = httpUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws", + Host = host, + Path = httpUri.AbsolutePath.TrimEnd('/') + "/hub/plugin" + }; + + return builder.Uri; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta b/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta new file mode 100644 index 0000000..8dba006 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 044c8f7beb4af4a77a14d677190c21dc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Services/Transport/Transports/WebSocketTransportClient.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Setup.meta b/Assets/MCPForUnity/Editor/Setup.meta new file mode 100644 index 0000000..1157b1e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Setup.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 600c9cb20c329d761bfa799158a87bac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs b/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs new file mode 100644 index 0000000..5a63bb9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs @@ -0,0 +1,109 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Handles automatic triggering of the MCP setup window and exposes menu entry points + /// + public static class SetupWindowService + { + private const string SETUP_COMPLETED_KEY = EditorPrefKeys.SetupCompleted; + private const string SETUP_DISMISSED_KEY = EditorPrefKeys.SetupDismissed; + + // Use SessionState to persist "checked this editor session" across domain reloads. + // SessionState survives assembly reloads within the same Editor session, which prevents + // the setup window from reappearing after code reloads / playmode transitions. + private const string SessionCheckedKey = "MCPForUnity.SetupWindowCheckedThisEditorSession"; + + static SetupWindowService() + { + // Skip in batch mode + if (Application.isBatchMode) + return; + + // Show Setup Window on package import + EditorApplication.delayCall += CheckSetupNeeded; + } + + /// + /// Check if Setup Window should be shown + /// + private static void CheckSetupNeeded() + { + // Ensure we only run once per Editor session (survives domain reloads). + // This avoids showing the setup dialog repeatedly when scripts recompile or Play mode toggles. + if (SessionState.GetBool(SessionCheckedKey, false)) + return; + + SessionState.SetBool(SessionCheckedKey, true); + + try + { + // Check if setup was already completed or dismissed in previous sessions + bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); + bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); + + // Only show Setup Window if it hasn't been completed or dismissed before + if (!(setupCompleted || setupDismissed)) + { + McpLog.Info("Package imported - showing Setup Window", always: false); + + var dependencyResult = DependencyManager.CheckAllDependencies(); + EditorApplication.delayCall += () => ShowSetupWindow(dependencyResult); + } + else + { + McpLog.Info( + "Setup Window skipped - previously completed or dismissed", + always: false + ); + } + } + catch (Exception ex) + { + McpLog.Error($"Error checking setup status: {ex.Message}"); + } + } + + /// + /// Show the setup window + /// + public static void ShowSetupWindow(DependencyCheckResult dependencyResult = null) + { + try + { + dependencyResult ??= DependencyManager.CheckAllDependencies(); + MCPSetupWindow.ShowWindow(dependencyResult); + } + catch (Exception ex) + { + McpLog.Error($"Error showing setup window: {ex.Message}"); + } + } + + /// + /// Mark setup as completed + /// + public static void MarkSetupCompleted() + { + EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); + McpLog.Info("Setup marked as completed"); + } + + /// + /// Mark setup as dismissed + /// + public static void MarkSetupDismissed() + { + EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); + McpLog.Info("Setup marked as dismissed"); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta b/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta new file mode 100644 index 0000000..0089200 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d1bf468667bb649989e3ef53dafddea6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Setup/SetupWindowService.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools.meta b/Assets/MCPForUnity/Editor/Tools.meta new file mode 100644 index 0000000..2bc55f0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c97b83a6ac92a704b864eef27c3d285b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs b/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs new file mode 100644 index 0000000..d9df336 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Executes multiple MCP commands within a single Unity-side handler. Commands are executed sequentially + /// on the main thread to preserve determinism and Unity API safety. + /// + [McpForUnityTool("batch_execute", AutoRegister = false)] + public static class BatchExecute + { + private const int MaxCommandsPerBatch = 25; + + public static async Task HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("'commands' payload is required."); + } + + var commandsToken = @params["commands"] as JArray; + if (commandsToken == null || commandsToken.Count == 0) + { + return new ErrorResponse("Provide at least one command entry in 'commands'."); + } + + if (commandsToken.Count > MaxCommandsPerBatch) + { + return new ErrorResponse($"A maximum of {MaxCommandsPerBatch} commands are allowed per batch."); + } + + bool failFast = @params.Value("failFast") ?? false; + bool parallelRequested = @params.Value("parallel") ?? false; + int? maxParallel = @params.Value("maxParallelism"); + + if (parallelRequested) + { + McpLog.Warn("batch_execute parallel mode requested, but commands will run sequentially on the main thread for safety."); + } + + var commandResults = new List(commandsToken.Count); + int invocationSuccessCount = 0; + int invocationFailureCount = 0; + bool anyCommandFailed = false; + + foreach (var token in commandsToken) + { + if (token is not JObject commandObj) + { + invocationFailureCount++; + anyCommandFailed = true; + commandResults.Add(new + { + tool = (string)null, + callSucceeded = false, + error = "Command entries must be JSON objects." + }); + if (failFast) + { + break; + } + continue; + } + + string toolName = commandObj["tool"]?.ToString(); + var rawParams = commandObj["params"] as JObject ?? new JObject(); + var commandParams = NormalizeParameterKeys(rawParams); + + if (string.IsNullOrWhiteSpace(toolName)) + { + invocationFailureCount++; + anyCommandFailed = true; + commandResults.Add(new + { + tool = toolName, + callSucceeded = false, + error = "Each command must include a non-empty 'tool' field." + }); + if (failFast) + { + break; + } + continue; + } + + try + { + var result = await CommandRegistry.InvokeCommandAsync(toolName, commandParams).ConfigureAwait(true); + bool callSucceeded = DetermineCallSucceeded(result); + if (callSucceeded) + { + invocationSuccessCount++; + } + else + { + invocationFailureCount++; + anyCommandFailed = true; + } + + commandResults.Add(new + { + tool = toolName, + callSucceeded, + result + }); + + if (!callSucceeded && failFast) + { + break; + } + } + catch (Exception ex) + { + invocationFailureCount++; + anyCommandFailed = true; + commandResults.Add(new + { + tool = toolName, + callSucceeded = false, + error = ex.Message + }); + + if (failFast) + { + break; + } + } + } + + bool overallSuccess = !anyCommandFailed; + var data = new + { + results = commandResults, + callSuccessCount = invocationSuccessCount, + callFailureCount = invocationFailureCount, + parallelRequested, + parallelApplied = false, + maxParallelism = maxParallel + }; + + return overallSuccess + ? new SuccessResponse("Batch execution completed.", data) + : new ErrorResponse("One or more commands failed.", data); + } + + private static bool DetermineCallSucceeded(object result) + { + if (result == null) + { + return true; + } + + if (result is IMcpResponse response) + { + return response.Success; + } + + if (result is JObject obj) + { + var successToken = obj["success"]; + if (successToken != null && successToken.Type == JTokenType.Boolean) + { + return successToken.Value(); + } + } + + if (result is JToken token) + { + var successToken = token["success"]; + if (successToken != null && successToken.Type == JTokenType.Boolean) + { + return successToken.Value(); + } + } + + return true; + } + + private static JObject NormalizeParameterKeys(JObject source) + { + if (source == null) + { + return new JObject(); + } + + var normalized = new JObject(); + foreach (var property in source.Properties()) + { + string normalizedName = ToCamelCase(property.Name); + normalized[normalizedName] = NormalizeToken(property.Value); + } + return normalized; + } + + private static JArray NormalizeArray(JArray source) + { + var normalized = new JArray(); + foreach (var token in source) + { + normalized.Add(NormalizeToken(token)); + } + return normalized; + } + + private static JToken NormalizeToken(JToken token) + { + return token switch + { + JObject obj => NormalizeParameterKeys(obj), + JArray arr => NormalizeArray(arr), + _ => token.DeepClone() + }; + } + + private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs.meta b/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs.meta new file mode 100644 index 0000000..7c62c22 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/BatchExecute.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4e1e2d8f3a454a37b18d06a7a7b6c3fb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/BatchExecute.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs b/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs new file mode 100644 index 0000000..ca39ea5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Resources; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Holds information about a registered command handler. + /// + class HandlerInfo + { + public string CommandName { get; } + public Func SyncHandler { get; } + public Func> AsyncHandler { get; } + + public bool IsAsync => AsyncHandler != null; + + public HandlerInfo(string commandName, Func syncHandler, Func> asyncHandler) + { + CommandName = commandName; + SyncHandler = syncHandler; + AsyncHandler = asyncHandler; + } + } + + /// + /// Registry for all MCP command handlers via reflection. + /// Handles both MCP tools and resources. + /// + public static class CommandRegistry + { + private static readonly Dictionary _handlers = new(); + private static bool _initialized = false; + + /// + /// Initialize and auto-discover all tools and resources marked with + /// [McpForUnityTool] or [McpForUnityResource] + /// + public static void Initialize() + { + if (_initialized) return; + + AutoDiscoverCommands(); + _initialized = true; + } + + private static string ToSnakeCase(string name) => StringCaseUtility.ToSnakeCase(name); + + /// + /// Auto-discover all types with [McpForUnityTool] or [McpForUnityResource] attributes + /// + private static void AutoDiscoverCommands() + { + try + { + var allTypes = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic) + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return new Type[0]; } + }) + .ToList(); + + // Discover tools + var toolTypes = allTypes.Where(t => t.GetCustomAttribute() != null); + int toolCount = 0; + foreach (var type in toolTypes) + { + if (RegisterCommandType(type, isResource: false)) + toolCount++; + } + + // Discover resources + var resourceTypes = allTypes.Where(t => t.GetCustomAttribute() != null); + int resourceCount = 0; + foreach (var type in resourceTypes) + { + if (RegisterCommandType(type, isResource: true)) + resourceCount++; + } + + McpLog.Info($"Auto-discovered {toolCount} tools and {resourceCount} resources ({_handlers.Count} total handlers)", false); + } + catch (Exception ex) + { + McpLog.Error($"Failed to auto-discover MCP commands: {ex.Message}"); + } + } + + /// + /// Register a command type (tool or resource) with the registry. + /// Returns true if successfully registered, false otherwise. + /// + private static bool RegisterCommandType(Type type, bool isResource) + { + string commandName; + string typeLabel = isResource ? "resource" : "tool"; + + // Get command name from appropriate attribute + if (isResource) + { + var resourceAttr = type.GetCustomAttribute(); + commandName = resourceAttr.ResourceName; + } + else + { + var toolAttr = type.GetCustomAttribute(); + commandName = toolAttr.CommandName; + } + + // Auto-generate command name if not explicitly provided + if (string.IsNullOrEmpty(commandName)) + { + commandName = ToSnakeCase(type.Name); + } + + // Check for duplicate command names + if (_handlers.ContainsKey(commandName)) + { + McpLog.Warn( + $"Duplicate command name '{commandName}' detected. " + + $"{typeLabel} {type.Name} will override previously registered handler." + ); + } + + // Find HandleCommand method + var method = type.GetMethod( + "HandleCommand", + BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(JObject) }, + null + ); + + if (method == null) + { + McpLog.Warn( + $"MCP {typeLabel} {type.Name} is marked with [McpForUnity{(isResource ? "Resource" : "Tool")}] " + + $"but has no public static HandleCommand(JObject) method" + ); + return false; + } + + try + { + HandlerInfo handlerInfo; + + if (typeof(Task).IsAssignableFrom(method.ReturnType)) + { + var asyncHandler = CreateAsyncHandlerDelegate(method, commandName); + handlerInfo = new HandlerInfo(commandName, null, asyncHandler); + } + else + { + var handler = (Func)Delegate.CreateDelegate( + typeof(Func), + method + ); + handlerInfo = new HandlerInfo(commandName, handler, null); + } + + _handlers[commandName] = handlerInfo; + return true; + } + catch (Exception ex) + { + McpLog.Error($"Failed to register {typeLabel} {type.Name}: {ex.Message}"); + return false; + } + } + + /// + /// Get a command handler by name + /// + private static HandlerInfo GetHandlerInfo(string commandName) + { + if (!_handlers.TryGetValue(commandName, out var handler)) + { + throw new InvalidOperationException( + $"Unknown or unsupported command type: {commandName}" + ); + } + return handler; + } + + /// + /// Get a synchronous command handler by name. + /// Throws if the command is asynchronous. + /// + /// + /// + /// + public static Func GetHandler(string commandName) + { + var handlerInfo = GetHandlerInfo(commandName); + if (handlerInfo.IsAsync) + { + throw new InvalidOperationException( + $"Command '{commandName}' is asynchronous and must be executed via ExecuteCommand" + ); + } + + return handlerInfo.SyncHandler; + } + + /// + /// Execute a command handler, supporting both synchronous and asynchronous (coroutine) handlers. + /// If the handler returns an IEnumerator, it will be executed as a coroutine. + /// + /// The command name to execute + /// Command parameters + /// TaskCompletionSource to complete when async operation finishes + /// The result for synchronous commands, or null for async commands (TCS will be completed later) + public static object ExecuteCommand(string commandName, JObject @params, TaskCompletionSource tcs) + { + var handlerInfo = GetHandlerInfo(commandName); + + if (handlerInfo.IsAsync) + { + ExecuteAsyncHandler(handlerInfo, @params, commandName, tcs); + return null; + } + + if (handlerInfo.SyncHandler == null) + { + throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation"); + } + + return handlerInfo.SyncHandler(@params); + } + + /// + /// Execute a command handler and return its raw result, regardless of sync or async implementation. + /// Used internally for features like batch execution where commands need to be composed. + /// + /// The registered command to execute. + /// Parameters to pass to the command (optional). + public static Task InvokeCommandAsync(string commandName, JObject @params) + { + var handlerInfo = GetHandlerInfo(commandName); + var payload = @params ?? new JObject(); + + if (handlerInfo.IsAsync) + { + if (handlerInfo.AsyncHandler == null) + { + throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly"); + } + + return handlerInfo.AsyncHandler(payload); + } + + if (handlerInfo.SyncHandler == null) + { + throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation"); + } + + object result = handlerInfo.SyncHandler(payload); + return Task.FromResult(result); + } + + /// + /// Create a delegate for an async handler method that returns Task or Task. + /// The delegate will invoke the method and await its completion, returning the result. + /// + /// + /// + /// + /// + private static Func> CreateAsyncHandlerDelegate(MethodInfo method, string commandName) + { + return async (JObject parameters) => + { + object rawResult; + + try + { + rawResult = method.Invoke(null, new object[] { parameters }); + } + catch (TargetInvocationException ex) + { + throw ex.InnerException ?? ex; + } + + if (rawResult == null) + { + return null; + } + + if (rawResult is not Task task) + { + throw new InvalidOperationException( + $"Async handler '{commandName}' returned an object that is not a Task" + ); + } + + await task.ConfigureAwait(true); + + var taskType = task.GetType(); + if (taskType.IsGenericType) + { + var resultProperty = taskType.GetProperty("Result"); + if (resultProperty != null) + { + return resultProperty.GetValue(task); + } + } + + return null; + }; + } + + private static void ExecuteAsyncHandler( + HandlerInfo handlerInfo, + JObject parameters, + string commandName, + TaskCompletionSource tcs) + { + if (handlerInfo.AsyncHandler == null) + { + throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly"); + } + + Task handlerTask; + + try + { + handlerTask = handlerInfo.AsyncHandler(parameters); + } + catch (Exception ex) + { + ReportAsyncFailure(commandName, tcs, ex); + return; + } + + if (handlerTask == null) + { + CompleteAsyncCommand(commandName, tcs, null); + return; + } + + async void AwaitHandler() + { + try + { + var finalResult = await handlerTask.ConfigureAwait(true); + CompleteAsyncCommand(commandName, tcs, finalResult); + } + catch (Exception ex) + { + ReportAsyncFailure(commandName, tcs, ex); + } + } + + AwaitHandler(); + } + + /// + /// Complete the TaskCompletionSource for an async command with a success result. + /// + /// + /// + /// + private static void CompleteAsyncCommand(string commandName, TaskCompletionSource tcs, object result) + { + try + { + var response = new { status = "success", result }; + string json = JsonConvert.SerializeObject(response); + + if (!tcs.TrySetResult(json)) + { + McpLog.Warn($"TCS for async command '{commandName}' was already completed"); + } + } + catch (Exception ex) + { + McpLog.Error($"Error completing async command '{commandName}': {ex.Message}\n{ex.StackTrace}"); + ReportAsyncFailure(commandName, tcs, ex); + } + } + + /// + /// Report an error that occurred during async command execution. + /// Completes the TaskCompletionSource with an error response. + /// + /// + /// + /// + private static void ReportAsyncFailure(string commandName, TaskCompletionSource tcs, Exception ex) + { + McpLog.Error($"Error in async command '{commandName}': {ex.Message}\n{ex.StackTrace}"); + + var errorResponse = new + { + status = "error", + error = ex.Message, + command = commandName, + stackTrace = ex.StackTrace + }; + + string json; + try + { + json = JsonConvert.SerializeObject(errorResponse); + } + catch (Exception serializationEx) + { + McpLog.Error($"Failed to serialize error response for '{commandName}': {serializationEx.Message}"); + json = "{\"status\":\"error\",\"error\":\"Failed to complete command\"}"; + } + + if (!tcs.TrySetResult(json)) + { + McpLog.Warn($"TCS for async command '{commandName}' was already completed when trying to report error"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta b/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta new file mode 100644 index 0000000..bad3002 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5b61b5a84813b5749a5c64422694a0fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/CommandRegistry.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs b/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs new file mode 100644 index 0000000..f606f77 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("execute_menu_item", AutoRegister = false)] + /// + /// Tool to execute a Unity Editor menu item by its path. + /// + public static class ExecuteMenuItem + { + // Basic blacklist to prevent execution of disruptive menu items. + private static readonly HashSet _menuPathBlacklist = new HashSet( + StringComparer.OrdinalIgnoreCase) + { + "File/Quit", + }; + + public static object HandleCommand(JObject @params) + { + McpLog.Info("[ExecuteMenuItem] Handling menu item command"); + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(menuPath)) + { + return new ErrorResponse("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + if (_menuPathBlacklist.Contains(menuPath)) + { + return new ErrorResponse($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + } + + try + { + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (!executed) + { + McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + return new ErrorResponse($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + } + return new SuccessResponse($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + } + catch (Exception e) + { + McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); + return new ErrorResponse($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs.meta b/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs.meta new file mode 100644 index 0000000..ac8d107 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 269232350d16a464091aea9e9fcc9b55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ExecuteMenuItem.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs b/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs new file mode 100644 index 0000000..d04f094 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Tool for searching GameObjects in the scene. + /// Returns only instance IDs with pagination support. + /// + /// This is a focused search tool that returns lightweight results (IDs only). + /// For detailed GameObject data, use the unity://scene/gameobject/{id} resource. + /// + [McpForUnityTool("find_gameobjects")] + public static class FindGameObjects + { + /// + /// Handles the find_gameobjects command. + /// + /// Command parameters + /// Paginated list of instance IDs + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + // Parse search parameters + string searchMethod = p.Get("searchMethod", "by_name"); + + // Try searchTerm, search_term, or target (for backwards compatibility) + string searchTerm = p.Get("searchTerm"); + if (string.IsNullOrEmpty(searchTerm)) + { + searchTerm = p.Get("target"); + } + + if (string.IsNullOrEmpty(searchTerm)) + { + return new ErrorResponse("'searchTerm' or 'target' parameter is required."); + } + + // Pagination parameters using standard PaginationRequest + var pagination = PaginationRequest.FromParams(@params, defaultPageSize: 50); + pagination.PageSize = Mathf.Clamp(pagination.PageSize, 1, 500); + + // Search options (supports multiple parameter name variants) + bool includeInactive = p.GetBool("includeInactive", false) || + p.GetBool("searchInactive", false); + + try + { + // Get all matching instance IDs + var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0); + + // Use standard pagination response + var paginatedResult = PaginationResponse.Create(allIds, pagination); + + return new SuccessResponse("Found GameObjects", new + { + instanceIDs = paginatedResult.Items, + pageSize = paginatedResult.PageSize, + cursor = paginatedResult.Cursor, + nextCursor = paginatedResult.NextCursor, + totalCount = paginatedResult.TotalCount, + hasMore = paginatedResult.HasMore + }); + } + catch (System.Exception ex) + { + McpLog.Error($"[FindGameObjects] Error searching GameObjects: {ex.Message}"); + return new ErrorResponse($"Error searching GameObjects: {ex.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta b/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta new file mode 100644 index 0000000..28e9519 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4511082b395b14922b34e90f7a23027e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/FindGameObjects.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects.meta new file mode 100644 index 0000000..a1ba389 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b61d0e8082ed14c1fb500648007bba7a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs new file mode 100644 index 0000000..589374a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs @@ -0,0 +1,142 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Component resolver that delegates to UnityTypeResolver. + /// Kept for backwards compatibility. + /// + internal static class ComponentResolver + { + /// + /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. + /// Delegates to UnityTypeResolver.TryResolve with Component constraint. + /// + public static bool TryResolve(string nameOrFullName, out Type type, out string error) + { + return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component)); + } + + /// + /// Gets all accessible property and field names from a component type. + /// + public static List GetAllComponentProperties(Type componentType) + { + if (componentType == null) return new List(); + + var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => p.Name); + + var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) + .Where(f => !f.IsInitOnly && !f.IsLiteral) + .Select(f => f.Name); + + // Also include SerializeField private fields (common in Unity) + var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.GetCustomAttribute() != null) + .Select(f => f.Name); + + return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); + } + + /// + /// Suggests the most likely property matches for a user's input using fuzzy matching. + /// Uses Levenshtein distance, substring matching, and common naming pattern heuristics. + /// + public static List GetFuzzyPropertySuggestions(string userInput, List availableProperties) + { + if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) + return new List(); + + var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; + if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); + PropertySuggestionCache[cacheKey] = suggestions; + return suggestions; + } + catch (Exception ex) + { + McpLog.Warn($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); + return new List(); + } + } + + private static readonly Dictionary> PropertySuggestionCache = new(); + + /// + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. + /// + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) + { + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) + { + var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + if (cleanedProperty == cleanedInput) + { + suggestions.Add(property); + continue; + } + + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) + { + suggestions.Add(property); + continue; + } + + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); + } + } + + return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) + .Take(3) + .ToList(); + } + + /// + /// Calculates Levenshtein distance between two strings for similarity matching. + /// + private static int LevenshteinDistance(string s1, string s2) + { + if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; + if (string.IsNullOrEmpty(s2)) return s1.Length; + + var matrix = new int[s1.Length + 1, s2.Length + 1]; + + for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; + for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; + + for (int i = 1; i <= s1.Length; i++) + { + for (int j = 1; j <= s2.Length; j++) + { + int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, + matrix[i, j - 1] + 1), + matrix[i - 1, j - 1] + cost); + } + } + + return matrix[s1.Length, s2.Length]; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta new file mode 100644 index 0000000..0c41298 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f5e5a46bdebc040c68897fa4b5e689c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs new file mode 100644 index 0000000..e37d70a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs @@ -0,0 +1,410 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectComponentHelpers + { + internal static object AddComponentInternal(GameObject targetGo, string typeName, JObject properties) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return new ErrorResponse($"Component type '{typeName}' not found or is not a valid Component."); + } + if (!typeof(Component).IsAssignableFrom(componentType)) + { + return new ErrorResponse($"Type '{typeName}' is not a Component."); + } + + if (componentType == typeof(Transform)) + { + return new ErrorResponse("Cannot add another Transform component."); + } + + bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); + bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) + { + return new ErrorResponse($"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider."); + } + } + else if (isAdding3DPhysics) + { + if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) + { + return new ErrorResponse($"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider."); + } + } + + try + { + Component newComponent = Undo.AddComponent(targetGo, componentType); + if (newComponent == null) + { + return new ErrorResponse($"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." + ); + } + + if (newComponent is Light light) + { + light.type = LightType.Directional; + } + + if (properties != null) + { + var setResult = SetComponentPropertiesInternal(targetGo, typeName, properties, newComponent); + if (setResult != null) + { + Undo.DestroyObjectImmediate(newComponent); + return setResult; + } + } + + return null; + } + catch (Exception e) + { + return new ErrorResponse($"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}"); + } + } + + internal static object RemoveComponentInternal(GameObject targetGo, string typeName) + { + if (targetGo == null) + { + return new ErrorResponse("Target GameObject is null."); + } + + Type componentType = FindType(typeName); + if (componentType == null) + { + return new ErrorResponse($"Component type '{typeName}' not found for removal."); + } + + if (componentType == typeof(Transform)) + { + return new ErrorResponse("Cannot remove the Transform component."); + } + + Component componentToRemove = targetGo.GetComponent(componentType); + if (componentToRemove == null) + { + return new ErrorResponse($"Component '{typeName}' not found on '{targetGo.name}' to remove."); + } + + try + { + Undo.DestroyObjectImmediate(componentToRemove); + return null; + } + catch (Exception e) + { + return new ErrorResponse($"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}"); + } + } + + internal static object SetComponentPropertiesInternal(GameObject targetGo, string componentTypeName, JObject properties, Component targetComponentInstance = null) + { + Component targetComponent = targetComponentInstance; + if (targetComponent == null) + { + if (ComponentResolver.TryResolve(componentTypeName, out var compType, out var compError)) + { + targetComponent = targetGo.GetComponent(compType); + } + else + { + targetComponent = targetGo.GetComponent(componentTypeName); + } + } + if (targetComponent == null) + { + return new ErrorResponse($"Component '{componentTypeName}' not found on '{targetGo.name}' to set properties."); + } + + Undo.RecordObject(targetComponent, "Set Component Properties"); + + var failures = new List(); + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + + try + { + bool setResult = SetProperty(targetComponent, propName, propValue); + if (!setResult) + { + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties); + var msg = suggestions.Any() + ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" + : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; + McpLog.Warn($"[ManageGameObject] {msg}"); + failures.Add(msg); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageGameObject] Error setting property '{propName}' on '{componentTypeName}': {e.Message}"); + failures.Add($"Error setting '{propName}': {e.Message}"); + } + } + + EditorUtility.SetDirty(targetComponent); + return failures.Count == 0 + ? null + : new ErrorResponse($"One or more properties failed on '{componentTypeName}'.", new { errors = failures }); + } + + private static JsonSerializer InputSerializer => UnityJsonSerializer.Instance; + + private static bool SetProperty(object target, string memberName, JToken value) + { + Type type = target.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + string normalizedName = Helpers.ParamCoercion.NormalizePropertyName(memberName); + var inputSerializer = InputSerializer; + + try + { + if (memberName.Contains('.') || memberName.Contains('[')) + { + return SetNestedProperty(target, memberName, value, inputSerializer); + } + + PropertyInfo propInfo = type.GetProperty(memberName, flags) ?? type.GetProperty(normalizedName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + FieldInfo fieldInfo = type.GetField(memberName, flags) ?? type.GetField(normalizedName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase) + ?? type.GetField(normalizedName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (npField != null && npField.GetCustomAttribute() != null) + { + object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + npField.SetValue(target, convertedValue); + return true; + } + } + } + } + } + catch (Exception ex) + { + McpLog.Error($"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}"); + } + return false; + } + + private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) + { + try + { + string[] pathParts = SplitPropertyPath(path); + if (pathParts.Length == 0) + return false; + + object currentObject = target; + Type currentType = currentObject.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + for (int i = 0; i < pathParts.Length - 1; i++) + { + string part = pathParts[i]; + bool isArray = false; + int arrayIndex = -1; + + if (part.Contains("[")) + { + int startBracket = part.IndexOf('['); + int endBracket = part.IndexOf(']'); + if (startBracket > 0 && endBracket > startBracket) + { + string indexStr = part.Substring(startBracket + 1, endBracket - startBracket - 1); + if (int.TryParse(indexStr, out arrayIndex)) + { + isArray = true; + part = part.Substring(0, startBracket); + } + } + } + + PropertyInfo propInfo = currentType.GetProperty(part, flags); + FieldInfo fieldInfo = null; + if (propInfo == null) + { + fieldInfo = currentType.GetField(part, flags); + if (fieldInfo == null) + { + McpLog.Warn($"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'"); + return false; + } + } + + currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); + if (currentObject == null) + { + McpLog.Warn($"[SetNestedProperty] Property '{part}' is null, cannot access nested properties."); + return false; + } + + if (isArray) + { + if (currentObject is Material[]) + { + var materials = currentObject as Material[]; + if (arrayIndex < 0 || arrayIndex >= materials.Length) + { + McpLog.Warn($"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})"); + return false; + } + currentObject = materials[arrayIndex]; + } + else if (currentObject is System.Collections.IList) + { + var list = currentObject as System.Collections.IList; + if (arrayIndex < 0 || arrayIndex >= list.Count) + { + McpLog.Warn($"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})"); + return false; + } + currentObject = list[arrayIndex]; + } + else + { + McpLog.Warn($"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index."); + return false; + } + } + + currentType = currentObject.GetType(); + } + + string finalPart = pathParts[pathParts.Length - 1]; + + if (currentObject is Material material && finalPart.StartsWith("_")) + { + return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer); + } + + PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); + if (finalPropInfo != null && finalPropInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalPropInfo.SetValue(currentObject, convertedValue); + return true; + } + } + else + { + FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); + if (finalFieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalFieldInfo.SetValue(currentObject, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + McpLog.Error($"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}"); + } + + return false; + } + + private static string[] SplitPropertyPath(string path) + { + List parts = new List(); + int startIndex = 0; + bool inBrackets = false; + + for (int i = 0; i < path.Length; i++) + { + char c = path[i]; + + if (c == '[') + { + inBrackets = true; + } + else if (c == ']') + { + inBrackets = false; + } + else if (c == '.' && !inBrackets) + { + parts.Add(path.Substring(startIndex, i - startIndex)); + startIndex = i + 1; + } + } + if (startIndex < path.Length) + { + parts.Add(path.Substring(startIndex)); + } + return parts.ToArray(); + } + + private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) + { + return PropertyConversion.ConvertToType(token, targetType); + } + + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + return resolvedType; + } + + if (!string.IsNullOrEmpty(error)) + { + McpLog.Warn($"[FindType] {error}"); + } + + return null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta new file mode 100644 index 0000000..c56d67b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b580af06e2d3a4788960f3f779edac54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs new file mode 100644 index 0000000..6954a26 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs @@ -0,0 +1,338 @@ +#nullable disable +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectCreate + { + internal static object Handle(JObject @params) + { + string name = @params["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return new ErrorResponse("'name' parameter is required for 'create' action."); + } + + // Get prefab creation parameters + bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; + string prefabPath = @params["prefabPath"]?.ToString(); + string tag = @params["tag"]?.ToString(); + string primitiveType = @params["primitiveType"]?.ToString(); + GameObject newGo = null; + + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; + if (!saveAsPrefab && !string.IsNullOrEmpty(prefabPath)) + { + string extension = System.IO.Path.GetExtension(prefabPath); + + if (!prefabPath.Contains("/") && (string.IsNullOrEmpty(extension) || extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase))) + { + string prefabNameOnly = prefabPath; + McpLog.Info($"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'"); + string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); + if (guids.Length == 0) + { + return new ErrorResponse($"Prefab named '{prefabNameOnly}' not found anywhere in the project."); + } + else if (guids.Length > 1) + { + string foundPaths = string.Join(", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g))); + return new ErrorResponse($"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path."); + } + else + { + prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); + McpLog.Info($"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'"); + } + } + else if (prefabPath.Contains("/") && string.IsNullOrEmpty(extension)) + { + McpLog.Warn($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' has no extension. Assuming it's a prefab and appending .prefab."); + prefabPath += ".prefab"; + } + else if (!prefabPath.Contains("/") && !string.IsNullOrEmpty(extension) && !extension.Equals(".prefab", StringComparison.OrdinalIgnoreCase)) + { + string fileName = prefabPath; + string fileNameWithoutExtension = System.IO.Path.GetFileNameWithoutExtension(fileName); + McpLog.Info($"[ManageGameObject.Create] Searching for asset file named: '{fileName}'"); + + string[] guids = AssetDatabase.FindAssets(fileNameWithoutExtension); + var matches = guids + .Select(g => AssetDatabase.GUIDToAssetPath(g)) + .Where(p => p.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || p.Equals(fileName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (matches.Length == 0) + { + return new ErrorResponse($"Asset file '{fileName}' not found anywhere in the project."); + } + else if (matches.Length > 1) + { + string foundPaths = string.Join(", ", matches); + return new ErrorResponse($"Multiple assets found matching file name '{fileName}': {foundPaths}. Please provide a more specific path."); + } + else + { + prefabPath = matches[0]; + McpLog.Info($"[ManageGameObject.Create] Found unique asset at path: '{prefabPath}'"); + } + } + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) + { + try + { + newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; + + if (newGo == null) + { + McpLog.Error($"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject."); + return new ErrorResponse($"Failed to instantiate prefab at '{prefabPath}'."); + } + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + Undo.RegisterCreatedObjectUndo(newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'"); + McpLog.Info($"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'."); + } + catch (Exception e) + { + return new ErrorResponse($"Error instantiating prefab '{prefabPath}': {e.Message}"); + } + } + else + { + return new ErrorResponse($"Asset not found or not a GameObject at path: '{prefabPath}'."); + } + } + + // --- Fallback: Create Primitive or Empty GameObject --- + bool createdNewObject = false; + if (newGo == null) + { + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse("'name' parameter is required when creating a primitive."); + } + createdNewObject = true; + } + catch (ArgumentException) + { + return new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}"); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create primitive '{primitiveType}': {e.Message}"); + } + } + else + { + if (string.IsNullOrEmpty(name)) + { + return new ErrorResponse("'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive."); + } + newGo = new GameObject(name); + createdNewObject = true; + } + + if (createdNewObject) + { + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); + } + } + + if (newGo == null) + { + return new ErrorResponse("Failed to create or instantiate the GameObject."); + } + + Undo.RecordObject(newGo.transform, "Set GameObject Transform"); + Undo.RecordObject(newGo, "Set GameObject Properties"); + + // Set Parent + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject parentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if (parentGo == null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Parent specified ('{parentToken}') but not found."); + } + newGo.transform.SetParent(parentGo.transform, true); + } + + // Set Transform + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); + + if (position.HasValue) newGo.transform.localPosition = position.Value; + if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; + if (scale.HasValue) newGo.transform.localScale = scale.Value; + + // Set Tag + if (!string.IsNullOrEmpty(tag)) + { + if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag)) + { + McpLog.Info($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it."); + try + { + InternalEditorUtility.AddTag(tag); + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}."); + } + } + + try + { + newGo.tag = tag; + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}."); + } + } + + // Set Layer + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newGo.layer = layerId; + } + else + { + McpLog.Warn($"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer."); + } + } + + // Add Components + if (@params["componentsToAdd"] is JArray componentsToAddArray) + { + foreach (var compToken in componentsToAddArray) + { + string typeName = null; + JObject properties = null; + + if (compToken.Type == JTokenType.String) + { + typeName = compToken.ToString(); + } + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = GameObjectComponentHelpers.AddComponentInternal(newGo, typeName, properties); + if (addResult != null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return addResult; + } + } + else + { + McpLog.Warn($"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}"); + } + } + } + + // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true + GameObject finalInstance = newGo; + if (createdNewObject && saveAsPrefab) + { + string finalPrefabPath = prefabPath; + if (string.IsNullOrEmpty(finalPrefabPath)) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse("'prefabPath' is required when 'saveAsPrefab' is true and creating a new object."); + } + if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + McpLog.Info($"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'"); + finalPrefabPath += ".prefab"; + } + + try + { + string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); + if (!string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath)) + { + System.IO.Directory.CreateDirectory(directoryPath); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + McpLog.Info($"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"); + } + + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction); + + if (finalInstance == null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions."); + } + McpLog.Info($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected."); + } + catch (Exception e) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + } + } + + Selection.activeGameObject = finalInstance; + + string messagePrefabPath = + finalInstance == null + ? originalPrefabPath + : AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance); + + string successMessage; + if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) + { + successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; + } + else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) + { + successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; + } + else + { + successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; + } + + return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta new file mode 100644 index 0000000..ba239b0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 0931774a07e4b4626b4261dd8d0974c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs new file mode 100644 index 0000000..f568185 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs @@ -0,0 +1,48 @@ +#nullable disable +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectDelete + { + internal static object Handle(JToken targetToken, string searchMethod) + { + List targets = ManageGameObjectCommon.FindObjectsInternal(targetToken, searchMethod, true); + + if (targets.Count == 0) + { + return new ErrorResponse($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + List deletedObjects = new List(); + foreach (var targetGo in targets) + { + if (targetGo != null) + { + string goName = targetGo.name; + int goId = targetGo.GetInstanceID(); + // Note: Undo.DestroyObjectImmediate doesn't work reliably in test context, + // so we use Object.DestroyImmediate. This means delete isn't undoable. + // TODO: Investigate Undo.DestroyObjectImmediate behavior in Unity 2022+ + Object.DestroyImmediate(targetGo); + deletedObjects.Add(new { name = goName, instanceID = goId }); + } + } + + if (deletedObjects.Count > 0) + { + string message = + targets.Count == 1 + ? $"GameObject '{((dynamic)deletedObjects[0]).name}' deleted successfully." + : $"{deletedObjects.Count} GameObjects deleted successfully."; + return new SuccessResponse(message, deletedObjects); + } + + return new ErrorResponse("Failed to delete target GameObject(s)."); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta new file mode 100644 index 0000000..0a53914 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 505a482aaf60b415abd794737a630b10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs new file mode 100644 index 0000000..6faeb2a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs @@ -0,0 +1,86 @@ +#nullable disable +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectDuplicate + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + GameObject sourceGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod); + if (sourceGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string newName = @params["new_name"]?.ToString(); + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? offset = VectorParsing.ParseVector3(@params["offset"]); + JToken parentToken = @params["parent"]; + + GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo); + Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}"); + + if (!string.IsNullOrEmpty(newName)) + { + duplicatedGo.name = newName; + } + else + { + duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy"; + } + + if (position.HasValue) + { + duplicatedGo.transform.position = position.Value; + } + else if (offset.HasValue) + { + duplicatedGo.transform.position = sourceGo.transform.position + offset.Value; + } + + if (parentToken != null) + { + if (parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) + { + duplicatedGo.transform.SetParent(null); + } + else + { + GameObject newParent = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if (newParent != null) + { + duplicatedGo.transform.SetParent(newParent.transform, true); + } + else + { + McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Object will remain at root level."); + } + } + } + else + { + duplicatedGo.transform.SetParent(sourceGo.transform.parent, true); + } + + EditorUtility.SetDirty(duplicatedGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + Selection.activeGameObject = duplicatedGo; + + return new SuccessResponse( + $"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.", + new + { + originalName = sourceGo.name, + originalId = sourceGo.GetInstanceID(), + duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo) + } + ); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta new file mode 100644 index 0000000..2c2a755 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 698728d56425a47af92a45377031a48b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs new file mode 100644 index 0000000..bca3687 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs @@ -0,0 +1,22 @@ +#nullable disable +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectHandlers + { + internal static object Create(JObject @params) => GameObjectCreate.Handle(@params); + + internal static object Modify(JObject @params, JToken targetToken, string searchMethod) + => GameObjectModify.Handle(@params, targetToken, searchMethod); + + internal static object Delete(JToken targetToken, string searchMethod) + => GameObjectDelete.Handle(targetToken, searchMethod); + + internal static object Duplicate(JObject @params, JToken targetToken, string searchMethod) + => GameObjectDuplicate.Handle(@params, targetToken, searchMethod); + + internal static object MoveRelative(JObject @params, JToken targetToken, string searchMethod) + => GameObjectMoveRelative.Handle(@params, targetToken, searchMethod); + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta new file mode 100644 index 0000000..0538a15 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: f3cf2313460d44a09b258d2ee04c5ef0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs new file mode 100644 index 0000000..44511e9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -0,0 +1,297 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectModify + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + // When setActive=true is specified, we need to search for inactive objects + // otherwise we can't find an inactive object to activate it + JObject findParams = null; + if (@params["setActive"]?.ToObject() == true) + { + findParams = new JObject { ["searchInactive"] = true }; + } + + GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod, findParams); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); + Undo.RecordObject(targetGo, "Modify GameObject Properties"); + + bool modified = false; + + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) + { + // Check if we're renaming the root object of an open prefab stage + var prefabStageForRename = PrefabStageUtility.GetCurrentPrefabStage(); + bool isRenamingPrefabRoot = prefabStageForRename != null && + prefabStageForRename.prefabContentsRoot == targetGo; + + if (isRenamingPrefabRoot) + { + // Rename the prefab asset file to match the new name (avoids Unity dialog) + string assetPath = prefabStageForRename.assetPath; + string directory = System.IO.Path.GetDirectoryName(assetPath); + string newAssetPath = AssetPathUtility.NormalizeSeparators(System.IO.Path.Combine(directory, name + ".prefab")); + + // Only rename if the path actually changes + if (newAssetPath != assetPath) + { + // Check for collision using GUID comparison + string currentGuid = AssetDatabase.AssetPathToGUID(assetPath); + string existingGuid = AssetDatabase.AssetPathToGUID(newAssetPath); + + // Collision only if there's a different asset at the new path + if (!string.IsNullOrEmpty(existingGuid) && existingGuid != currentGuid) + { + return new ErrorResponse($"Cannot rename prefab root to '{name}': a prefab already exists at '{newAssetPath}'."); + } + + // Rename the asset file + string renameError = AssetDatabase.RenameAsset(assetPath, name); + if (!string.IsNullOrEmpty(renameError)) + { + return new ErrorResponse($"Failed to rename prefab asset: {renameError}"); + } + + McpLog.Info($"[GameObjectModify] Renamed prefab asset from '{assetPath}' to '{newAssetPath}'"); + } + } + + targetGo.name = name; + modified = true; + } + + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject newParentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if ( + newParentGo == null + && !(parentToken.Type == JTokenType.Null + || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) + ) + { + return new ErrorResponse($"New parent ('{parentToken}') not found."); + } + if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) + { + return new ErrorResponse($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop."); + } + if (targetGo.transform.parent != (newParentGo?.transform)) + { + targetGo.transform.SetParent(newParentGo?.transform, true); + modified = true; + } + } + + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + string tag = @params["tag"]?.ToString(); + if (tag != null && targetGo.tag != tag) + { + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + + if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet)) + { + McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it."); + try + { + InternalEditorUtility.AddTag(tagToSet); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}."); + } + } + + try + { + targetGo.tag = tagToSet; + modified = true; + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}."); + } + } + + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1) + { + return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name."); + } + if (layerId != -1 && targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + if (@params["componentsToRemove"] is JArray componentsToRemoveArray) + { + foreach (var compToken in componentsToRemoveArray) + { + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + var removeResult = GameObjectComponentHelpers.RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; + modified = true; + } + } + } + + if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) + { + foreach (var compToken in componentsToAddArrayModify) + { + string typeName = null; + JObject properties = null; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = GameObjectComponentHelpers.AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; + modified = true; + } + } + } + + var componentErrors = new List(); + if (@params["componentProperties"] is JObject componentPropertiesObj) + { + foreach (var prop in componentPropertiesObj.Properties()) + { + string compName = prop.Name; + JObject propertiesToSet = prop.Value as JObject; + if (propertiesToSet != null) + { + var setResult = GameObjectComponentHelpers.SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); + if (setResult != null) + { + componentErrors.Add(setResult); + } + else + { + modified = true; + } + } + } + } + + if (componentErrors.Count > 0) + { + var aggregatedErrors = new List(); + foreach (var errorObj in componentErrors) + { + try + { + var dataProp = errorObj?.GetType().GetProperty("data"); + var dataVal = dataProp?.GetValue(errorObj); + if (dataVal != null) + { + var errorsProp = dataVal.GetType().GetProperty("errors"); + var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; + if (errorsEnum != null) + { + foreach (var item in errorsEnum) + { + var s = item?.ToString(); + if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); + } + } + } + } + catch (Exception ex) + { + McpLog.Warn($"[GameObjectModify] Error aggregating component errors: {ex.Message}"); + } + } + + return new ErrorResponse( + $"One or more component property operations failed on '{targetGo.name}'.", + new { componentErrors = componentErrors, errors = aggregatedErrors } + ); + } + + if (!modified) + { + return new SuccessResponse( + $"No modifications applied to GameObject '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + EditorUtility.SetDirty(targetGo); + + // Mark the appropriate scene as dirty (handles both regular scenes and prefab stages) + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + { + EditorSceneManager.MarkSceneDirty(prefabStage.scene); + } + else + { + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + } + + return new SuccessResponse( + $"GameObject '{targetGo.name}' modified successfully.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta new file mode 100644 index 0000000..2d7f4f9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: ec5e33513bd094257a26ef6f75ea4574 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs new file mode 100644 index 0000000..3335d28 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs @@ -0,0 +1,119 @@ +#nullable disable +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectMoveRelative + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + JToken referenceToken = @params["reference_object"]; + if (referenceToken == null) + { + return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action."); + } + + GameObject referenceGo = ManageGameObjectCommon.FindObjectInternal(referenceToken, "by_id_or_name_or_path"); + if (referenceGo == null) + { + return new ErrorResponse($"Reference object '{referenceToken}' not found."); + } + + string direction = @params["direction"]?.ToString()?.ToLower(); + float distance = @params["distance"]?.ToObject() ?? 1f; + Vector3? customOffset = VectorParsing.ParseVector3(@params["offset"]); + bool useWorldSpace = @params["world_space"]?.ToObject() ?? true; + + Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}"); + + Vector3 newPosition; + + if (customOffset.HasValue) + { + if (useWorldSpace) + { + newPosition = referenceGo.transform.position + customOffset.Value; + } + else + { + newPosition = referenceGo.transform.TransformPoint(customOffset.Value); + } + } + else if (!string.IsNullOrEmpty(direction)) + { + Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace); + newPosition = referenceGo.transform.position + directionVector * distance; + } + else + { + return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action."); + } + + targetGo.transform.position = newPosition; + + EditorUtility.SetDirty(targetGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + return new SuccessResponse( + $"Moved '{targetGo.name}' relative to '{referenceGo.name}'.", + new + { + movedObject = targetGo.name, + referenceObject = referenceGo.name, + newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z }, + direction = direction, + distance = distance, + gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + } + ); + } + + private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace) + { + if (useWorldSpace) + { + switch (direction) + { + case "right": return Vector3.right; + case "left": return Vector3.left; + case "up": return Vector3.up; + case "down": return Vector3.down; + case "forward": + case "front": return Vector3.forward; + case "back": + case "backward": + case "behind": return Vector3.back; + default: + McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return Vector3.forward; + } + } + + switch (direction) + { + case "right": return referenceTransform.right; + case "left": return -referenceTransform.right; + case "up": return referenceTransform.up; + case "down": return -referenceTransform.up; + case "forward": + case "front": return referenceTransform.forward; + case "back": + case "backward": + case "behind": return -referenceTransform.forward; + default: + McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return referenceTransform.forward; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta new file mode 100644 index 0000000..11f936c --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8b19997a165de45c2af3ada79a6d3f08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs new file mode 100644 index 0000000..6900234 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs @@ -0,0 +1,115 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; // For Response class +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + /// + /// Handles GameObject manipulation within the current scene (CRUD, find, components). + /// + [McpForUnityTool("manage_gameobject", AutoRegister = false)] + public static class ManageGameObject + { + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + // Parameters used by various actions + JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) + string name = @params["name"]?.ToString(); + + // --- Usability Improvement: Alias 'name' to 'target' for modification actions --- + // If 'target' is missing but 'name' is provided, and we aren't creating a new object, + // assume the user meant "find object by name". + if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create") + { + targetToken = name; + // We don't update @params["target"] because we use targetToken locally mostly, + // but some downstream methods might parse @params directly. Let's update @params too for safety. + @params["target"] = name; + } + // ------------------------------------------------------------------------------- + + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); + string tag = @params["tag"]?.ToString(); + string layer = @params["layer"]?.ToString(); + JToken parentToken = @params["parent"]; + + // Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string + var componentPropsToken = @params["componentProperties"]; + if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String) + { + try + { + var parsed = JObject.Parse(componentPropsToken.ToString()); + @params["componentProperties"] = parsed; + } + catch (Exception e) + { + McpLog.Warn($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}"); + } + } + + // --- Prefab Asset Check --- + // Prefab assets require different tools. Only 'create' (instantiation) is valid here. + string targetPath = + targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; + if ( + !string.IsNullOrEmpty(targetPath) + && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + && action != "create" // Allow prefab instantiation + ) + { + return new ErrorResponse( + $"Target '{targetPath}' is a prefab asset. " + + $"Use 'manage_asset' with action='modify' for prefab asset modifications, " + + $"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode." + ); + } + // --- End Prefab Asset Check --- + + try + { + switch (action) + { + // --- Primary lifecycle actions (kept in manage_gameobject) --- + case "create": + return GameObjectCreate.Handle(@params); + case "modify": + return GameObjectModify.Handle(@params, targetToken, searchMethod); + case "delete": + return GameObjectDelete.Handle(targetToken, searchMethod); + case "duplicate": + return GameObjectDuplicate.Handle(@params, targetToken, searchMethod); + case "move_relative": + return GameObjectMoveRelative.Handle(@params, targetToken, searchMethod); + + default: + return new ErrorResponse($"Unknown action: '{action}'."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageGameObject] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta new file mode 100644 index 0000000..eaab062 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7641d7388f0f6634b9d83d34de87b2ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs new file mode 100644 index 0000000..8d8a36e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs @@ -0,0 +1,238 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class ManageGameObjectCommon + { + internal static GameObject FindObjectInternal(JToken targetToken, string searchMethod, JObject findParams = null) + { + bool findAll = findParams?["findAll"]?.ToObject() ?? false; + + if ( + targetToken?.Type == JTokenType.Integer + || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) + ) + { + findAll = false; + } + + List results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams); + return results.Count > 0 ? results[0] : null; + } + + internal static List FindObjectsInternal( + JToken targetToken, + string searchMethod, + bool findAll, + JObject findParams = null + ) + { + List results = new List(); + string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); + bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; + bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; + + if (string.IsNullOrEmpty(searchMethod)) + { + if (targetToken?.Type == JTokenType.Integer) + searchMethod = "by_id"; + else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) + searchMethod = "by_path"; + else + searchMethod = "by_name"; + } + + GameObject rootSearchObject = null; + if (searchInChildren && targetToken != null) + { + rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); + if (rootSearchObject == null) + { + McpLog.Warn($"[ManageGameObject.Find] Root object '{targetToken}' for child search not found."); + return results; + } + } + + switch (searchMethod) + { + case "by_id": + if (int.TryParse(searchTerm, out int instanceId)) + { + var allObjects = GetAllSceneObjects(searchInactive); + GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceID() == instanceId); + if (obj != null) + results.Add(obj); + } + break; + + case "by_name": + var searchPoolName = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); + break; + + case "by_path": + if (rootSearchObject != null) + { + Transform foundTransform = rootSearchObject.transform.Find(searchTerm); + if (foundTransform != null) + results.Add(foundTransform.gameObject); + } + else + { + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null || searchInactive) + { + // In Prefab Stage, GameObject.Find() doesn't work, need to search manually + var allObjects = GetAllSceneObjects(searchInactive); + foreach (var go in allObjects) + { + if (GameObjectLookup.MatchesPath(go, searchTerm)) + { + results.Add(go); + } + } + } + else + { + var found = GameObject.Find(searchTerm); + if (found != null) + results.Add(found); + } + } + break; + + case "by_tag": + var searchPoolTag = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); + break; + + case "by_layer": + var searchPoolLayer = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + if (int.TryParse(searchTerm, out int layerIndex)) + { + results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); + } + else + { + int namedLayer = LayerMask.NameToLayer(searchTerm); + if (namedLayer != -1) + results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); + } + break; + + case "by_component": + Type componentType = FindType(searchTerm); + if (componentType != null) + { + IEnumerable searchPoolComp; + if (rootSearchObject) + { + searchPoolComp = rootSearchObject + .GetComponentsInChildren(componentType, searchInactive) + .Select(c => (c as Component).gameObject); + } + else + { +#if UNITY_2023_1_OR_NEWER + var inactive = searchInactive ? FindObjectsInactive.Include : FindObjectsInactive.Exclude; + searchPoolComp = UnityEngine.Object.FindObjectsByType(componentType, inactive, FindObjectsSortMode.None) + .Cast() + .Select(c => c.gameObject); +#else + searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive) + .Cast() + .Select(c => c.gameObject); +#endif + } + results.AddRange(searchPoolComp.Where(go => go != null)); + } + else + { + McpLog.Warn($"[ManageGameObject.Find] Component type not found: {searchTerm}"); + } + break; + + case "by_id_or_name_or_path": + if (int.TryParse(searchTerm, out int id)) + { + var allObjectsId = GetAllSceneObjects(true); + GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceID() == id); + if (objById != null) + { + results.Add(objById); + break; + } + } + + // Try path search - in Prefab Stage, GameObject.Find() doesn't work + var allObjectsForPath = GetAllSceneObjects(true); + GameObject objByPath = allObjectsForPath.FirstOrDefault(go => + { + return GameObjectLookup.MatchesPath(go, searchTerm); + }); + if (objByPath != null) + { + results.Add(objByPath); + break; + } + + var allObjectsName = GetAllSceneObjects(true); + results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); + break; + + default: + McpLog.Warn($"[ManageGameObject.Find] Unknown search method: {searchMethod}"); + break; + } + + if (!findAll && results.Count > 1) + { + return new List { results[0] }; + } + + return results.Distinct().ToList(); + } + + private static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // Delegate to GameObjectLookup to avoid code duplication and ensure consistent behavior + return GameObjectLookup.GetAllSceneObjects(includeInactive); + } + + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + return resolvedType; + } + + if (!string.IsNullOrEmpty(error)) + { + McpLog.Warn($"[FindType] {error}"); + } + + return null; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta new file mode 100644 index 0000000..fb5ab14 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6bf0edf3cd2af46729294682cee3bee4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs b/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs new file mode 100644 index 0000000..30c4fbf --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs @@ -0,0 +1,35 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Poll a previously started async test job by job_id. + /// + [McpForUnityTool("get_test_job", AutoRegister = false)] + public static class GetTestJob + { + public static object HandleCommand(JObject @params) + { + string jobId = @params?["job_id"]?.ToString() ?? @params?["jobId"]?.ToString(); + if (string.IsNullOrWhiteSpace(jobId)) + { + return new ErrorResponse("Missing required parameter 'job_id'."); + } + + bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false); + bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false); + + var job = TestJobManager.GetJob(jobId); + if (job == null) + { + return new ErrorResponse("Unknown job_id."); + } + + var payload = TestJobManager.ToSerializable(job, includeDetails, includeFailedTests); + return new SuccessResponse("Test job status retrieved.", payload); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs.meta b/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs.meta new file mode 100644 index 0000000..acc50c2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/GetTestJob.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7f92c2b67a2c4b5c9d1a3c0e6f9b2d10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/GetTestJob.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs b/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs new file mode 100644 index 0000000..74b745d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs @@ -0,0 +1,31 @@ +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + internal static class JsonUtil + { + /// + /// If @params[paramName] is a JSON string, parse it to a JObject in-place. + /// Logs a warning on parse failure and leaves the original value. + /// + internal static void CoerceJsonStringParameter(JObject @params, string paramName) + { + if (@params == null || string.IsNullOrEmpty(paramName)) return; + var token = @params[paramName]; + if (token != null && token.Type == JTokenType.String) + { + try + { + var parsed = JObject.Parse(token.ToString()); + @params[paramName] = parsed; + } + catch (Newtonsoft.Json.JsonReaderException e) + { + McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}"); + } + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs.meta b/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs.meta new file mode 100644 index 0000000..145cda8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/JsonUtil.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: d4b3b6009d53e4b8f97fe7ab57888c65 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/JsonUtil.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs b/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs new file mode 100644 index 0000000..ed6e6c9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -0,0 +1,1120 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; // For Response class +using MCPForUnity.Editor.Tools; + +#if UNITY_6000_0_OR_NEWER +using PhysicsMaterialType = UnityEngine.PhysicsMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; +#else +using PhysicsMaterialType = UnityEngine.PhysicMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; +#endif + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles asset management operations within the Unity project. + /// + [McpForUnityTool("manage_asset", AutoRegister = false)] + public static class ManageAsset + { + // --- Main Handler --- + + // Define the list of valid actions + private static readonly List ValidActions = new List + { + "import", + "create", + "modify", + "delete", + "duplicate", + "move", + "rename", + "search", + "get_info", + "create_folder", + "get_components", + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + // Check if the action is valid before switching + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + // Common parameters + string path = @params["path"]?.ToString(); + + // Coerce string JSON to JObject for 'properties' if provided as a JSON string + var propertiesToken = @params["properties"]; + if (propertiesToken != null && propertiesToken.Type == JTokenType.String) + { + try + { + var parsed = JObject.Parse(propertiesToken.ToString()); + @params["properties"] = parsed; + } + catch (Exception e) + { + McpLog.Warn($"[ManageAsset] Could not parse 'properties' JSON string: {e.Message}"); + } + } + + try + { + switch (action) + { + case "import": + // Note: Unity typically auto-imports. This might re-import or configure import settings. + return ReimportAsset(path, @params["properties"] as JObject); + case "create": + return CreateAsset(@params); + case "modify": + var properties = @params["properties"] as JObject; + return ModifyAsset(path, properties); + case "delete": + return DeleteAsset(path); + case "duplicate": + return DuplicateAsset(path, @params["destination"]?.ToString()); + case "move": // Often same as rename if within Assets/ + case "rename": + return MoveOrRenameAsset(path, @params["destination"]?.ToString()); + case "search": + return SearchAssets(@params); + case "get_info": + return GetAssetInfo( + path, + @params["generatePreview"]?.ToObject() ?? false + ); + case "create_folder": // Added specific action for clarity + return CreateFolder(path); + case "get_components": + return GetComponentsFromAsset(path); + + default: + // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. + string validActionsListDefault = string.Join(", ", ValidActions); + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" + ); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); + return new ErrorResponse( + $"Internal error processing action '{action}' on '{path}': {e.Message}" + ); + } + } + + // --- Action Implementations --- + + private static object ReimportAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for reimport."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Asset not found at path: {fullPath}"); + + try + { + // TODO: Apply importer properties before reimporting? + // This is complex as it requires getting the AssetImporter, casting it, + // applying properties via reflection or specific methods, saving, then reimporting. + if (properties != null && properties.HasValues) + { + McpLog.Warn( + "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." + ); + // AssetImporter importer = AssetImporter.GetAtPath(fullPath); + // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } + } + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh + return new SuccessResponse($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to reimport asset '{fullPath}': {e.Message}"); + } + } + + private static object CreateAsset(JObject @params) + { + string path = @params["path"]?.ToString(); + string assetType = + @params["assetType"]?.ToString() + ?? @params["asset_type"]?.ToString(); // tolerate snake_case payloads from batched commands + JObject properties = @params["properties"] as JObject; + + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for create."); + if (string.IsNullOrEmpty(assetType)) + return new ErrorResponse("'assetType' is required for create."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + string directory = Path.GetDirectoryName(fullPath); + + // Ensure directory exists + if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) + { + Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Make sure Unity knows about the new folder + } + + if (AssetExists(fullPath)) + return new ErrorResponse($"Asset already exists at path: {fullPath}"); + + try + { + UnityEngine.Object newAsset = null; + string lowerAssetType = assetType.ToLowerInvariant(); + + // Handle common asset types + if (lowerAssetType == "folder") + { + return CreateFolder(path); // Use dedicated method + } + else if (lowerAssetType == "material") + { + var requested = properties?["shader"]?.ToString(); + Shader shader = RenderPipelineUtility.ResolveShader(requested); + if (shader == null) + return new ErrorResponse($"Could not find a project-compatible shader (requested: '{requested ?? "none"}'). Consider installing URP/HDRP or provide an explicit shader path."); + + var mat = new Material(shader); + if (properties != null) + { + JObject propertiesForApply = properties; + if (propertiesForApply["shader"] != null) + { + propertiesForApply = (JObject)properties.DeepClone(); + propertiesForApply.Remove("shader"); + } + + if (propertiesForApply.HasValues) + { + MaterialOps.ApplyProperties(mat, propertiesForApply, UnityJsonSerializer.Instance); + } + } + AssetDatabase.CreateAsset(mat, fullPath); + newAsset = mat; + } + else if (lowerAssetType == "physicsmaterial") + { + PhysicsMaterialType pmat = new PhysicsMaterialType(); + if (properties != null) + ApplyPhysicsMaterialProperties(pmat, properties); + AssetDatabase.CreateAsset(pmat, fullPath); + newAsset = pmat; + } + else if (lowerAssetType == "prefab") + { + // Creating prefabs usually involves saving an existing GameObject hierarchy. + // A common pattern is to create an empty GameObject, configure it, and then save it. + return new ErrorResponse( + "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." + ); + // Example (conceptual): + // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); + // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); + } + // TODO: Add more asset types (Animation Controller, Scene, etc.) + else + { + // Generic creation attempt (might fail or create empty files) + // For some types, just creating the file might be enough if Unity imports it. + // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); + // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it + // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); + return new ErrorResponse( + $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, PhysicsMaterial." + ); + } + + if ( + newAsset == null + && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) + ) // Check if it wasn't a folder and asset wasn't created + { + return new ErrorResponse( + $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." + ); + } + + AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); // CreateAsset often handles refresh + return new SuccessResponse( + $"Asset '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create asset at '{fullPath}': {e.Message}"); + } + } + + private static object CreateFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for create_folder."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + string parentDir = Path.GetDirectoryName(fullPath); + string folderName = Path.GetFileName(fullPath); + + if (AssetExists(fullPath)) + { + // Check if it's actually a folder already + if (AssetDatabase.IsValidFolder(fullPath)) + { + return new SuccessResponse( + $"Folder already exists at path: {fullPath}", + GetAssetData(fullPath) + ); + } + else + { + return new ErrorResponse( + $"An asset (not a folder) already exists at path: {fullPath}" + ); + } + } + + try + { + // Ensure parent exists + if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) + { + // Recursively create parent folders if needed (AssetDatabase handles this internally) + // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); + } + + string guid = AssetDatabase.CreateFolder(parentDir, folderName); + if (string.IsNullOrEmpty(guid)) + { + return new ErrorResponse( + $"Failed to create folder '{fullPath}'. Check logs and permissions." + ); + } + + // AssetDatabase.Refresh(); // CreateFolder usually handles refresh + return new SuccessResponse( + $"Folder '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create folder '{fullPath}': {e.Message}"); + } + } + + private static object ModifyAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for modify."); + if (properties == null || !properties.HasValues) + return new ErrorResponse("'properties' are required for modify."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Asset not found at path: {fullPath}"); + + try + { + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); + + bool modified = false; // Flag to track if any changes were made + + // --- NEW: Handle GameObject / Prefab Component Modification --- + if (asset is GameObject gameObject) + { + // Iterate through the properties JSON: keys are component names, values are properties objects for that component + foreach (var prop in properties.Properties()) + { + string componentName = prop.Name; // e.g., "Collectible" + // Check if the value associated with the component name is actually an object containing properties + if ( + prop.Value is JObject componentProperties + && componentProperties.HasValues + ) // e.g., {"bobSpeed": 2.0} + { + // Resolve component type via ComponentResolver, then fetch by Type + Component targetComponent = null; + bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); + if (resolved) + { + targetComponent = gameObject.GetComponent(compType); + } + + // Only warn about resolution failure if component also not found + if (targetComponent == null && !resolved) + { + McpLog.Warn( + $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" + ); + } + + if (targetComponent != null) + { + // Apply the nested properties (e.g., bobSpeed) to the found component instance + // Use |= to ensure 'modified' becomes true if any component is successfully modified + modified |= ApplyObjectProperties( + targetComponent, + componentProperties + ); + } + else + { + // Log a warning if a specified component couldn't be found + McpLog.Warn( + $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." + ); + } + } + else + { + // Log a warning if the structure isn't {"ComponentName": {"prop": value}} + // We could potentially try to apply this property directly to the GameObject here if needed, + // but the primary goal is component modification. + McpLog.Warn( + $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." + ); + } + } + // Note: 'modified' is now true if ANY component property was successfully changed. + } + // --- End NEW --- + + // --- Existing logic for other asset types (now as else-if) --- + // Example: Modifying a Material + else if (asset is Material material) + { + // Apply properties directly to the material. If this modifies, it sets modified=true. + // Use |= in case the asset was already marked modified by previous logic (though unlikely here) + modified |= MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance); + } + // Example: Modifying a ScriptableObject (Use manage_scriptable_object instead!) + else if (asset is ScriptableObject so) + { + // Deprecated: Prefer manage_scriptable_object for robust patching. + // Kept for simple property setting fallback on existing assets if manage_scriptable_object isn't used. + modified |= ApplyObjectProperties(so, properties); + } + // Example: Modifying TextureImporter settings + else if (asset is Texture) + { + AssetImporter importer = AssetImporter.GetAtPath(fullPath); + if (importer is TextureImporter textureImporter) + { + bool importerModified = ApplyObjectProperties(textureImporter, properties); + if (importerModified) + { + // Importer settings need saving and reimporting + AssetDatabase.WriteImportSettingsIfDirty(fullPath); + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes + modified = true; // Mark overall operation as modified + } + } + else + { + McpLog.Warn($"Could not get TextureImporter for {fullPath}."); + } + } + // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) + else // Fallback for other asset types OR direct properties on non-GameObject assets + { + // This block handles non-GameObject/Material/ScriptableObject/Texture assets. + // Attempts to apply properties directly to the asset itself. + McpLog.Warn( + $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." + ); + modified |= ApplyObjectProperties(asset, properties); + } + // --- End Existing Logic --- + + // Check if any modification happened (either component or direct asset modification) + if (modified) + { + // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. + EditorUtility.SetDirty(asset); + // Save all modified assets to disk. + AssetDatabase.SaveAssets(); + // Refresh might be needed in some edge cases, but SaveAssets usually covers it. + // AssetDatabase.Refresh(); + return new SuccessResponse( + $"Asset '{fullPath}' modified successfully.", + GetAssetData(fullPath) + ); + } + else + { + // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. + return new SuccessResponse( + $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", + GetAssetData(fullPath) + ); + // Previous message: return new SuccessResponse($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + } + } + catch (Exception e) + { + // Log the detailed error internally + McpLog.Error($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); + // Return a user-friendly error message + return new ErrorResponse($"Failed to modify asset '{fullPath}': {e.Message}"); + } + } + + private static object DeleteAsset(string path) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for delete."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Asset not found at path: {fullPath}"); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + { + // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh + return new SuccessResponse($"Asset '{fullPath}' deleted successfully."); + } + else + { + // This might happen if the file couldn't be deleted (e.g., locked) + return new ErrorResponse( + $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." + ); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error deleting asset '{fullPath}': {e.Message}"); + } + } + + private static object DuplicateAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for duplicate."); + + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(sourcePath)) + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); + + string destPath; + if (string.IsNullOrEmpty(destinationPath)) + { + // Generate a unique path if destination is not provided + destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); + } + else + { + destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); + if (AssetExists(destPath)) + return new ErrorResponse($"Asset already exists at destination path: {destPath}"); + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + } + + try + { + bool success = AssetDatabase.CopyAsset(sourcePath, destPath); + if (success) + { + // AssetDatabase.Refresh(); + return new SuccessResponse( + $"Asset '{sourcePath}' duplicated to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + return new ErrorResponse( + $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error duplicating asset '{sourcePath}': {e.Message}"); + } + } + + private static object MoveOrRenameAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for move/rename."); + if (string.IsNullOrEmpty(destinationPath)) + return new ErrorResponse("'destination' path is required for move/rename."); + + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); + + if (!AssetExists(sourcePath)) + return new ErrorResponse($"Source asset not found at path: {sourcePath}"); + if (AssetExists(destPath)) + return new ErrorResponse( + $"An asset already exists at the destination path: {destPath}" + ); + + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + + try + { + // Validate will return an error string if failed, null if successful + string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(error)) + { + return new ErrorResponse( + $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" + ); + } + + string guid = AssetDatabase.MoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success + { + // AssetDatabase.Refresh(); // MoveAsset usually handles refresh + return new SuccessResponse( + $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + // This case might not be reachable if ValidateMoveAsset passes, but good to have + return new ErrorResponse( + $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + } + } + + private static object SearchAssets(JObject @params) + { + string searchPattern = @params["searchPattern"]?.ToString(); + string filterType = @params["filterType"]?.ToString(); + string pathScope = @params["path"]?.ToString(); // Use path as folder scope + string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); + int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size + int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) + bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; + + List searchFilters = new List(); + if (!string.IsNullOrEmpty(searchPattern)) + searchFilters.Add(searchPattern); + if (!string.IsNullOrEmpty(filterType)) + searchFilters.Add($"t:{filterType}"); + + string[] folderScope = null; + if (!string.IsNullOrEmpty(pathScope)) + { + folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; + if (!AssetDatabase.IsValidFolder(folderScope[0])) + { + // Maybe the user provided a file path instead of a folder? + // We could search in the containing folder, or return an error. + McpLog.Warn( + $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." + ); + folderScope = null; // Search everywhere if path isn't a folder + } + } + + DateTime? filterDateAfter = null; + if (!string.IsNullOrEmpty(filterDateAfterStr)) + { + if ( + DateTime.TryParse( + filterDateAfterStr, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate + ) + ) + { + filterDateAfter = parsedDate; + } + else + { + McpLog.Warn( + $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." + ); + } + } + + try + { + string[] guids = AssetDatabase.FindAssets( + string.Join(" ", searchFilters), + folderScope + ); + List results = new List(); + int totalFound = 0; + + foreach (string guid in guids) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(assetPath)) + continue; + + // Apply date filter if present + if (filterDateAfter.HasValue) + { + DateTime lastWriteTime = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), assetPath) + ); + if (lastWriteTime <= filterDateAfter.Value) + { + continue; // Skip assets older than or equal to the filter date + } + } + + totalFound++; // Count matching assets before pagination + results.Add(GetAssetData(assetPath, generatePreview)); + } + + // Apply pagination + int startIndex = (pageNumber - 1) * pageSize; + var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); + + return new SuccessResponse( + $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", + new + { + totalAssets = totalFound, + pageSize = pageSize, + pageNumber = pageNumber, + assets = pagedResults, + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Error searching assets: {e.Message}"); + } + } + + private static object GetAssetInfo(string path, bool generatePreview) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for get_info."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Asset not found at path: {fullPath}"); + + try + { + return new SuccessResponse( + "Asset info retrieved.", + GetAssetData(fullPath, generatePreview) + ); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting info for asset '{fullPath}': {e.Message}"); + } + } + + /// + /// Retrieves components attached to a GameObject asset (like a Prefab). + /// + /// The asset path of the GameObject or Prefab. + /// A response object containing a list of component type names or an error. + private static object GetComponentsFromAsset(string path) + { + // 1. Validate input path + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for get_components."); + + // 2. Sanitize and check existence + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Asset not found at path: {fullPath}"); + + try + { + // 3. Load the asset + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return new ErrorResponse($"Failed to load asset at path: {fullPath}"); + + // 4. Check if it's a GameObject (Prefabs load as GameObjects) + GameObject gameObject = asset as GameObject; + if (gameObject == null) + { + // Also check if it's *directly* a Component type (less common for primary assets) + Component componentAsset = asset as Component; + if (componentAsset != null) + { + // If the asset itself *is* a component, maybe return just its info? + // This is an edge case. Let's stick to GameObjects for now. + return new ErrorResponse( + $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." + ); + } + return new ErrorResponse( + $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." + ); + } + + // 5. Get components + Component[] components = gameObject.GetComponents(); + + // 6. Format component data + List componentList = components + .Select(comp => new + { + typeName = comp.GetType().FullName, + instanceID = comp.GetInstanceID(), + // TODO: Add more component-specific details here if needed in the future? + // Requires reflection or specific handling per component type. + }) + .ToList(); // Explicit cast for clarity if needed + + // 7. Return success response + return new SuccessResponse( + $"Found {componentList.Count} component(s) on asset '{fullPath}'.", + componentList + ); + } + catch (Exception e) + { + McpLog.Error( + $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" + ); + return new ErrorResponse( + $"Error getting components for asset '{fullPath}': {e.Message}" + ); + } + } + + // --- Internal Helpers --- + + /// + /// Ensures the asset path starts with "Assets/". + /// + /// + /// Checks if an asset exists at the given path (file or folder). + /// + private static bool AssetExists(string sanitizedPath) + { + // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. + // Check if it's a known asset GUID. + if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) + { + return true; + } + // AssetPathToGUID might not work for newly created folders not yet refreshed. + // Check directory explicitly for folders. + if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + // Check if it's considered a *valid* folder by Unity + return AssetDatabase.IsValidFolder(sanitizedPath); + } + // Check file existence for non-folder assets. + if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + return true; // Assume if file exists, it's an asset or will be imported + } + + return false; + // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); + } + + /// + /// Ensures the directory for a given asset path exists, creating it if necessary. + /// + private static void EnsureDirectoryExists(string directoryPath) + { + if (string.IsNullOrEmpty(directoryPath)) + return; + string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); + if (!Directory.Exists(fullDirPath)) + { + Directory.CreateDirectory(fullDirPath); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Let Unity know about the new folder + } + } + + + + /// + /// Applies properties from JObject to a PhysicsMaterial. + /// + private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) + { + if (pmat == null || properties == null) + return false; + bool modified = false; + + // Example: Set dynamic friction + if (properties["dynamicFriction"]?.Type == JTokenType.Float) + { + float dynamicFriction = properties["dynamicFriction"].ToObject(); + pmat.dynamicFriction = dynamicFriction; + modified = true; + } + + // Example: Set static friction + if (properties["staticFriction"]?.Type == JTokenType.Float) + { + float staticFriction = properties["staticFriction"].ToObject(); + pmat.staticFriction = staticFriction; + modified = true; + } + + // Example: Set bounciness + if (properties["bounciness"]?.Type == JTokenType.Float) + { + float bounciness = properties["bounciness"].ToObject(); + pmat.bounciness = bounciness; + modified = true; + } + + List averageList = new List { "ave", "Ave", "average", "Average" }; + List multiplyList = new List { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; + List minimumList = new List { "min", "Min", "minimum", "Minimum" }; + List maximumList = new List { "max", "Max", "maximum", "Maximum" }; + + // Example: Set friction combine + if (properties["frictionCombine"]?.Type == JTokenType.String) + { + string frictionCombine = properties["frictionCombine"].ToString(); + if (averageList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + // Example: Set bounce combine + if (properties["bounceCombine"]?.Type == JTokenType.String) + { + string bounceCombine = properties["bounceCombine"].ToString(); + if (averageList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + return modified; + } + + /// + /// Generic helper to set properties on any UnityEngine.Object using reflection. + /// + private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) + { + if (target == null || properties == null) + return false; + bool modified = false; + Type type = target.GetType(); + + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + if (SetPropertyOrField(target, propName, propValue, type)) + { + modified = true; + } + } + return modified; + } + + /// + /// Helper to set a property or field via reflection, handling basic types and Unity objects. + /// + private static bool SetPropertyOrField( + object target, + string memberName, + JToken value, + Type type = null + ) + { + type = type ?? target.GetType(); + System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.IgnoreCase; + + try + { + System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, propInfo.PropertyType); + if ( + convertedValue != null + && !object.Equals(propInfo.GetValue(target), convertedValue) + ) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) + { + object convertedValue = Helpers.PropertyConversion.TryConvertToType(value, fieldInfo.FieldType); + if ( + convertedValue != null + && !object.Equals(fieldInfo.GetValue(target), convertedValue) + ) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + McpLog.Warn( + $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" + ); + } + return false; + } + + // --- Data Serialization --- + + /// + /// Creates a serializable representation of an asset. + /// + private static object GetAssetData(string path, bool generatePreview = false) + { + if (string.IsNullOrEmpty(path) || !AssetExists(path)) + return null; + + string guid = AssetDatabase.AssetPathToGUID(path); + Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); + string previewBase64 = null; + int previewWidth = 0; + int previewHeight = 0; + + if (generatePreview && asset != null) + { + Texture2D preview = AssetPreview.GetAssetPreview(asset); + + if (preview != null) + { + try + { + // Ensure texture is readable for EncodeToPNG + // Creating a temporary readable copy is safer + RenderTexture rt = null; + Texture2D readablePreview = null; + RenderTexture previous = RenderTexture.active; + try + { + rt = RenderTexture.GetTemporary(preview.width, preview.height); + Graphics.Blit(preview, rt); + RenderTexture.active = rt; + readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); + readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readablePreview.Apply(); + + var pngData = readablePreview.EncodeToPNG(); + if (pngData != null && pngData.Length > 0) + { + previewBase64 = Convert.ToBase64String(pngData); + previewWidth = readablePreview.width; + previewHeight = readablePreview.height; + } + } + finally + { + RenderTexture.active = previous; + if (rt != null) RenderTexture.ReleaseTemporary(rt); + if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); + } + } + catch (Exception ex) + { + McpLog.Warn( + $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." + ); + // Fallback: Try getting static preview if available? + // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); + } + } + else + { + McpLog.Warn( + $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" + ); + } + } + + return new + { + path = path, + guid = guid, + assetType = assetType?.FullName ?? "Unknown", + name = Path.GetFileNameWithoutExtension(path), + fileName = Path.GetFileName(path), + isFolder = AssetDatabase.IsValidFolder(path), + instanceID = asset?.GetInstanceID() ?? 0, + lastWriteTimeUtc = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), path) + ) + .ToString("o"), // ISO 8601 + // --- Preview Data --- + previewBase64 = previewBase64, // PNG data as Base64 string + previewWidth = previewWidth, + previewHeight = previewHeight, + // TODO: Add more metadata? Importer settings? Dependencies? + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs.meta new file mode 100644 index 0000000..c6cc94f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageAsset.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: de90a1d9743a2874cb235cf0b83444b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageAsset.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs b/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs new file mode 100644 index 0000000..596e5b8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Tool for managing components on GameObjects. + /// Actions: add, remove, set_property + /// + /// This is a focused tool for component lifecycle operations. + /// For reading component data, use the unity://scene/gameobject/{id}/components resource. + /// + [McpForUnityTool("manage_components")] + public static class ManageComponents + { + /// + /// Handles the manage_components command. + /// + /// Command parameters + /// Result of the component operation + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("'action' parameter is required (add, remove, set_property)."); + } + + // Target resolution + JToken targetToken = @params["target"]; + string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], null); + + if (targetToken == null) + { + return new ErrorResponse("'target' parameter is required."); + } + + try + { + return action switch + { + "add" => AddComponent(@params, targetToken, searchMethod), + "remove" => RemoveComponent(@params, targetToken, searchMethod), + "set_property" => SetProperty(@params, targetToken, searchMethod), + _ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property") + }; + } + catch (Exception e) + { + McpLog.Error($"[ManageComponents] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + + #region Action Implementations + + private static object AddComponent(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = FindTarget(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null); + if (string.IsNullOrEmpty(componentTypeName)) + { + return new ErrorResponse("'componentType' parameter is required for 'add' action."); + } + + // Resolve component type using unified type resolver + Type type = UnityTypeResolver.ResolveComponent(componentTypeName); + if (type == null) + { + return new ErrorResponse($"Component type '{componentTypeName}' not found. Use a fully-qualified name if needed."); + } + + // Use ComponentOps for the actual operation + Component newComponent = ComponentOps.AddComponent(targetGo, type, out string error); + if (newComponent == null) + { + return new ErrorResponse(error ?? $"Failed to add component '{componentTypeName}'."); + } + + // Set properties if provided + JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject; + if (properties != null && properties.HasValues) + { + // Record for undo before modifying properties + Undo.RecordObject(newComponent, "Modify Component Properties"); + SetPropertiesOnComponent(newComponent, properties); + } + + EditorUtility.SetDirty(targetGo); + MarkOwningSceneDirty(targetGo); + + return new + { + success = true, + message = $"Component '{componentTypeName}' added to '{targetGo.name}'.", + data = new + { + instanceID = targetGo.GetInstanceID(), + componentType = type.FullName, + componentInstanceID = newComponent.GetInstanceID() + } + }; + } + + private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = FindTarget(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string componentTypeName = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null); + if (string.IsNullOrEmpty(componentTypeName)) + { + return new ErrorResponse("'componentType' parameter is required for 'remove' action."); + } + + // Resolve component type using unified type resolver + Type type = UnityTypeResolver.ResolveComponent(componentTypeName); + if (type == null) + { + return new ErrorResponse($"Component type '{componentTypeName}' not found."); + } + + // Use ComponentOps for the actual operation + bool removed = ComponentOps.RemoveComponent(targetGo, type, out string error); + if (!removed) + { + return new ErrorResponse(error ?? $"Failed to remove component '{componentTypeName}'."); + } + + EditorUtility.SetDirty(targetGo); + MarkOwningSceneDirty(targetGo); + + return new + { + success = true, + message = $"Component '{componentTypeName}' removed from '{targetGo.name}'.", + data = new + { + instanceID = targetGo.GetInstanceID() + } + }; + } + + private static object SetProperty(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = FindTarget(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null); + if (string.IsNullOrEmpty(componentType)) + { + return new ErrorResponse("'componentType' parameter is required for 'set_property' action."); + } + + // Resolve component type using unified type resolver + Type type = UnityTypeResolver.ResolveComponent(componentType); + if (type == null) + { + return new ErrorResponse($"Component type '{componentType}' not found."); + } + + Component component = targetGo.GetComponent(type); + if (component == null) + { + return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'."); + } + + // Get property and value + string propertyName = ParamCoercion.CoerceString(@params["property"], null); + JToken valueToken = @params["value"]; + + // Support both single property or properties object + JObject properties = @params["properties"] as JObject; + + if (string.IsNullOrEmpty(propertyName) && (properties == null || !properties.HasValues)) + { + return new ErrorResponse("Either 'property'+'value' or 'properties' object is required for 'set_property' action."); + } + + var errors = new List(); + + try + { + Undo.RecordObject(component, $"Set property on {componentType}"); + + if (!string.IsNullOrEmpty(propertyName) && valueToken != null) + { + // Single property mode + var error = TrySetProperty(component, propertyName, valueToken); + if (error != null) + { + errors.Add(error); + } + } + + if (properties != null && properties.HasValues) + { + // Multiple properties mode + foreach (var prop in properties.Properties()) + { + var error = TrySetProperty(component, prop.Name, prop.Value); + if (error != null) + { + errors.Add(error); + } + } + } + + EditorUtility.SetDirty(component); + MarkOwningSceneDirty(targetGo); + + if (errors.Count > 0) + { + return new + { + success = false, + message = $"Some properties failed to set on '{componentType}'.", + data = new + { + instanceID = targetGo.GetInstanceID(), + errors = errors + } + }; + } + + return new + { + success = true, + message = $"Properties set on component '{componentType}' on '{targetGo.name}'.", + data = new + { + instanceID = targetGo.GetInstanceID() + } + }; + } + catch (Exception e) + { + return new ErrorResponse($"Error setting properties on component '{componentType}': {e.Message}"); + } + } + + #endregion + + #region Helpers + + /// + /// Marks the appropriate scene as dirty for the given GameObject. + /// Handles both regular scenes and prefab stages. + /// + private static void MarkOwningSceneDirty(GameObject targetGo) + { + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + { + EditorSceneManager.MarkSceneDirty(prefabStage.scene); + } + else + { + EditorSceneManager.MarkSceneDirty(targetGo.scene); + } + } + + private static GameObject FindTarget(JToken targetToken, string searchMethod) + { + if (targetToken == null) + return null; + + // Try instance ID first + if (targetToken.Type == JTokenType.Integer) + { + int instanceId = targetToken.Value(); + return GameObjectLookup.FindById(instanceId); + } + + string targetStr = targetToken.ToString(); + + // Try parsing as instance ID + if (int.TryParse(targetStr, out int parsedId)) + { + var byId = GameObjectLookup.FindById(parsedId); + if (byId != null) + return byId; + } + + // Use GameObjectLookup for search + return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true); + } + + private static void SetPropertiesOnComponent(Component component, JObject properties) + { + if (component == null || properties == null) + return; + + var errors = new List(); + foreach (var prop in properties.Properties()) + { + var error = TrySetProperty(component, prop.Name, prop.Value); + if (error != null) + errors.Add(error); + } + + if (errors.Count > 0) + { + McpLog.Warn($"[ManageComponents] Some properties failed to set on {component.GetType().Name}: {string.Join(", ", errors)}"); + } + } + + /// + /// Attempts to set a property or field on a component. + /// Delegates to ComponentOps.SetProperty for unified implementation. + /// + private static string TrySetProperty(Component component, string propertyName, JToken value) + { + if (component == null || string.IsNullOrEmpty(propertyName)) + return "Invalid component or property name"; + + if (ComponentOps.SetProperty(component, propertyName, value, out string error)) + { + return null; // Success + } + + McpLog.Warn($"[ManageComponents] {error}"); + return error; + } + + #endregion + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs.meta new file mode 100644 index 0000000..d0a14fc --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageComponents.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c6f476359563842c79eda2c180566c98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageComponents.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs b/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs new file mode 100644 index 0000000..2704803 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -0,0 +1,393 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; // Required for tag management + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles editor control actions including play mode control, tool selection, + /// and tag/layer management. For reading editor state, use MCP resources instead. + /// + [McpForUnityTool("manage_editor", AutoRegister = false)] + public static class ManageEditor + { + // Constant for starting user layer index + private const int FirstUserLayerIndex = 8; + + // Constant for total layer count + private const int TotalLayerCount = 32; + + /// + /// Main handler for editor management actions. + /// + public static object HandleCommand(JObject @params) + { + // Step 1: Null parameter guard (consistent across all tools) + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + // Step 2: Wrap parameters + var p = new ToolParams(@params); + + // Step 3: Extract and validate required parameters + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + { + return new ErrorResponse(actionResult.ErrorMessage); + } + string action = actionResult.Value.ToLowerInvariant(); + + // Parameters for specific actions + string tagName = p.Get("tagName"); + string layerName = p.Get("layerName"); + bool waitForCompletion = p.GetBool("waitForCompletion", false); + + // Route action + switch (action) + { + // Play Mode Control + case "play": + try + { + if (!EditorApplication.isPlaying) + { + EditorApplication.isPlaying = true; + return new SuccessResponse("Entered play mode."); + } + return new SuccessResponse("Already in play mode."); + } + catch (Exception e) + { + return new ErrorResponse($"Error entering play mode: {e.Message}"); + } + case "pause": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPaused = !EditorApplication.isPaused; + return new SuccessResponse( + EditorApplication.isPaused ? "Game paused." : "Game resumed." + ); + } + return new ErrorResponse("Cannot pause/resume: Not in play mode."); + } + catch (Exception e) + { + return new ErrorResponse($"Error pausing/resuming game: {e.Message}"); + } + case "stop": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPlaying = false; + return new SuccessResponse("Exited play mode."); + } + return new SuccessResponse("Already stopped (not in play mode)."); + } + catch (Exception e) + { + return new ErrorResponse($"Error stopping play mode: {e.Message}"); + } + + // Tool Control + case "set_active_tool": + var toolNameResult = p.GetRequired("toolName", "'toolName' parameter required for set_active_tool."); + if (!toolNameResult.IsSuccess) + return new ErrorResponse(toolNameResult.ErrorMessage); + return SetActiveTool(toolNameResult.Value); + + // Tag Management + case "add_tag": + var addTagResult = p.GetRequired("tagName", "'tagName' parameter required for add_tag."); + if (!addTagResult.IsSuccess) + return new ErrorResponse(addTagResult.ErrorMessage); + return AddTag(addTagResult.Value); + case "remove_tag": + var removeTagResult = p.GetRequired("tagName", "'tagName' parameter required for remove_tag."); + if (!removeTagResult.IsSuccess) + return new ErrorResponse(removeTagResult.ErrorMessage); + return RemoveTag(removeTagResult.Value); + // Layer Management + case "add_layer": + var addLayerResult = p.GetRequired("layerName", "'layerName' parameter required for add_layer."); + if (!addLayerResult.IsSuccess) + return new ErrorResponse(addLayerResult.ErrorMessage); + return AddLayer(addLayerResult.Value); + case "remove_layer": + var removeLayerResult = p.GetRequired("layerName", "'layerName' parameter required for remove_layer."); + if (!removeLayerResult.IsSuccess) + return new ErrorResponse(removeLayerResult.ErrorMessage); + return RemoveLayer(removeLayerResult.Value); + // --- Settings (Example) --- + // case "set_resolution": + // int? width = @params["width"]?.ToObject(); + // int? height = @params["height"]?.ToObject(); + // if (!width.HasValue || !height.HasValue) return new ErrorResponse("'width' and 'height' parameters required."); + // return SetGameViewResolution(width.Value, height.Value); + // case "set_quality": + // // Handle string name or int index + // return SetQualityLevel(@params["qualityLevel"]); + + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." + ); + } + } + + // --- Tool Control Methods --- + + private static object SetActiveTool(string toolName) + { + try + { + Tool targetTool; + if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse + { + // Check if it's a valid built-in tool + if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool + { + UnityEditor.Tools.current = targetTool; + return new SuccessResponse($"Set active tool to '{targetTool}'."); + } + else + { + return new ErrorResponse( + $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." + ); + } + } + else + { + // Potentially try activating a custom tool by name here if needed + // This often requires specific editor scripting knowledge for that tool. + return new ErrorResponse( + $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." + ); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error setting active tool: {e.Message}"); + } + } + + // --- Tag Management Methods --- + + private static object AddTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return new ErrorResponse("Tag name cannot be empty or whitespace."); + + // Check if tag already exists + if (System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) + { + return new ErrorResponse($"Tag '{tagName}' already exists."); + } + + try + { + // Add the tag using the internal utility + InternalEditorUtility.AddTag(tagName); + // Force save assets to ensure the change persists in the TagManager asset + AssetDatabase.SaveAssets(); + return new SuccessResponse($"Tag '{tagName}' added successfully."); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to add tag '{tagName}': {e.Message}"); + } + } + + private static object RemoveTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return new ErrorResponse("Tag name cannot be empty or whitespace."); + if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) + return new ErrorResponse("Cannot remove the built-in 'Untagged' tag."); + + // Check if tag exists before attempting removal + if (!System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagName)) + { + return new ErrorResponse($"Tag '{tagName}' does not exist."); + } + + try + { + // Remove the tag using the internal utility + InternalEditorUtility.RemoveTag(tagName); + // Force save assets + AssetDatabase.SaveAssets(); + return new SuccessResponse($"Tag '{tagName}' removed successfully."); + } + catch (Exception e) + { + // Catch potential issues if the tag is somehow in use or removal fails + return new ErrorResponse($"Failed to remove tag '{tagName}': {e.Message}"); + } + } + + // --- Layer Management Methods --- + + private static object AddLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return new ErrorResponse("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return new ErrorResponse("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return new ErrorResponse("Could not find 'layers' property in TagManager."); + + // Check if layer name already exists (case-insensitive check recommended) + for (int i = 0; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + return new ErrorResponse($"Layer '{layerName}' already exists at index {i}."); + } + } + + // Find the first empty user layer slot (indices 8 to 31) + int firstEmptyUserLayer = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) + { + firstEmptyUserLayer = i; + break; + } + } + + if (firstEmptyUserLayer == -1) + { + return new ErrorResponse("No empty User Layer slots available (8-31 are full)."); + } + + // Assign the name to the found slot + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + firstEmptyUserLayer + ); + targetLayerSP.stringValue = layerName; + // Apply the changes to the TagManager asset + tagManager.ApplyModifiedProperties(); + // Save assets to make sure it's written to disk + AssetDatabase.SaveAssets(); + return new SuccessResponse( + $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to add layer '{layerName}': {e.Message}"); + } + } + + private static object RemoveLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return new ErrorResponse("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return new ErrorResponse("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return new ErrorResponse("Could not find 'layers' property in TagManager."); + + // Find the layer by name (must be user layer) + int layerIndexToRemove = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + // Case-insensitive comparison is safer + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + layerIndexToRemove = i; + break; + } + } + + if (layerIndexToRemove == -1) + { + return new ErrorResponse($"User layer '{layerName}' not found."); + } + + // Clear the name for that index + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + layerIndexToRemove + ); + targetLayerSP.stringValue = string.Empty; // Set to empty string to remove + // Apply the changes + tagManager.ApplyModifiedProperties(); + // Save assets + AssetDatabase.SaveAssets(); + return new SuccessResponse( + $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to remove layer '{layerName}': {e.Message}"); + } + } + + // --- Helper Methods --- + + /// + /// Gets the SerializedObject for the TagManager asset. + /// + private static SerializedObject GetTagManager() + { + try + { + // Load the TagManager asset from the ProjectSettings folder + UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( + "ProjectSettings/TagManager.asset" + ); + if (tagManagerAssets == null || tagManagerAssets.Length == 0) + { + McpLog.Error("[ManageEditor] TagManager.asset not found in ProjectSettings."); + return null; + } + // The first object in the asset file should be the TagManager + return new SerializedObject(tagManagerAssets[0]); + } + catch (Exception e) + { + McpLog.Error($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); + return null; + } + } + + // --- Example Implementations for Settings --- + /* + private static object SetGameViewResolution(int width, int height) { ... } + private static object SetQualityLevel(JToken qualityLevelToken) { ... } + */ + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs.meta new file mode 100644 index 0000000..e60635d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageEditor.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 43ac60aa36b361b4dbe4a038ae9f35c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageEditor.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs b/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs new file mode 100644 index 0000000..e6d7cd2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + [McpForUnityTool("manage_material", AutoRegister = false)] + public static class ManageMaterial + { + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action is required"); + } + + try + { + switch (action) + { + case "ping": + return new SuccessResponse("pong", new { tool = "manage_material" }); + + case "create": + return CreateMaterial(@params); + + case "set_material_shader_property": + return SetMaterialShaderProperty(@params); + + case "set_material_color": + return SetMaterialColor(@params); + + case "assign_material_to_renderer": + return AssignMaterialToRenderer(@params); + + case "set_renderer_color": + return SetRendererColor(@params); + + case "get_material_info": + return GetMaterialInfo(@params); + + default: + return new ErrorResponse($"Unknown action: {action}"); + } + } + catch (Exception ex) + { + return new ErrorResponse(ex.Message, new { stackTrace = ex.StackTrace }); + } + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + // Normalize separators and ensure Assets/ root + path = AssetPathUtility.SanitizeAssetPath(path); + + // Ensure .mat extension + if (!path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase)) + { + path += ".mat"; + } + + return path; + } + + private static object SetMaterialShaderProperty(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + string property = @params["property"]?.ToString(); + JToken value = @params["value"]; + + if (string.IsNullOrEmpty(materialPath) || string.IsNullOrEmpty(property) || value == null) + { + return new ErrorResponse("materialPath, property, and value are required"); + } + + // Find material + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new ErrorResponse($"Could not find material at path: {materialPath}"); + } + + Undo.RecordObject(mat, "Set Material Property"); + + // Normalize alias/casing once for all code paths + property = MaterialOps.ResolvePropertyName(mat, property); + + // 1. Try handling Texture instruction explicitly (ManageMaterial special feature) + if (value.Type == JTokenType.Object) + { + // Check if it looks like an instruction + if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method"))) + { + Texture tex = ObjectResolver.Resolve(obj, typeof(Texture)) as Texture; + if (tex != null && mat.HasProperty(property)) + { + mat.SetTexture(property, tex); + EditorUtility.SetDirty(mat); + return new SuccessResponse($"Set texture property {property} on {mat.name}"); + } + } + } + + // 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path) + bool success = MaterialOps.TrySetShaderProperty(mat, property, value, UnityJsonSerializer.Instance); + + if (success) + { + EditorUtility.SetDirty(mat); + return new SuccessResponse($"Set property {property} on {mat.name}"); + } + else + { + return new ErrorResponse($"Failed to set property {property}. Value format might be unsupported or texture not found."); + } + } + + private static object SetMaterialColor(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + JToken colorToken = @params["color"]; + string property = @params["property"]?.ToString(); + + if (string.IsNullOrEmpty(materialPath) || colorToken == null) + { + return new ErrorResponse("materialPath and color are required"); + } + + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new ErrorResponse($"Could not find material at path: {materialPath}"); + } + + Color color; + try + { + color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance); + } + catch (Exception e) + { + return new ErrorResponse($"Invalid color format: {e.Message}"); + } + + Undo.RecordObject(mat, "Set Material Color"); + + bool foundProp = false; + if (!string.IsNullOrEmpty(property)) + { + if (mat.HasProperty(property)) + { + mat.SetColor(property, color); + foundProp = true; + } + } + else + { + // Fallback logic: _BaseColor (URP/HDRP) then _Color (Built-in) + if (mat.HasProperty("_BaseColor")) + { + mat.SetColor("_BaseColor", color); + foundProp = true; + property = "_BaseColor"; + } + else if (mat.HasProperty("_Color")) + { + mat.SetColor("_Color", color); + foundProp = true; + property = "_Color"; + } + } + + if (foundProp) + { + EditorUtility.SetDirty(mat); + return new SuccessResponse($"Set color on {property}"); + } + else + { + return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) or specified property does not exist."); + } + } + + private static object AssignMaterialToRenderer(JObject @params) + { + string target = @params["target"]?.ToString(); + string searchMethod = @params["searchMethod"]?.ToString(); + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + int slot = @params["slot"]?.ToObject() ?? 0; + + if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath)) + { + return new ErrorResponse("target and materialPath are required"); + } + + var goInstruction = new JObject { ["find"] = target }; + if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; + + GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; + if (go == null) + { + return new ErrorResponse($"Could not find target GameObject: {target}"); + } + + Renderer renderer = go.GetComponent(); + if (renderer == null) + { + return new ErrorResponse($"GameObject {go.name} has no Renderer component"); + } + + var matInstruction = new JObject { ["find"] = materialPath }; + Material mat = ObjectResolver.Resolve(matInstruction, typeof(Material)) as Material; + if (mat == null) + { + return new ErrorResponse($"Could not find material: {materialPath}"); + } + + Undo.RecordObject(renderer, "Assign Material"); + + Material[] sharedMats = renderer.sharedMaterials; + if (slot < 0 || slot >= sharedMats.Length) + { + return new ErrorResponse($"Slot {slot} out of bounds (count: {sharedMats.Length})"); + } + + sharedMats[slot] = mat; + renderer.sharedMaterials = sharedMats; + + EditorUtility.SetDirty(renderer); + return new SuccessResponse($"Assigned material {mat.name} to {go.name} slot {slot}"); + } + + private static object SetRendererColor(JObject @params) + { + string target = @params["target"]?.ToString(); + string searchMethod = @params["searchMethod"]?.ToString(); + JToken colorToken = @params["color"]; + int slot = @params["slot"]?.ToObject() ?? 0; + string mode = @params["mode"]?.ToString() ?? "property_block"; + + if (string.IsNullOrEmpty(target) || colorToken == null) + { + return new ErrorResponse("target and color are required"); + } + + Color color; + try + { + color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance); + } + catch (Exception e) + { + return new ErrorResponse($"Invalid color format: {e.Message}"); + } + + var goInstruction = new JObject { ["find"] = target }; + if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; + + GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; + if (go == null) + { + return new ErrorResponse($"Could not find target GameObject: {target}"); + } + + Renderer renderer = go.GetComponent(); + if (renderer == null) + { + return new ErrorResponse($"GameObject {go.name} has no Renderer component"); + } + + if (mode == "property_block") + { + if (slot < 0 || slot >= renderer.sharedMaterials.Length) + { + return new ErrorResponse($"Slot {slot} out of bounds (count: {renderer.sharedMaterials.Length})"); + } + + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, slot); + + if (renderer.sharedMaterials[slot] != null) + { + Material mat = renderer.sharedMaterials[slot]; + if (mat.HasProperty("_BaseColor")) block.SetColor("_BaseColor", color); + else if (mat.HasProperty("_Color")) block.SetColor("_Color", color); + else block.SetColor("_Color", color); + } + else + { + block.SetColor("_Color", color); + } + + renderer.SetPropertyBlock(block, slot); + EditorUtility.SetDirty(renderer); + return new SuccessResponse($"Set renderer color (PropertyBlock) on slot {slot}"); + } + else if (mode == "shared") + { + if (slot >= 0 && slot < renderer.sharedMaterials.Length) + { + Material mat = renderer.sharedMaterials[slot]; + if (mat == null) + { + return new ErrorResponse($"No material in slot {slot}"); + } + Undo.RecordObject(mat, "Set Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + EditorUtility.SetDirty(mat); + return new SuccessResponse("Set shared material color"); + } + return new ErrorResponse("Invalid slot"); + } + else if (mode == "instance") + { + if (slot >= 0 && slot < renderer.materials.Length) + { + Material mat = renderer.materials[slot]; + if (mat == null) + { + return new ErrorResponse($"No material in slot {slot}"); + } + // Note: Undo cannot fully revert material instantiation + Undo.RecordObject(mat, "Set Instance Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + return new SuccessResponse("Set instance material color", new { warning = "Material instance created; Undo cannot fully revert instantiation." }); + } + return new ErrorResponse("Invalid slot"); + } + + return new ErrorResponse($"Unknown mode: {mode}"); + } + + private static object GetMaterialInfo(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + if (string.IsNullOrEmpty(materialPath)) + { + return new ErrorResponse("materialPath is required"); + } + + var findInstruction = new JObject { ["find"] = materialPath }; + Material mat = ObjectResolver.Resolve(findInstruction, typeof(Material)) as Material; + + if (mat == null) + { + return new ErrorResponse($"Could not find material at path: {materialPath}"); + } + + Shader shader = mat.shader; + var properties = new List(); + +#if UNITY_6000_0_OR_NEWER + int propertyCount = shader.GetPropertyCount(); + for (int i = 0; i < propertyCount; i++) + { + string name = shader.GetPropertyName(i); + var type = shader.GetPropertyType(i); + string description = shader.GetPropertyDescription(i); + + object currentValue = null; + try + { + if (mat.HasProperty(name)) + { + switch (type) + { + case UnityEngine.Rendering.ShaderPropertyType.Color: + var c = mat.GetColor(name); + currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; + break; + case UnityEngine.Rendering.ShaderPropertyType.Vector: + var v = mat.GetVector(name); + currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w }; + break; + case UnityEngine.Rendering.ShaderPropertyType.Float: + case UnityEngine.Rendering.ShaderPropertyType.Range: + currentValue = mat.GetFloat(name); + break; + case UnityEngine.Rendering.ShaderPropertyType.Texture: + currentValue = mat.GetTexture(name)?.name ?? "null"; + break; + } + } + } + catch (Exception ex) + { + currentValue = $""; + } + + properties.Add(new + { + name = name, + type = type.ToString(), + description = description, + value = currentValue + }); + } +#else + int propertyCount = ShaderUtil.GetPropertyCount(shader); + for (int i = 0; i < propertyCount; i++) + { + string name = ShaderUtil.GetPropertyName(shader, i); + ShaderUtil.ShaderPropertyType type = ShaderUtil.GetPropertyType(shader, i); + string description = ShaderUtil.GetPropertyDescription(shader, i); + + object currentValue = null; + try + { + if (mat.HasProperty(name)) + { + switch (type) + { + case ShaderUtil.ShaderPropertyType.Color: + var c = mat.GetColor(name); + currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; + break; + case ShaderUtil.ShaderPropertyType.Vector: + var v = mat.GetVector(name); + currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w }; + break; + case ShaderUtil.ShaderPropertyType.Float: currentValue = mat.GetFloat(name); break; + case ShaderUtil.ShaderPropertyType.Range: currentValue = mat.GetFloat(name); break; + case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break; + } + } + } + catch (Exception ex) + { + currentValue = $""; + } + + properties.Add(new + { + name = name, + type = type.ToString(), + description = description, + value = currentValue + }); + } +#endif + + return new SuccessResponse($"Retrieved material info for {mat.name}", new + { + material = mat.name, + shader = shader.name, + properties = properties + }); + } + + private static object CreateMaterial(JObject @params) + { + string materialPath = NormalizePath(@params["materialPath"]?.ToString()); + string shaderName = @params["shader"]?.ToString() ?? "Standard"; + JToken colorToken = @params["color"]; + string colorProperty = @params["property"]?.ToString(); + + JObject properties = null; + JToken propsToken = @params["properties"]; + if (propsToken != null) + { + if (propsToken.Type == JTokenType.String) + { + try { properties = JObject.Parse(propsToken.ToString()); } + catch (Exception ex) { return new ErrorResponse($"Invalid JSON in properties: {ex.Message}"); } + } + else if (propsToken is JObject obj) + { + properties = obj; + } + } + + if (string.IsNullOrEmpty(materialPath)) + { + return new ErrorResponse("materialPath is required"); + } + + // Safety check: SanitizeAssetPath should guarantee Assets/ prefix + // This check catches edge cases where normalization might fail + if (!materialPath.StartsWith("Assets/")) + { + return new ErrorResponse($"Invalid path '{materialPath}'. Path must be within Assets/ folder."); + } + + Shader shader = RenderPipelineUtility.ResolveShader(shaderName); + if (shader == null) + { + return new ErrorResponse($"Could not find shader: {shaderName}"); + } + + // Check for existing asset to avoid silent overwrite + if (AssetDatabase.LoadAssetAtPath(materialPath) != null) + { + return new ErrorResponse($"Material already exists at {materialPath}"); + } + + Material material = null; + var shouldDestroyMaterial = true; + try + { + material = new Material(shader); + + // Apply color param during creation (keeps Python tool signature and C# implementation consistent). + // If "properties" already contains a color property, let properties win. + bool shouldApplyColor = false; + if (colorToken != null) + { + if (properties == null) + { + shouldApplyColor = true; + } + else if (!string.IsNullOrEmpty(colorProperty)) + { + // If colorProperty is specified, only check that specific property. + shouldApplyColor = !properties.ContainsKey(colorProperty); + } + else + { + // If colorProperty is not specified, check fallback properties. + shouldApplyColor = !properties.ContainsKey("_BaseColor") && !properties.ContainsKey("_Color"); + } + } + + if (shouldApplyColor) + { + Color color; + try + { + color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance); + } + catch (Exception e) + { + return new ErrorResponse($"Invalid color format: {e.Message}"); + } + + if (!string.IsNullOrEmpty(colorProperty)) + { + if (material.HasProperty(colorProperty)) + { + material.SetColor(colorProperty, color); + } + else + { + return new ErrorResponse($"Specified color property '{colorProperty}' does not exist on this material."); + } + } + else if (material.HasProperty("_BaseColor")) + { + material.SetColor("_BaseColor", color); + } + else if (material.HasProperty("_Color")) + { + material.SetColor("_Color", color); + } + else + { + return new ErrorResponse("Could not find suitable color property (_BaseColor or _Color) on this material's shader."); + } + } + + AssetDatabase.CreateAsset(material, materialPath); + shouldDestroyMaterial = false; // material is now owned by the AssetDatabase + + if (properties != null) + { + MaterialOps.ApplyProperties(material, properties, UnityJsonSerializer.Instance); + } + + EditorUtility.SetDirty(material); + AssetDatabase.SaveAssets(); + + return new SuccessResponse($"Created material at {materialPath} with shader {shaderName}"); + } + finally + { + if (shouldDestroyMaterial && material != null) + { + UnityEngine.Object.DestroyImmediate(material); + } + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta new file mode 100644 index 0000000..f5cd95f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: e55741e2b00794a049a0ed5e63278a56 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageMaterial.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScene.cs b/Assets/MCPForUnity/Editor/Tools/ManageScene.cs new file mode 100644 index 0000000..2ceecd9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScene.cs @@ -0,0 +1,838 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MCPForUnity.Editor.Helpers; // For Response class +using MCPForUnity.Runtime.Helpers; // For ScreenshotUtility +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles scene management operations like loading, saving, creating, and querying hierarchy. + /// + [McpForUnityTool("manage_scene", AutoRegister = false)] + public static class ManageScene + { + private sealed class SceneCommand + { + public string action { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string path { get; set; } = string.Empty; + public int? buildIndex { get; set; } + public string fileName { get; set; } = string.Empty; + public int? superSize { get; set; } + + // get_hierarchy paging + safety (summary-first) + public JToken parent { get; set; } + public int? pageSize { get; set; } + public int? cursor { get; set; } + public int? maxNodes { get; set; } + public int? maxDepth { get; set; } + public int? maxChildrenPerNode { get; set; } + public bool? includeTransform { get; set; } + } + + private static SceneCommand ToSceneCommand(JObject p) + { + if (p == null) return new SceneCommand(); + return new SceneCommand + { + action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), + name = p["name"]?.ToString() ?? string.Empty, + path = p["path"]?.ToString() ?? string.Empty, + buildIndex = ParamCoercion.CoerceIntNullable(p["buildIndex"] ?? p["build_index"]), + fileName = (p["fileName"] ?? p["filename"])?.ToString() ?? string.Empty, + superSize = ParamCoercion.CoerceIntNullable(p["superSize"] ?? p["super_size"] ?? p["supersize"]), + + // get_hierarchy paging + safety + parent = p["parent"], + pageSize = ParamCoercion.CoerceIntNullable(p["pageSize"] ?? p["page_size"]), + cursor = ParamCoercion.CoerceIntNullable(p["cursor"]), + maxNodes = ParamCoercion.CoerceIntNullable(p["maxNodes"] ?? p["max_nodes"]), + maxDepth = ParamCoercion.CoerceIntNullable(p["maxDepth"] ?? p["max_depth"]), + maxChildrenPerNode = ParamCoercion.CoerceIntNullable(p["maxChildrenPerNode"] ?? p["max_children_per_node"]), + includeTransform = ParamCoercion.CoerceBoolNullable(p["includeTransform"] ?? p["include_transform"]), + }; + } + + /// + /// Main handler for scene management actions. + /// + public static object HandleCommand(JObject @params) + { + try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } + var cmd = ToSceneCommand(@params); + string action = cmd.action; + string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; + string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ + int? buildIndex = cmd.buildIndex; + // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension + + // Ensure path is relative to Assets/, removing any leading "Assets/" + string relativeDir = path ?? string.Empty; + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + + // Apply default *after* sanitizing, using the original path variable for the check + if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness + { + relativeDir = "Scenes"; // Default relative directory + } + + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; + // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) + string fullPath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine(fullPathDir, sceneFileName); + // Ensure relativePath always starts with "Assets/" and uses forward slashes + string relativePath = string.IsNullOrEmpty(sceneFileName) + ? null + : AssetPathUtility.NormalizeSeparators(Path.Combine("Assets", relativeDir, sceneFileName)); + + // Ensure directory exists for 'create' + if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return new ErrorResponse( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route action + try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } + switch (action) + { + case "create": + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) + return new ErrorResponse( + "'name' and 'path' parameters are required for 'create' action." + ); + return CreateScene(fullPath, relativePath); + case "load": + // Loading can be done by path/name or build index + if (!string.IsNullOrEmpty(relativePath)) + return LoadScene(relativePath); + else if (buildIndex.HasValue) + return LoadScene(buildIndex.Value); + else + return new ErrorResponse( + "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." + ); + case "save": + // Save current scene, optionally to a new path + return SaveScene(fullPath, relativePath); + case "get_hierarchy": + try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } + var gh = GetSceneHierarchyPaged(cmd); + try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } + return gh; + case "get_active": + try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } + var ga = GetActiveSceneInfo(); + try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } + return ga; + case "get_build_settings": + return GetBuildSettingsScenes(); + case "screenshot": + return CaptureScreenshot(cmd.fileName, cmd.superSize); + // Add cases for modifying build settings, additive loading, unloading etc. + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings, screenshot." + ); + } + } + + /// + /// Captures a screenshot to Assets/Screenshots and returns a response payload. + /// Public so the tools UI can reuse the same logic without duplicating parameters. + /// Available in both Edit Mode and Play Mode. + /// + public static object ExecuteScreenshot(string fileName = null, int? superSize = null) + { + return CaptureScreenshot(fileName, superSize); + } + + private static object CreateScene(string fullPath, string relativePath) + { + if (File.Exists(fullPath)) + { + return new ErrorResponse($"Scene already exists at '{relativePath}'."); + } + + try + { + // Create a new empty scene + Scene newScene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, + NewSceneMode.Single + ); + // Save it to the specified path + bool saved = EditorSceneManager.SaveScene(newScene, relativePath); + + if (saved) + { + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity sees the new scene file + return new SuccessResponse( + $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + else + { + // If SaveScene fails, it might leave an untitled scene open. + // Optionally try to close it, but be cautious. + return new ErrorResponse($"Failed to save new scene to '{relativePath}'."); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error creating scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(string relativePath) + { + if ( + !File.Exists( + Path.Combine( + Application.dataPath.Substring( + 0, + Application.dataPath.Length - "Assets".Length + ), + relativePath + ) + ) + ) + { + return new ErrorResponse($"Scene file not found at '{relativePath}'."); + } + + // Check for unsaved changes in the current scene + if (EditorSceneManager.GetActiveScene().isDirty) + { + // Optionally prompt the user or save automatically before loading + return new ErrorResponse( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); + // if (!saveOK) return new ErrorResponse("Load cancelled by user."); + } + + try + { + EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); + return new SuccessResponse( + $"Scene '{relativePath}' loaded successfully.", + new + { + path = relativePath, + name = Path.GetFileNameWithoutExtension(relativePath), + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Error loading scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(int buildIndex) + { + if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) + { + return new ErrorResponse( + $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." + ); + } + + // Check for unsaved changes + if (EditorSceneManager.GetActiveScene().isDirty) + { + return new ErrorResponse( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + } + + try + { + string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); + EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); + return new SuccessResponse( + $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", + new + { + path = scenePath, + name = Path.GetFileNameWithoutExtension(scenePath), + buildIndex = buildIndex, + } + ); + } + catch (Exception e) + { + return new ErrorResponse( + $"Error loading scene with build index {buildIndex}: {e.Message}" + ); + } + } + + private static object SaveScene(string fullPath, string relativePath) + { + try + { + Scene currentScene = EditorSceneManager.GetActiveScene(); + if (!currentScene.IsValid()) + { + return new ErrorResponse("No valid scene is currently active to save."); + } + + bool saved; + string finalPath = currentScene.path; // Path where it was last saved or will be saved + + if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) + { + // Save As... + // Ensure directory exists + string dir = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + saved = EditorSceneManager.SaveScene(currentScene, relativePath); + finalPath = relativePath; + } + else + { + // Save (overwrite existing or save untitled) + if (string.IsNullOrEmpty(currentScene.path)) + { + // Scene is untitled, needs a path + return new ErrorResponse( + "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." + ); + } + saved = EditorSceneManager.SaveScene(currentScene); + } + + if (saved) + { + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + return new SuccessResponse( + $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", + new { path = finalPath, name = currentScene.name } + ); + } + else + { + return new ErrorResponse($"Failed to save scene '{currentScene.name}'."); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error saving scene: {e.Message}"); + } + } + + private static object CaptureScreenshot(string fileName, int? superSize) + { + try + { + int resolvedSuperSize = (superSize.HasValue && superSize.Value > 0) ? superSize.Value : 1; + + // Batch mode warning + if (Application.isBatchMode) + { + McpLog.Warn("[ManageScene] Screenshot capture in batch mode uses camera-based fallback. Results may vary."); + } + + // Check Screen Capture module availability and warn if not available + bool screenCaptureAvailable = ScreenshotUtility.IsScreenCaptureModuleAvailable; + bool hasCameraFallback = Camera.main != null || UnityEngine.Object.FindObjectsOfType().Length > 0; + +#if UNITY_2022_1_OR_NEWER + if (!screenCaptureAvailable && !hasCameraFallback) + { + return new ErrorResponse( + "Cannot capture screenshot. The Screen Capture module is not enabled and no Camera was found in the scene. " + + "Please either: (1) Enable the Screen Capture module: Window > Package Manager > Built-in > Screen Capture > Enable, " + + "or (2) Add a Camera to your scene for camera-based fallback capture." + ); + } + + if (!screenCaptureAvailable) + { + McpLog.Warn("[ManageScene] Screen Capture module not enabled. Using camera-based fallback. " + + "For best results, enable it: Window > Package Manager > Built-in > Screen Capture > Enable."); + } +#else + if (!hasCameraFallback) + { + return new ErrorResponse( + "No camera found in the scene. Screenshot capture on Unity versions before 2022.1 requires a Camera in the scene. " + + "Please add a Camera to your scene or upgrade to Unity 2022.1+ for ScreenCapture API support." + ); + } +#endif + + // Best-effort: ensure Game View exists and repaints before capture. + if (!Application.isBatchMode) + { + EnsureGameView(); + } + + ScreenshotCaptureResult result = ScreenshotUtility.CaptureToAssetsFolder(fileName, resolvedSuperSize, ensureUniqueFileName: true); + + // ScreenCapture.CaptureScreenshot is async. Import after the file actually hits disk. + if (result.IsAsync) + { + ScheduleAssetImportWhenFileExists(result.AssetsRelativePath, result.FullPath, timeoutSeconds: 30.0); + } + else + { + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + } + + string verb = result.IsAsync ? "Screenshot requested" : "Screenshot captured"; + string message = $"{verb} to '{result.AssetsRelativePath}' (full: {result.FullPath})."; + + return new SuccessResponse( + message, + new + { + path = result.AssetsRelativePath, + fullPath = result.FullPath, + superSize = result.SuperSize, + isAsync = result.IsAsync, + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Error capturing screenshot: {e.Message}"); + } + } + + private static void EnsureGameView() + { + try + { + // Ensure a Game View exists and has a chance to repaint before capture. + try + { + if (!EditorApplication.ExecuteMenuItem("Window/General/Game")) + { + // Some Unity versions expose hotkey suffixes in menu paths. + EditorApplication.ExecuteMenuItem("Window/General/Game %2"); + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to open Game View via menu item: {e.Message}"); } catch { } + } + + try + { + var gameViewType = Type.GetType("UnityEditor.GameView,UnityEditor"); + if (gameViewType != null) + { + var window = EditorWindow.GetWindow(gameViewType); + window?.Repaint(); + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Game View: {e.Message}"); } catch { } + } + + try { SceneView.RepaintAll(); } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to repaint Scene View: {e.Message}"); } catch { } + } + + try { EditorApplication.QueuePlayerLoopUpdate(); } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: failed to queue player loop update: {e.Message}"); } catch { } + } + } + catch (Exception e) + { + try { McpLog.Debug($"[ManageScene] screenshot: EnsureGameView failed: {e.Message}"); } catch { } + } + } + + private static void ScheduleAssetImportWhenFileExists(string assetsRelativePath, string fullPath, double timeoutSeconds) + { + if (string.IsNullOrWhiteSpace(assetsRelativePath) || string.IsNullOrWhiteSpace(fullPath)) + { + McpLog.Warn("[ManageScene] ScheduleAssetImportWhenFileExists: invalid paths provided, skipping import scheduling."); + return; + } + + double start = EditorApplication.timeSinceStartup; + int failureCount = 0; + bool hasSeenFile = false; + const int maxLoggedFailures = 3; + EditorApplication.CallbackFunction tick = null; + tick = () => + { + try + { + if (File.Exists(fullPath)) + { + hasSeenFile = true; + + AssetDatabase.ImportAsset(assetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + McpLog.Debug($"[ManageScene] Imported asset at '{assetsRelativePath}'."); + EditorApplication.update -= tick; + return; + } + } + catch (Exception e) + { + failureCount++; + + if (failureCount <= maxLoggedFailures) + { + McpLog.Warn($"[ManageScene] Exception while importing asset '{assetsRelativePath}' from '{fullPath}' (attempt {failureCount}): {e}"); + } + } + + if (EditorApplication.timeSinceStartup - start > timeoutSeconds) + { + if (!hasSeenFile) + { + McpLog.Warn($"[ManageScene] Timed out waiting for file '{fullPath}' (asset: '{assetsRelativePath}') after {timeoutSeconds:F1} seconds. The asset was not imported."); + } + else + { + McpLog.Warn($"[ManageScene] Timed out importing asset '{assetsRelativePath}' from '{fullPath}' after {timeoutSeconds:F1} seconds. The file existed but the asset was not imported."); + } + + EditorApplication.update -= tick; + } + }; + + EditorApplication.update += tick; + } + + private static object GetActiveSceneInfo() + { + try + { + try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + Scene activeScene = EditorSceneManager.GetActiveScene(); + try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid()) + { + return new ErrorResponse("No active scene found."); + } + + var sceneInfo = new + { + name = activeScene.name, + path = activeScene.path, + buildIndex = activeScene.buildIndex, // -1 if not in build settings + isDirty = activeScene.isDirty, + isLoaded = activeScene.isLoaded, + rootCount = activeScene.rootCount, + }; + + return new SuccessResponse("Retrieved active scene information.", sceneInfo); + } + catch (Exception e) + { + try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } + return new ErrorResponse($"Error getting active scene info: {e.Message}"); + } + } + + private static object GetBuildSettingsScenes() + { + try + { + var scenes = new List(); + for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) + { + var scene = EditorBuildSettings.scenes[i]; + scenes.Add( + new + { + path = scene.path, + guid = scene.guid.ToString(), + enabled = scene.enabled, + buildIndex = i, // Actual build index considering only enabled scenes might differ + } + ); + } + return new SuccessResponse("Retrieved scenes from Build Settings.", scenes); + } + catch (Exception e) + { + return new ErrorResponse($"Error getting scenes from Build Settings: {e.Message}"); + } + } + + private static object GetSceneHierarchyPaged(SceneCommand cmd) + { + try + { + // Check Prefab Stage first + var prefabStage = PrefabStageUtility.GetCurrentPrefabStage(); + Scene activeScene; + + if (prefabStage != null) + { + activeScene = prefabStage.scene; + try { McpLog.Info("[ManageScene] get_hierarchy: using Prefab Stage scene", always: false); } catch { } + } + else + { + try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + activeScene = EditorSceneManager.GetActiveScene(); + } + + try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return new ErrorResponse( + "No valid and loaded scene is active to get hierarchy from." + ); + } + + // Defaults tuned for safety; callers can override but we clamp to sane maxes. + // NOTE: pageSize is "items per page", not "number of pages". + // Keep this conservative to reduce peak response sizes when callers omit page_size. + int resolvedPageSize = Mathf.Clamp(cmd.pageSize ?? 50, 1, 500); + int resolvedCursor = Mathf.Max(0, cmd.cursor ?? 0); + int resolvedMaxNodes = Mathf.Clamp(cmd.maxNodes ?? 1000, 1, 5000); + int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxNodes); + int resolvedMaxChildrenPerNode = Mathf.Clamp(cmd.maxChildrenPerNode ?? 200, 0, 2000); + bool includeTransform = cmd.includeTransform ?? false; + + // NOTE: maxDepth is accepted for forward-compatibility, but current paging mode + // returns a single level (roots or direct children). This keeps payloads bounded. + + List nodes; + string scope; + + GameObject parentGo = ResolveGameObject(cmd.parent, activeScene); + if (cmd.parent == null || cmd.parent.Type == JTokenType.Null) + { + try { McpLog.Info("[ManageScene] get_hierarchy: listing root objects (paged summary)", always: false); } catch { } + nodes = activeScene.GetRootGameObjects().Where(go => go != null).ToList(); + scope = "roots"; + } + else + { + if (parentGo == null) + { + return new ErrorResponse($"Parent GameObject ('{cmd.parent}') not found."); + } + try { McpLog.Info($"[ManageScene] get_hierarchy: listing children of '{parentGo.name}' (paged summary)", always: false); } catch { } + nodes = new List(parentGo.transform.childCount); + foreach (Transform child in parentGo.transform) + { + if (child != null) nodes.Add(child.gameObject); + } + scope = "children"; + } + + int total = nodes.Count; + if (resolvedCursor > total) resolvedCursor = total; + int end = Mathf.Min(total, resolvedCursor + effectiveTake); + + var items = new List(Mathf.Max(0, end - resolvedCursor)); + for (int i = resolvedCursor; i < end; i++) + { + var go = nodes[i]; + if (go == null) continue; + items.Add(BuildGameObjectSummary(go, includeTransform, resolvedMaxChildrenPerNode)); + } + + bool truncated = end < total; + string nextCursor = truncated ? end.ToString() : null; + + var payload = new + { + scope = scope, + cursor = resolvedCursor, + pageSize = effectiveTake, + next_cursor = nextCursor, + truncated = truncated, + total = total, + items = items, + }; + + var resp = new SuccessResponse($"Retrieved hierarchy page for scene '{activeScene.name}'.", payload); + try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } + return resp; + } + catch (Exception e) + { + try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } + return new ErrorResponse($"Error getting scene hierarchy: {e.Message}"); + } + } + + private static GameObject ResolveGameObject(JToken targetToken, Scene activeScene) + { + if (targetToken == null || targetToken.Type == JTokenType.Null) return null; + + try + { + if (targetToken.Type == JTokenType.Integer || int.TryParse(targetToken.ToString(), out _)) + { + if (int.TryParse(targetToken.ToString(), out int id)) + { + var obj = EditorUtility.InstanceIDToObject(id); + if (obj is GameObject go) return go; + if (obj is Component c) return c.gameObject; + } + } + } + catch { } + + string s = targetToken.ToString(); + if (string.IsNullOrEmpty(s)) return null; + + // Path-based find (e.g., "Root/Child/GrandChild") + if (s.Contains("/")) + { + try + { + var ids = GameObjectLookup.SearchGameObjects("by_path", s, includeInactive: true, maxResults: 1); + if (ids.Count > 0) + { + var byPath = GameObjectLookup.FindById(ids[0]); + if (byPath != null) return byPath; + } + } + catch { } + } + + // Name-based find (first match, includes inactive) + try + { + var all = activeScene.GetRootGameObjects(); + foreach (var root in all) + { + if (root == null) continue; + if (root.name == s) return root; + var trs = root.GetComponentsInChildren(includeInactive: true); + foreach (var t in trs) + { + if (t != null && t.gameObject != null && t.gameObject.name == s) return t.gameObject; + } + } + } + catch { } + + return null; + } + + private static object BuildGameObjectSummary(GameObject go, bool includeTransform, int maxChildrenPerNode) + { + if (go == null) return null; + + int childCount = 0; + try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { } + bool childrenTruncated = childCount > 0; // We do not inline children in summary mode. + + // Get component type names (lightweight - no full serialization) + var componentTypes = new List(); + try + { + var components = go.GetComponents(); + if (components != null) + { + foreach (var c in components) + { + if (c != null) + { + componentTypes.Add(c.GetType().Name); + } + } + } + } + catch (Exception ex) + { + McpLog.Debug($"[ManageScene] Failed to enumerate components for '{go.name}': {ex.Message}"); + } + + var d = new Dictionary + { + { "name", go.name }, + { "instanceID", go.GetInstanceID() }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "path", GetGameObjectPath(go) }, + { "childCount", childCount }, + { "childrenTruncated", childrenTruncated }, + { "childrenCursor", childCount > 0 ? "0" : null }, + { "childrenPageSizeDefault", maxChildrenPerNode }, + { "componentTypes", componentTypes }, + }; + + if (includeTransform && go.transform != null) + { + var t = go.transform; + d["transform"] = new + { + position = new[] { t.localPosition.x, t.localPosition.y, t.localPosition.z }, + rotation = new[] { t.localRotation.eulerAngles.x, t.localRotation.eulerAngles.y, t.localRotation.eulerAngles.z }, + scale = new[] { t.localScale.x, t.localScale.y, t.localScale.z }, + }; + } + + return d; + } + + private static string GetGameObjectPath(GameObject go) + { + if (go == null) return string.Empty; + try + { + var names = new Stack(); + Transform t = go.transform; + while (t != null) + { + names.Push(t.name); + t = t.parent; + } + return string.Join("/", names); + } + catch + { + return go.name; + } + } + + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScene.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageScene.cs.meta new file mode 100644 index 0000000..51823bd --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScene.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: b6ddda47f4077e74fbb5092388cefcc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageScene.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScript.cs b/Assets/MCPForUnity/Editor/Tools/ManageScript.cs new file mode 100644 index 0000000..3e98172 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScript.cs @@ -0,0 +1,2672 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using System.Threading; +using System.Security.Cryptography; + +#if USE_ROSLYN +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; +#endif + +#if UNITY_EDITOR +using UnityEditor.Compilation; +#endif + + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles CRUD operations for C# scripts within the Unity project. + /// + /// + /// ROSLYN INSTALLATION GUIDE: + /// To enable advanced syntax validation with Roslyn compiler services: + /// + /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: + /// - Open Package Manager in Unity + /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity + /// + /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: + /// + /// 3. Alternative: Manual DLL installation: + /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies + /// - Place in Assets/Plugins/ folder + /// - Ensure .NET compatibility settings are correct + /// + /// 4. Define USE_ROSLYN symbol: + /// - Go to Player Settings > Scripting Define Symbols + /// - Add "USE_ROSLYN" to enable Roslyn-based validation + /// + /// 5. Restart Unity after installation + /// + /// Note: Without Roslyn, the system falls back to basic structural validation. + /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. + /// + [McpForUnityTool("manage_script", AutoRegister = false)] + public static class ManageScript + { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = AssetPathUtility.NormalizeSeparators(Application.dataPath); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = AssetPathUtility.NormalizeSeparators(relDir ?? "Scripts").Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + + // Handle both "Assets" and "Assets/" prefixes + if (rel.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + rel = string.Empty; + } + else if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + rel = rel.Substring(7); + } + + rel = rel.TrimStart('/'); + + string targetDir = AssetPathUtility.NormalizeSeparators(Path.Combine(assets, rel)); + string full = AssetPathUtility.NormalizeSeparators(Path.GetFullPath(targetDir)); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + while (di != null) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + var atAssets = string.Equals( + di.FullName.Replace('\\', '/'), + assets, + StringComparison.OrdinalIgnoreCase + ); + if (atAssets) break; + di = di.Parent; + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } + /// + /// Main handler for script management actions. + /// + public static object HandleCommand(JObject @params) + { + // Handle null parameters + if (@params == null) + { + return new ErrorResponse("invalid_params", "Parameters cannot be null."); + } + + var p = new ToolParams(@params); + + // Extract and validate required parameters + var actionResult = p.GetRequired("action"); + if (!actionResult.IsSuccess) + { + return new ErrorResponse(actionResult.ErrorMessage); + } + string action = actionResult.Value.ToLowerInvariant(); + + var nameResult = p.GetRequired("name"); + if (!nameResult.IsSuccess) + { + return new ErrorResponse(nameResult.ErrorMessage); + } + string name = nameResult.Value; + + // Optional parameters + string path = p.Get("path"); // Relative to Assets/ + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = p.GetBool("contentsEncoded", false); + if (contentsEncoded && p.Has("encodedContents")) + { + try + { + contents = DecodeBase64(p.Get("encodedContents")); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to decode script contents: {e.Message}"); + } + } + else + { + contents = p.Get("contents"); + } + + string scriptType = p.Get("scriptType"); // For templates/validation + string namespaceName = p.Get("namespace"); // For organizing code + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) + { + return new ErrorResponse( + $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) + { + return new ErrorResponse($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + } + + // Construct file paths + string scriptFileName = $"{name}.cs"; + string fullPath = Path.Combine(fullPathDir, scriptFileName); + string relativePath = AssetPathUtility.NormalizeSeparators(Path.Combine(relPathSafeDir, scriptFileName)); + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return new ErrorResponse( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateScript( + fullPath, + relativePath, + name, + contents, + scriptType, + namespaceName + ); + case "read": + McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); + case "update": + McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + return UpdateScript(fullPath, relativePath, name, contents); + case "delete": + return DeleteScript(fullPath, relativePath); + case "apply_text_edits": + { + var textEdits = p.GetRaw("edits") as JArray; + string precondition = p.Get("precondition_sha256"); + // Respect optional options (guard type before indexing) + var optionsObj = p.GetRaw("options") as JObject; + string refreshOpt = optionsObj?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = optionsObj?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); + } + case "validate": + { + string level = p.Get("level", "standard").ToLowerInvariant(); + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + var m = Regex.Match( + s, + @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", + RegexOptions.CultureInvariant | RegexOptions.Multiline, + TimeSpan.FromMilliseconds(250) + ); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? new SuccessResponse("Validation completed.", result) + : new ErrorResponse("Validation failed.", result); + } + case "edit": + McpLog.Warn("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var structEdits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, structEdits, options); + case "get_sha": + { + try + { + if (!File.Exists(fullPath)) + return new ErrorResponse($"Script not found at '{relativePath}'."); + + string text = File.ReadAllText(fullPath); + string sha = ComputeSha256(text); + var fi = new FileInfo(fullPath); + long lengthBytes; + try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } + catch { lengthBytes = fi.Exists ? fi.Length : 0; } + var data = new + { + uri = $"mcpforunity://path/{relativePath}", + path = relativePath, + sha256 = sha, + lengthBytes, + lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty + }; + return new SuccessResponse($"SHA computed for '{relativePath}'.", data); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to compute SHA: {ex.Message}"); + } + } + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateScript( + string fullPath, + string relativePath, + string name, + string contents, + string scriptType, + string namespaceName + ) + { + // Check if script already exists + if (File.Exists(fullPath)) + { + return new ErrorResponse( + $"Script already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block creation + McpLog.Warn($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Atomic create without BOM; schedule refresh after reply + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + try + { + File.Move(tmp, fullPath); + } + catch (IOException) + { + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } + + var uri = $"mcpforunity://path/{relativePath}"; + var ok = new SuccessResponse( + $"Script '{name}.cs' created successfully at '{relativePath}'.", + new { uri, scheduledRefresh = false } + ); + + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + + return ok; + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create script '{relativePath}': {e.Message}"); + } + } + + private static object ReadScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse($"Script not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"mcpforunity://path/{relativePath}"; + var responseData = new + { + uri, + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return new SuccessResponse( + $"Script '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to read script '{relativePath}': {e.Message}"); + } + } + + private static object UpdateScript( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse( + $"Script not found at '{relativePath}'. Use 'create' action to add a new script." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return new ErrorResponse("Content is required for the 'update' action."); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block update + McpLog.Warn($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Safe write with atomic replace when available, without BOM + var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (IOException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"mcpforunity://path/{relativePath}"; + var ok = new SuccessResponse( + $"Script '{name}.cs' updated successfully at '{relativePath}'.", + new { uri, path = relativePath, scheduledRefresh = true } + ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; + } + catch (Exception e) + { + return new ErrorResponse($"Failed to update script '{relativePath}': {e.Message}"); + } + } + + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private const int MaxEditPayloadBytes = 64 * 1024; + + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits, + string preconditionSha256, + string refreshModeFromCaller = null, + string validateMode = null) + { + if (!File.Exists(fullPath)) + return new ErrorResponse($"Script not found at '{relativePath}'."); + // Refuse edits if the target or any ancestor is a symlink + try + { + var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); + while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + return new ErrorResponse("Refusing to edit a symlinked script path."); + di = di.Parent; + } + } + catch + { + // If checking attributes fails, proceed without the symlink guard + } + if (edits == null || edits.Count == 0) + return new ErrorResponse("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } + + // Require precondition to avoid drift on large files + string currentSha = ComputeSha256(original); + if (string.IsNullOrEmpty(preconditionSha256)) + return new ErrorResponse("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) + return new ErrorResponse("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + long totalBytes = 0; + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return new ErrorResponse($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return new ErrorResponse($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } + } + catch (Exception ex) + { + return new ErrorResponse($"Invalid edit payload: {ex.Message}"); + } + } + + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present + // Find first top-level using (supports alias, static, and dotted namespaces) + var mUsing = System.Text.RegularExpressions.Regex.Match( + original, + @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", + System.Text.RegularExpressions.RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(2) + ); + if (mUsing.Success) + { + headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); + } + foreach (var sp in spans) + { + if (sp.start < headerBoundary) + { + return new ErrorResponse("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + } + } + + // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method + if (spans.Count == 1) + { + var sp = spans[0]; + // Heuristic: around the start of the edit, try to match a method header in original + int searchStart = Math.Max(0, sp.start - 200); + int searchEnd = Math.Min(original.Length, sp.start + 200); + string slice = original.Substring(searchStart, searchEnd - searchStart); + var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); + var mh = rx.Match(slice); + if (mh.Success) + { + string methodName = mh.Groups[1].Value; + // Find class span containing the edit + if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) + { + if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) + { + // If the edit overlaps the method span significantly, treat as replace_method + if (sp.start <= mStart + 2 && sp.end >= mStart + 1) + { + var structEdits = new JArray(); + + // Apply the edit to get a candidate string, then recompute method span on the edited text + string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string replacementText; + if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) + && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) + { + replacementText = candidate.Substring(m2Start, m2Len); + } + else + { + // Fallback: adjust method start by the net delta if the edit was before the method + int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); + int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); + adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); + + // If the edit was within the original method span, adjust the length by the delta within-method + int withinMethodDelta = 0; + if (sp.start >= mStart && sp.start <= mStart + mLen) + { + withinMethodDelta = delta; + } + int adjustedLen = mLen + withinMethodDelta; + adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); + replacementText = candidate.Substring(adjustedStart, adjustedLen); + } + + var op = new JObject + { + ["mode"] = "replace_method", + ["className"] = name, + ["methodName"] = methodName, + ["replacement"] = replacementText + }; + structEdits.Add(op); + // Reuse structured path + return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" }); + } + } + } + } + } + + if (totalBytes > MaxEditPayloadBytes) + { + return new ErrorResponse("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + { + var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + + string working = original; + bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); + bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); + foreach (var sp in spans) + { + string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + if (relaxed) + { + // Scoped balance check: validate just around the changed region to avoid false positives + int originalLength = sp.end - sp.start; + int newLength = sp.text?.Length ?? 0; + int endPos = sp.start + newLength; + if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) + { + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + } + } + working = next; + } + + // No-op guard: if resulting text is identical, avoid writes and return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + string noChangeSha = ComputeSha256(original); + return new SuccessResponse( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + uri = $"mcpforunity://path/{relativePath}", + path = relativePath, + editsApplied = 0, + no_op = true, + sha256 = noChangeSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Always check final structural balance regardless of relaxed mode + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return new ErrorResponse(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); + } + +#if USE_ROSLYN + if (!syntaxOnly) + { + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + int firstLine = diagnostics[0].line; + int startLineRos = Math.Max(1, firstLine - 5); + int endLineRos = firstLine + 5; + return new ErrorResponse("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); + } + + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } + } +#endif + + string newSha = ComputeSha256(working); + + // Atomic write and schedule refresh + try + { + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + // Respect refresh mode: immediate vs debounced + bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || + string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); + if (immediate) + { + McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + else + { + McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + + return new SuccessResponse( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + uri = $"mcpforunity://path/{relativePath}", + path = relativePath, + editsApplied = spans.Count, + sha256 = newSha, + scheduledRefresh = !immediate + } + ); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } + } + index = -1; + return false; + } + + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + + // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context + private static bool CheckScopedBalance(string text, int start, int end) + { + start = Math.Max(0, Math.Min(text.Length, start)); + end = Math.Max(start, Math.Min(text.Length, end)); + int brace = 0, paren = 0, bracket = 0; + bool inStr = false, inChr = false, esc = false; + for (int i = start; i < end; i++) + { + char c = text[i]; + char n = (i + 1 < end) ? text[i + 1] : '\0'; + if (inStr) + { + if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; + } + if (inChr) + { + if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; + } + if (c == '"') { inStr = true; esc = false; continue; } + if (c == '\'') { inChr = true; esc = false; continue; } + if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } + if (c == '{') brace++; + else if (c == '}') brace--; + else if (c == '(') paren++; + else if (c == ')') paren--; + else if (c == '[') bracket++; else if (c == ']') bracket--; + // Allow temporary negative balance - will check tolerance at end + } + return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region + } + + private static object DeleteScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse($"Script not found at '{relativePath}'. Cannot delete."); + } + + try + { + // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) + bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); + if (deleted) + { + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + return new SuccessResponse( + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } + ); + } + else + { + // Fallback or error if MoveAssetToTrash fails + return new ErrorResponse( + $"Failed to move script '{relativePath}' to trash. It might be locked or in use." + ); + } + } + catch (Exception e) + { + return new ErrorResponse($"Error deleting script '{relativePath}': {e.Message}"); + } + } + + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return new ErrorResponse($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return new ErrorResponse("Refusing to edit a symlinked script path."); + } + catch + { + // ignore failures checking attributes and proceed + } + if (edits == null || edits.Count == 0) + return new ErrorResponse("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return new ErrorResponse($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return new ErrorResponse("replace_class requires 'className'."); + if (replacement == null) + return new ErrorResponse("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return new ErrorResponse($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return new ErrorResponse($"Replacement snippet invalid: {vErr}"); + + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return new ErrorResponse("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return new ErrorResponse($"delete_class failed: {why}"); + + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("replace_method requires 'methodName'."); + if (replacement == null) return new ErrorResponse("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return new ErrorResponse($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return new ErrorResponse($"replace_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return new ErrorResponse("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return new ErrorResponse($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return new ErrorResponse($"delete_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + // Harden: refuse empty replacement for inserts + if (snippet == null || snippet.Trim().Length == 0) + return new ErrorResponse("insert_method requires a non-empty 'replacement' text."); + + if (string.IsNullOrWhiteSpace(className)) return new ErrorResponse("insert_method requires 'className'."); + if (snippet == null) return new ErrorResponse("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return new ErrorResponse($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return new ErrorResponse("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return new ErrorResponse($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return new ErrorResponse($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + break; + } + + case "anchor_insert": + { + string anchor = op.Value("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("text") ?? ExtractReplacement(op); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return new ErrorResponse("anchor_insert requires non-empty 'text'."); + + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return new ErrorResponse($"anchor_insert: anchor not found: {anchor}"); + int insAt = position == "after" ? m.Index + m.Length : m.Index; + string norm = NormalizeNewlines(text); + if (!norm.EndsWith("\n")) + { + norm += "\n"; + } + + // Duplicate guard: if identical snippet already exists within this class, skip insert + if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + { + string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); + if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + { + // Do not insert duplicate; treat as no-op + break; + } + } + if (applySequentially) + { + working = working.Insert(insAt, norm); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, norm)); + } + } + catch (Exception ex) + { + return new ErrorResponse($"anchor_insert failed: {ex.Message}"); + } + break; + } + + case "anchor_delete": + { + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_delete requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}"); + int delAt = m.Index; + int delLen = m.Length; + if (applySequentially) + { + working = working.Remove(delAt, delLen); + appliedCount++; + } + else + { + replacements.Add((delAt, delLen, string.Empty)); + } + } + catch (Exception ex) + { + return new ErrorResponse($"anchor_delete failed: {ex.Message}"); + } + break; + } + + case "anchor_replace": + { + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return new ErrorResponse("anchor_replace requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}"); + int at = m.Index; + int len = m.Length; + string norm = NormalizeNewlines(replacement); + if (applySequentially) + { + working = working.Remove(at, len).Insert(at, norm); + appliedCount++; + } + else + { + replacements.Add((at, len, norm)); + } + } + catch (Exception ex) + { + return new ErrorResponse($"anchor_replace failed: {ex.Message}"); + } + break; + } + + default: + return new ErrorResponse($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); + } + } + + if (!applySequentially) + { + if (HasOverlaps(replacements)) + { + var ordered = replacements.OrderByDescending(r => r.start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + if (ordered[i].start + ordered[i].length > ordered[i - 1].start) + { + var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; + return new ErrorResponse("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + return new ErrorResponse("overlap", new { status = "overlap" }); + } + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } + + // Guard against structural imbalance before validation + if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) + return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); + + // No-op guard for structured edits: if text unchanged, return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + var sameSha = ComputeSha256(original); + return new SuccessResponse( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + path = relativePath, + uri = $"mcpforunity://path/{relativePath}", + editsApplied = 0, + no_op = true, + sha256 = sameSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return new ErrorResponse("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); + else if (errors != null && errors.Length > 0) + McpLog.Warn($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + // Persist changes atomically (no BOM), then compute/return new file SHA + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + var backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + var newSha = ComputeSha256(working); + var ok = new SuccessResponse( + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new + { + path = relativePath, + uri = $"mcpforunity://path/{relativePath}", + editsApplied = appliedCount, + scheduledRefresh = !immediate, + sha256 = newSha + } + ); + + if (immediate) + { + McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return new ErrorResponse($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 1) Find the method header using a stricter regex (allows optional attributes above) + string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); + string namePattern = Regex.Escape(methodName); + // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so + // we can safely embed the signature inside our own parenthesis group without duplicating. + string paramsPattern; + if (string.IsNullOrEmpty(parametersSignature)) + { + paramsPattern = @"[\s\S]*?"; // permissive when not specified + } + else + { + string ps = parametersSignature.Trim(); + if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) + { + ps = ps.Substring(1, ps.Length - 2); + } + // Escape literal text of the signature + paramsPattern = Regex.Escape(ps); + } + string pattern = + @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (; ; ) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + + /// + /// Generates basic C# script content based on name and type. + /// + private static string GenerateDefaultScriptContent( + string name, + string scriptType, + string namespaceName + ) + { + string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; + string classDeclaration; + string body = + "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; + + string baseClass = ""; + if (!string.IsNullOrEmpty(scriptType)) + { + if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) + baseClass = " : MonoBehaviour"; + else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) + { + baseClass = " : ScriptableObject"; + body = ""; // ScriptableObjects don't usually need Start/Update + } + else if ( + scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) + || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) + ) + { + usingStatements += "using UnityEditor;\n"; + if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) + baseClass = " : Editor"; + else + baseClass = " : EditorWindow"; + body = ""; // Editor scripts have different structures + } + // Add more types as needed + } + + classDeclaration = $"public class {name}{baseClass}"; + + string fullContent = $"{usingStatements}\n"; + bool useNamespace = !string.IsNullOrEmpty(namespaceName); + + if (useNamespace) + { + fullContent += $"namespace {namespaceName}\n{{\n"; + // Indent class and body if using namespace + classDeclaration = " " + classDeclaration; + body = string.Join("\n", body.Split('\n').Select(line => " " + line)); + } + + fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; + + if (useNamespace) + { + fullContent += "\n}"; // Close namespace + } + + return fullContent.Trim() + "\n"; // Ensure a trailing newline + } + + /// + /// Gets the validation level from the GUI settings + /// + private static ValidationLevel GetValidationLevelFromGUI() + { + int savedLevel = EditorPrefs.GetInt(EditorPrefKeys.ValidationLevel, (int)ValidationLevel.Standard); + return (ValidationLevel)Mathf.Clamp(savedLevel, 0, 3); + } + + /// + /// Validates C# script syntax using multiple validation layers. + /// + private static bool ValidateScriptSyntax(string contents) + { + return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); + } + + /// + /// Advanced syntax validation with detailed diagnostics and configurable strictness. + /// + private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) + { + var errorList = new System.Collections.Generic.List(); + errors = null; + + if (string.IsNullOrEmpty(contents)) + { + return true; // Empty content is valid + } + + // Basic structural validation + if (!ValidateBasicStructure(contents, errorList)) + { + errors = errorList.ToArray(); + return false; + } + +#if USE_ROSLYN + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) + { + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } + } +#endif + + // Unity-specific validation + if (level >= ValidationLevel.Standard) + { + ValidateScriptSyntaxUnity(contents, errorList); + } + + // Semantic analysis for common issues + if (level >= ValidationLevel.Comprehensive) + { + ValidateSemanticRules(contents, errorList); + } + +#if USE_ROSLYN + // Full semantic compilation validation for Strict level + if (level == ValidationLevel.Strict) + { + if (!ValidateScriptSemantics(contents, errorList)) + { + errors = errorList.ToArray(); + return false; // Strict level fails on any semantic errors + } + } +#endif + + errors = errorList.ToArray(); + return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); + } + + /// + /// Validation strictness levels + /// + private enum ValidationLevel + { + Basic, // Only syntax errors + Standard, // Syntax + Unity best practices + Comprehensive, // All checks + semantic analysis + Strict // Treat all issues as errors + } + + /// + /// Validates basic code structure (braces, quotes, comments) + /// + private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors) + { + bool isValid = true; + int braceBalance = 0; + int parenBalance = 0; + int bracketBalance = 0; + bool inStringLiteral = false; + bool inCharLiteral = false; + bool inSingleLineComment = false; + bool inMultiLineComment = false; + bool escaped = false; + + for (int i = 0; i < contents.Length; i++) + { + char c = contents[i]; + char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; + + // Handle escape sequences + if (escaped) + { + escaped = false; + continue; + } + + if (c == '\\' && (inStringLiteral || inCharLiteral)) + { + escaped = true; + continue; + } + + // Handle comments + if (!inStringLiteral && !inCharLiteral) + { + if (c == '/' && next == '/' && !inMultiLineComment) + { + inSingleLineComment = true; + continue; + } + if (c == '/' && next == '*' && !inSingleLineComment) + { + inMultiLineComment = true; + i++; // Skip next character + continue; + } + if (c == '*' && next == '/' && inMultiLineComment) + { + inMultiLineComment = false; + i++; // Skip next character + continue; + } + } + + if (c == '\n') + { + inSingleLineComment = false; + continue; + } + + if (inSingleLineComment || inMultiLineComment) + continue; + + // Handle string and character literals + if (c == '"' && !inCharLiteral) + { + inStringLiteral = !inStringLiteral; + continue; + } + if (c == '\'' && !inStringLiteral) + { + inCharLiteral = !inCharLiteral; + continue; + } + + if (inStringLiteral || inCharLiteral) + continue; + + // Count brackets and braces + switch (c) + { + case '{': braceBalance++; break; + case '}': braceBalance--; break; + case '(': parenBalance++; break; + case ')': parenBalance--; break; + case '[': bracketBalance++; break; + case ']': bracketBalance--; break; + } + + // Check for negative balances (closing without opening) + if (braceBalance < 0) + { + errors.Add("ERROR: Unmatched closing brace '}'"); + isValid = false; + } + if (parenBalance < 0) + { + errors.Add("ERROR: Unmatched closing parenthesis ')'"); + isValid = false; + } + if (bracketBalance < 0) + { + errors.Add("ERROR: Unmatched closing bracket ']'"); + isValid = false; + } + } + + // Check final balances + if (braceBalance != 0) + { + errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); + isValid = false; + } + if (parenBalance != 0) + { + errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); + isValid = false; + } + if (bracketBalance != 0) + { + errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); + isValid = false; + } + if (inStringLiteral) + { + errors.Add("ERROR: Unterminated string literal"); + isValid = false; + } + if (inCharLiteral) + { + errors.Add("ERROR: Unterminated character literal"); + isValid = false; + } + if (inMultiLineComment) + { + errors.Add("WARNING: Unterminated multi-line comment"); + } + + return isValid; + } + +#if USE_ROSLYN + /// + /// Cached compilation references for performance + /// + private static System.Collections.Generic.List _cachedReferences = null; + private static DateTime _cacheTime = DateTime.MinValue; + private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); + + /// + /// Validates syntax using Roslyn compiler services + /// + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + var diagnostics = syntaxTree.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + string severity = diagnostic.Severity.ToString().ToUpper(); + string message = $"{severity}: {diagnostic.GetMessage()}"; + + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + } + + // Include warnings in comprehensive mode + if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now + { + var location = diagnostic.Location.GetLineSpan(); + if (location.IsValid) + { + message += $" (Line {location.StartLinePosition.Line + 1})"; + } + errors.Add(message); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors + /// + private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List errors) + { + try + { + // Get compilation references with caching + var references = GetCompilationReferences(); + if (references == null || references.Count == 0) + { + errors.Add("WARNING: Could not load compilation references for semantic validation"); + return true; // Don't fail if we can't get references + } + + // Create syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + + // Create compilation with full context + var compilation = CSharpCompilation.Create( + "TempValidation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Get semantic diagnostics - this catches all the issues you mentioned! + var diagnostics = compilation.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + // Include diagnostic ID for better error identification + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + else if (diagnostic.Severity == DiagnosticSeverity.Warning) + { + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Gets compilation references with caching for performance + /// + private static System.Collections.Generic.List GetCompilationReferences() + { + // Check cache validity + if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) + { + return _cachedReferences; + } + + try + { + var references = new System.Collections.Generic.List(); + + // Core .NET assemblies + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib + references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq + references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections + + // Unity assemblies + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine + } + catch (Exception ex) + { + McpLog.Warn($"Could not load UnityEngine assembly: {ex.Message}"); + } + +#if UNITY_EDITOR + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor + } + catch (Exception ex) + { + McpLog.Warn($"Could not load UnityEditor assembly: {ex.Message}"); + } + + // Get Unity project assemblies + try + { + var assemblies = CompilationPipeline.GetAssemblies(); + foreach (var assembly in assemblies) + { + if (File.Exists(assembly.outputPath)) + { + references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); + } + } + } + catch (Exception ex) + { + McpLog.Warn($"Could not load Unity project assemblies: {ex.Message}"); + } +#endif + + // Cache the results + _cachedReferences = references; + _cacheTime = DateTime.Now; + + return references; + } + catch (Exception ex) + { + McpLog.Error($"Failed to get compilation references: {ex.Message}"); + return new System.Collections.Generic.List(); + } + } +#else + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + // Fallback when Roslyn is not available + return true; + } +#endif + + /// + /// Validates Unity-specific coding rules and best practices + /// //TODO: Naive Unity Checks and not really yield any results, need to be improved + /// + private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List errors) + { + // Check for common Unity anti-patterns + if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) + { + errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); + } + + if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) + { + errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); + } + + // Check for proper MonoBehaviour usage + if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); + } + + // Check for SerializeField usage + if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); + } + + // Check for proper coroutine usage + if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) + { + errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); + } + + // Check for Update without FixedUpdate for physics + if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) + { + errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); + } + + // Check for missing null checks on Unity objects + if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) + { + errors.Add("WARNING: Consider null checking GetComponent results"); + } + + // Check for proper event function signatures + if (contents.Contains("void Start(") && !contents.Contains("void Start()")) + { + errors.Add("WARNING: Start() should not have parameters"); + } + + if (contents.Contains("void Update(") && !contents.Contains("void Update()")) + { + errors.Add("WARNING: Update() should not have parameters"); + } + + // Check for inefficient string operations + if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) + { + errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); + } + } + + /// + /// Validates semantic rules and common coding issues + /// + private static void ValidateSemanticRules(string contents, System.Collections.Generic.List errors) + { + // Check for potential memory leaks + if (contents.Contains("new ") && contents.Contains("Update()")) + { + errors.Add("WARNING: Creating objects in Update() may cause memory issues"); + } + + // Check for magic numbers + var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var matches = magicNumberPattern.Matches(contents); + if (matches.Count > 5) + { + errors.Add("WARNING: Consider using named constants instead of magic numbers"); + } + + // Check for long methods (simple line count check) + var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var methodMatches = methodPattern.Matches(contents); + foreach (Match match in methodMatches) + { + int startIndex = match.Index; + int braceCount = 0; + int lineCount = 0; + bool inMethod = false; + + for (int i = startIndex; i < contents.Length; i++) + { + if (contents[i] == '{') + { + braceCount++; + inMethod = true; + } + else if (contents[i] == '}') + { + braceCount--; + if (braceCount == 0 && inMethod) + break; + } + else if (contents[i] == '\n' && inMethod) + { + lineCount++; + } + } + + if (lineCount > 50) + { + errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); + break; // Only report once + } + } + + // Check for proper exception handling + if (contents.Contains("catch") && contents.Contains("catch()")) + { + errors.Add("WARNING: Empty catch blocks should be avoided"); + } + + // Check for proper async/await usage + if (contents.Contains("async ") && !contents.Contains("await")) + { + errors.Add("WARNING: Async method should contain await or return Task"); + } + + // Check for hardcoded tags and layers + if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) + { + errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); + } + } + + //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) + /// + /// Public method to validate script syntax with configurable validation level + /// Returns detailed validation results including errors and warnings + /// + // public static object ValidateScript(JObject @params) + // { + // string contents = @params["contents"]?.ToString(); + // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; + + // if (string.IsNullOrEmpty(contents)) + // { + // return new ErrorResponse("Contents parameter is required for validation."); + // } + + // // Parse validation level + // ValidationLevel level = ValidationLevel.Standard; + // switch (validationLevel.ToLower()) + // { + // case "basic": level = ValidationLevel.Basic; break; + // case "standard": level = ValidationLevel.Standard; break; + // case "comprehensive": level = ValidationLevel.Comprehensive; break; + // case "strict": level = ValidationLevel.Strict; break; + // default: + // return new ErrorResponse($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // } + + // // Perform validation + // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); + + // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; + // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; + + // var result = new + // { + // isValid = isValid, + // validationLevel = validationLevel, + // errorCount = errors.Length, + // warningCount = warnings.Length, + // errors = errors, + // warnings = warnings, + // summary = isValid + // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") + // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" + // }; + + // if (isValid) + // { + // return new SuccessResponse("Script validation completed successfully.", result); + // } + // else + // { + // return new ErrorResponse("Script validation failed.", result); + // } + // } + } + + // Debounced refresh/compile scheduler to coalesce bursts of edits + static class RefreshDebounce + { + private static int _pending; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // The timestamp of the most recent schedule request. + private static DateTime _lastRequest; + + // Guard to ensure we only have a single ticking callback running. + private static bool _scheduled; + + public static void Schedule(string relPath, TimeSpan window) + { + // Record that work is pending and track the path in a threadsafe way. + Interlocked.Exchange(ref _pending, 1); + lock (_lock) + { + _paths.Add(relPath); + _lastRequest = DateTime.UtcNow; + + // If a debounce timer is already scheduled it will pick up the new request. + if (_scheduled) + return; + + _scheduled = true; + } + + // Kick off a ticking callback that waits until the window has elapsed + // from the last request before performing the refresh. + EditorApplication.delayCall += () => Tick(window); + // Nudge the editor loop so ticks run even if the window is unfocused + EditorApplication.QueuePlayerLoopUpdate(); + } + + private static void Tick(TimeSpan window) + { + bool ready; + lock (_lock) + { + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) + { + _scheduled = false; + } + } + + if (!ready) + { + // Window has not yet elapsed; check again on the next editor tick. + EditorApplication.delayCall += () => Tick(window); + return; + } + + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + { + var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); + AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + } +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + } + } + + static class ManageScriptRefreshHelpers + { + public static string SanitizeAssetsPath(string p) + { + if (string.IsNullOrEmpty(p)) return p; + p = AssetPathUtility.NormalizeSeparators(p).Trim(); + if (p.StartsWith("mcpforunity://path/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("mcpforunity://path/".Length); + while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("Assets/".Length); + if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + p = "Assets/" + p.TrimStart('/'); + return p; + } + + public static void ScheduleScriptRefresh(string relPath) + { + var sp = SanitizeAssetsPath(relPath); + RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); + } + + public static void ImportAndRequestCompile(string relPath, bool synchronous = true) + { + var sp = SanitizeAssetsPath(relPath); + var opts = ImportAssetOptions.ForceUpdate; + if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; + AssetDatabase.ImportAsset(sp, opts); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScript.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageScript.cs.meta new file mode 100644 index 0000000..ef94cc1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScript.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 626d2d44668019a45ae52e9ee066b7ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageScript.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs b/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs new file mode 100644 index 0000000..62d1c19 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs @@ -0,0 +1,1522 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Single tool for ScriptableObject workflows: + /// - action=create: create a ScriptableObject asset (and optionally apply patches) + /// - action=modify: apply serialized property patches to an existing asset + /// + /// Patching is performed via SerializedObject/SerializedProperty paths (Unity-native), not reflection. + /// + [McpForUnityTool("manage_scriptable_object", AutoRegister = false)] + public static class ManageScriptableObject + { + private const string CodeCompilingOrReloading = "compiling_or_reloading"; + private const string CodeInvalidParams = "invalid_params"; + private const string CodeTypeNotFound = "type_not_found"; + private const string CodeInvalidFolderPath = "invalid_folder_path"; + private const string CodeTargetNotFound = "target_not_found"; + private const string CodeAssetCreateFailed = "asset_create_failed"; + + private static readonly HashSet ValidActions = new(StringComparer.OrdinalIgnoreCase) + { + // NOTE: Action strings are normalized by NormalizeAction() (lowercased, '_'/'-' removed), + // so we only need the canonical normalized forms here. + "create", + "createso", + "modify", + "modifyso", + }; + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse(CodeInvalidParams); + } + + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + // Unity is transient; treat as retryable on the client side. + return new ErrorResponse(CodeCompilingOrReloading, new { hint = "retry" }); + } + + // Allow JSON-string parameters for objects/arrays. + JsonUtil.CoerceJsonStringParameter(@params, "target"); + CoerceJsonStringArrayParameter(@params, "patches"); + + string actionRaw = @params["action"]?.ToString(); + if (string.IsNullOrWhiteSpace(actionRaw)) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'action' is required.", validActions = ValidActions.ToArray() }); + } + + string action = NormalizeAction(actionRaw); + if (!ValidActions.Contains(action)) + { + return new ErrorResponse(CodeInvalidParams, new { message = $"Unknown action: '{actionRaw}'.", validActions = ValidActions.ToArray() }); + } + + if (IsCreateAction(action)) + { + return HandleCreate(@params); + } + + return HandleModify(@params); + } + + private static object HandleCreate(JObject @params) + { + string typeName = @params["typeName"]?.ToString() ?? @params["type_name"]?.ToString(); + string folderPath = @params["folderPath"]?.ToString() ?? @params["folder_path"]?.ToString(); + string assetName = @params["assetName"]?.ToString() ?? @params["asset_name"]?.ToString(); + bool overwrite = @params["overwrite"]?.ToObject() ?? false; + + if (string.IsNullOrWhiteSpace(typeName)) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'typeName' is required." }); + } + + if (string.IsNullOrWhiteSpace(folderPath)) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'folderPath' is required." }); + } + + if (string.IsNullOrWhiteSpace(assetName)) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' is required." }); + } + + if (assetName.Contains("/") || assetName.Contains("\\")) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'assetName' must not contain path separators." }); + } + + if (!TryNormalizeFolderPath(folderPath, out var normalizedFolder, out var folderNormalizeError)) + { + return new ErrorResponse(CodeInvalidFolderPath, new { message = folderNormalizeError, folderPath }); + } + + if (!EnsureFolderExists(normalizedFolder, out var folderError)) + { + return new ErrorResponse(CodeInvalidFolderPath, new { message = folderError, folderPath = normalizedFolder }); + } + + var resolvedType = ResolveType(typeName); + if (resolvedType == null || !typeof(ScriptableObject).IsAssignableFrom(resolvedType)) + { + return new ErrorResponse(CodeTypeNotFound, new { message = $"ScriptableObject type not found: '{typeName}'", typeName }); + } + + string fileName = assetName.EndsWith(".asset", StringComparison.OrdinalIgnoreCase) + ? assetName + : assetName + ".asset"; + string desiredPath = $"{normalizedFolder.TrimEnd('/')}/{fileName}"; + string finalPath = overwrite ? desiredPath : AssetDatabase.GenerateUniqueAssetPath(desiredPath); + + ScriptableObject instance; + try + { + instance = ScriptableObject.CreateInstance(resolvedType); + if (instance == null) + { + return new ErrorResponse(CodeAssetCreateFailed, new { message = "CreateInstance returned null.", typeName = resolvedType.FullName }); + } + } + catch (Exception ex) + { + return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, typeName = resolvedType.FullName }); + } + + // GUID-preserving overwrite logic + bool isNewAsset = true; + try + { + if (overwrite) + { + var existingAsset = AssetDatabase.LoadAssetAtPath(finalPath); + if (existingAsset != null && existingAsset.GetType() == resolvedType) + { + // Preserve GUID by overwriting existing asset data in-place + EditorUtility.CopySerialized(instance, existingAsset); + + // Fix for "Main Object Name does not match filename" warning: + // CopySerialized overwrites the name with the (empty) name of the new instance. + // We must restore the correct name to match the filename. + existingAsset.name = Path.GetFileNameWithoutExtension(finalPath); + + UnityEngine.Object.DestroyImmediate(instance); // Destroy temporary instance + instance = existingAsset; // Proceed with patching the existing asset + isNewAsset = false; + + // Mark dirty to ensure changes are picked up + EditorUtility.SetDirty(instance); + } + else if (existingAsset != null) + { + // Type mismatch or not a ScriptableObject - must delete and recreate to change type, losing GUID + // (Or we could warn, but overwrite usually implies replacing) + AssetDatabase.DeleteAsset(finalPath); + } + } + + if (isNewAsset) + { + // Ensure the new instance has the correct name before creating asset to avoid warnings + instance.name = Path.GetFileNameWithoutExtension(finalPath); + AssetDatabase.CreateAsset(instance, finalPath); + } + } + catch (Exception ex) + { + return new ErrorResponse(CodeAssetCreateFailed, new { message = ex.Message, path = finalPath }); + } + + string guid = AssetDatabase.AssetPathToGUID(finalPath); + var patchesToken = @params["patches"]; + object patchResults = null; + var warnings = new List(); + + if (patchesToken is JArray patches && patches.Count > 0) + { + var patchApply = ApplyPatches(instance, patches); + patchResults = patchApply.results; + warnings.AddRange(patchApply.warnings); + } + + EditorUtility.SetDirty(instance); + AssetDatabase.SaveAssets(); + + return new SuccessResponse( + "ScriptableObject created.", + new + { + guid, + path = finalPath, + typeNameResolved = resolvedType.FullName, + patchResults, + warnings = warnings.Count > 0 ? warnings : null + } + ); + } + + private static object HandleModify(JObject @params) + { + if (!TryResolveTarget(@params["target"], out var target, out var targetPath, out var targetGuid, out var err)) + { + return err; + } + + var patchesToken = @params["patches"]; + if (patchesToken == null || patchesToken.Type == JTokenType.Null) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'patches' is required.", targetPath, targetGuid }); + } + + if (patchesToken is not JArray patches) + { + return new ErrorResponse(CodeInvalidParams, new { message = "'patches' must be an array.", targetPath, targetGuid }); + } + + // Phase 5: Dry-run mode - validate patches without applying + bool dryRun = @params["dryRun"]?.ToObject() ?? @params["dry_run"]?.ToObject() ?? false; + + if (dryRun) + { + var validationResults = ValidatePatches(target, patches); + return new SuccessResponse( + "Dry-run validation complete.", + new + { + targetGuid, + targetPath, + targetTypeName = target.GetType().FullName, + dryRun = true, + valid = validationResults.All(r => (bool)r.GetType().GetProperty("ok")?.GetValue(r)), + validationResults + } + ); + } + + var (results, warnings) = ApplyPatches(target, patches); + + return new SuccessResponse( + "Serialized properties patched.", + new + { + targetGuid, + targetPath, + targetTypeName = target.GetType().FullName, + results, + warnings = warnings.Count > 0 ? warnings : null + } + ); + } + + /// + /// Validates patches without applying them (for dry-run mode). + /// Checks that property paths exist and that value types are compatible. + /// + private static List ValidatePatches(UnityEngine.Object target, JArray patches) + { + var results = new List(patches.Count); + var so = new SerializedObject(target); + so.Update(); + + for (int i = 0; i < patches.Count; i++) + { + if (patches[i] is not JObject patchObj) + { + results.Add(new { index = i, propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." }); + continue; + } + + string propertyPath = patchObj["propertyPath"]?.ToString() + ?? patchObj["property_path"]?.ToString() + ?? patchObj["path"]?.ToString(); + string op = (patchObj["op"]?.ToString() ?? "set").Trim(); + + if (string.IsNullOrWhiteSpace(propertyPath)) + { + results.Add(new { index = i, propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" }); + continue; + } + + // Normalize the path + string normalizedPath = NormalizePropertyPath(propertyPath); + string normalizedOp = op.ToLowerInvariant(); + + // For array_resize, check if the array exists + if (normalizedOp == "array_resize") + { + var valueToken = patchObj["value"]; + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = "array_resize requires integer 'value'." }); + continue; + } + + int size = ParamCoercion.CoerceInt(valueToken, -1); + if (size < 0) + { + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = "array_resize requires non-negative integer 'value'." }); + continue; + } + + // Check if the array path exists + string arrayPath = normalizedPath; + if (arrayPath.EndsWith(".Array.size", StringComparison.Ordinal)) + { + arrayPath = arrayPath.Substring(0, arrayPath.Length - ".Array.size".Length); + } + + var arrayProp = so.FindProperty(arrayPath); + if (arrayProp == null) + { + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Array not found: {arrayPath}" }); + continue; + } + + if (!arrayProp.isArray) + { + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Property is not an array: {arrayPath}" }); + continue; + } + + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = true, message = $"Will resize to {size}.", currentSize = arrayProp.arraySize }); + continue; + } + + // For set operations, check if the property exists (or can be auto-grown) + var prop = so.FindProperty(normalizedPath); + + // Check if it's an auto-growable array element path + bool isAutoGrowable = false; + if (prop == null) + { + var match = Regex.Match(normalizedPath, @"^(.+?)\.Array\.data\[(\d+)\]"); + if (match.Success) + { + string arrayPath = match.Groups[1].Value; + var arrayProp = so.FindProperty(arrayPath); + if (arrayProp != null && arrayProp.isArray) + { + isAutoGrowable = true; + // Get the element type info from existing elements or report as growable + int targetIndex = int.Parse(match.Groups[2].Value); + if (arrayProp.arraySize > 0) + { + var sampleElement = arrayProp.GetArrayElementAtIndex(0); + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = true, + message = $"Will auto-grow array from {arrayProp.arraySize} to {targetIndex + 1}.", + elementType = sampleElement?.propertyType.ToString() ?? "unknown" + }); + } + else + { + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = true, + message = $"Will auto-grow empty array to size {targetIndex + 1}." + }); + } + continue; + } + } + } + + if (prop == null && !isAutoGrowable) + { + results.Add(new { index = i, propertyPath = normalizedPath, op, ok = false, message = $"Property not found: {normalizedPath}" }); + continue; + } + + if (prop != null) + { + // Property exists - validate value format for supported complex types + var valueToken = patchObj["value"]; + string valueValidationMsg = null; + bool valueFormatOk = true; + + // Enhanced dry-run: validate value format for AnimationCurve and Quaternion + // Uses shared validators from VectorParsing + if (valueToken != null && valueToken.Type != JTokenType.Null) + { + switch (prop.propertyType) + { + case SerializedPropertyType.AnimationCurve: + valueFormatOk = VectorParsing.ValidateAnimationCurveFormat(valueToken, out valueValidationMsg); + break; + case SerializedPropertyType.Quaternion: + valueFormatOk = VectorParsing.ValidateQuaternionFormat(valueToken, out valueValidationMsg); + break; + } + } + + if (valueFormatOk) + { + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = true, + message = valueValidationMsg ?? "Property found.", + propertyType = prop.propertyType.ToString(), + isArray = prop.isArray + }); + } + else + { + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = false, + message = valueValidationMsg, + propertyType = prop.propertyType.ToString(), + isArray = prop.isArray + }); + } + } + } + + return results; + } + + private static (List results, List warnings) ApplyPatches(UnityEngine.Object target, JArray patches) + { + var warnings = new List(); + var results = new List(patches.Count); + bool anyChanged = false; + + var so = new SerializedObject(target); + so.Update(); + + for (int i = 0; i < patches.Count; i++) + { + if (patches[i] is not JObject patchObj) + { + results.Add(new { propertyPath = "", op = "", ok = false, message = $"Patch at index {i} must be an object." }); + continue; + } + + string propertyPath = patchObj["propertyPath"]?.ToString() + ?? patchObj["property_path"]?.ToString() + ?? patchObj["path"]?.ToString(); + string op = (patchObj["op"]?.ToString() ?? "set").Trim(); + if (string.IsNullOrWhiteSpace(propertyPath)) + { + results.Add(new { propertyPath = propertyPath ?? "", op, ok = false, message = "Missing required field: propertyPath" }); + continue; + } + + if (string.IsNullOrWhiteSpace(op)) + { + op = "set"; + } + + var patchResult = ApplyPatch(so, propertyPath, op, patchObj, out bool changed); + anyChanged |= changed; + results.Add(patchResult); + + // Array resize should be applied immediately so later paths resolve. + if (string.Equals(op, "array_resize", StringComparison.OrdinalIgnoreCase) && changed) + { + so.ApplyModifiedProperties(); + so.Update(); + } + } + + if (anyChanged) + { + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(target); + AssetDatabase.SaveAssets(); + } + + return (results, warnings); + } + + private static object ApplyPatch(SerializedObject so, string propertyPath, string op, JObject patchObj, out bool changed) + { + changed = false; + try + { + // Phase 1.1: Normalize friendly path syntax (e.g., myList[5] → myList.Array.data[5]) + string normalizedPath = NormalizePropertyPath(propertyPath); + string normalizedOp = op.Trim().ToLowerInvariant(); + + switch (normalizedOp) + { + case "array_resize": + return ApplyArrayResize(so, normalizedPath, patchObj, out changed); + case "set": + default: + return ApplySet(so, normalizedPath, patchObj, out changed); + } + } + catch (Exception ex) + { + return new { propertyPath, op, ok = false, message = ex.Message }; + } + } + + /// + /// Normalizes friendly property path syntax to Unity's internal format. + /// Converts bracket notation (e.g., myList[5]) to Unity's Array.data format (myList.Array.data[5]). + /// + private static string NormalizePropertyPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + // Pattern: word[number] where it's not already in .Array.data[number] format + // We need to handle cases like: myList[5], nested.list[0].field, etc. + // But NOT: myList.Array.data[5] (already in Unity format) + + // Replace fieldName[index] with fieldName.Array.data[index] + // But only if it's not already in Array.data format + return Regex.Replace(path, @"(\w+)\[(\d+)\]", m => + { + string fieldName = m.Groups[1].Value; + string index = m.Groups[2].Value; + + // Check if this match is already part of .Array.data[index] pattern + // by checking if the text immediately before the field name is ".Array." + // and the field name is "data" + int matchStart = m.Index; + if (fieldName == "data" && matchStart >= 7) // Length of ".Array." + { + string preceding = path.Substring(matchStart - 7, 7); + if (preceding == ".Array.") + { + // Already in Unity format (e.g., myList.Array.data[0]), return as-is + return m.Value; + } + } + + return $"{fieldName}.Array.data[{index}]"; + }); + } + + /// + /// Ensures an array has sufficient capacity for the given index. + /// Automatically resizes the array if the target index is beyond current bounds. + /// + /// The SerializedObject containing the array + /// The normalized property path (must be in Array.data format) + /// True if the array was resized + /// True if the path is valid for setting, false if it cannot be resolved + private static bool EnsureArrayCapacity(SerializedObject so, string path, out bool resized) + { + resized = false; + + // Match pattern: something.Array.data[N] + var match = Regex.Match(path, @"^(.+?)\.Array\.data\[(\d+)\]"); + if (!match.Success) + { + // Not an array element path, nothing to do + return true; + } + + string arrayPath = match.Groups[1].Value; + if (!int.TryParse(match.Groups[2].Value, out int targetIndex)) + { + return false; + } + + var arrayProp = so.FindProperty(arrayPath); + if (arrayProp == null || !arrayProp.isArray) + { + // Array property not found or not an array + return false; + } + + if (arrayProp.arraySize <= targetIndex) + { + // Need to grow the array + arrayProp.arraySize = targetIndex + 1; + so.ApplyModifiedProperties(); + so.Update(); + resized = true; + } + + return true; + } + + private static object ApplyArrayResize(SerializedObject so, string propertyPath, JObject patchObj, out bool changed) + { + changed = false; + + // Use ParamCoercion for robust int parsing + var valueToken = patchObj["value"]; + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." }; + } + + int newSize = ParamCoercion.CoerceInt(valueToken, -1); + if (newSize < 0) + { + return new { propertyPath, op = "array_resize", ok = false, message = "array_resize requires integer 'value'." }; + } + + newSize = Math.Max(0, newSize); + + // Unity supports resizing either: + // - the array/list property itself (prop.isArray -> prop.arraySize) + // - the synthetic leaf property ".Array.size" (prop.intValue) + // + // Different Unity versions/serialization edge cases can fail to resolve the synthetic leaf via FindProperty + // (or can return different property types), so we keep a "best-effort" fallback: + // - Prefer acting on the requested path if it resolves. + // - If the requested path doesn't resolve, try to resolve the *array property* and set arraySize directly. + SerializedProperty prop = so.FindProperty(propertyPath); + SerializedProperty arrayProp = null; + if (propertyPath.EndsWith(".Array.size", StringComparison.Ordinal)) + { + // Caller explicitly targeted the synthetic leaf. Resolve the parent array property as a fallback + // (Unity sometimes fails to resolve the synthetic leaf in certain serialization contexts). + var arrayPath = propertyPath.Substring(0, propertyPath.Length - ".Array.size".Length); + arrayProp = so.FindProperty(arrayPath); + } + else + { + // Caller targeted either the array property itself (e.g., "items") or some other property. + // If it's already an array, we can resize it directly. Otherwise, we attempt to resolve + // a synthetic ".Array.size" leaf as a convenience, which some clients may pass. + arrayProp = prop != null && prop.isArray ? prop : so.FindProperty(propertyPath + ".Array.size"); + } + + if (prop == null) + { + // If we failed to find the direct property but we *can* find the array property, use that. + if (arrayProp != null && arrayProp.isArray) + { + if (arrayProp.arraySize != newSize) + { + arrayProp.arraySize = newSize; + changed = true; + } + return new + { + propertyPath, + op = "array_resize", + ok = true, + resolvedPropertyType = "Array", + message = $"Set array size to {newSize}." + }; + } + + return new { propertyPath, op = "array_resize", ok = false, message = $"Property not found: {propertyPath}" }; + } + + // Unity may represent ".Array.size" as either Integer or ArraySize depending on version. + if ((prop.propertyType == SerializedPropertyType.Integer || prop.propertyType == SerializedPropertyType.ArraySize) + && propertyPath.EndsWith(".Array.size", StringComparison.Ordinal)) + { + // We successfully resolved the synthetic leaf; write the size through its intValue. + if (prop.intValue != newSize) + { + prop.intValue = newSize; + changed = true; + } + return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = $"Set array size to {newSize}." }; + } + + if (prop.isArray) + { + // We resolved the array property itself; write through arraySize. + if (prop.arraySize != newSize) + { + prop.arraySize = newSize; + changed = true; + } + return new { propertyPath, op = "array_resize", ok = true, resolvedPropertyType = "Array", message = $"Set array size to {newSize}." }; + } + + return new { propertyPath, op = "array_resize", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = $"Property is not an array or array-size field: {propertyPath}" }; + } + + private static object ApplySet(SerializedObject so, string propertyPath, JObject patchObj, out bool changed) + { + changed = false; + + // Phase 1.2: Auto-resize arrays if targeting an index beyond current bounds + if (!EnsureArrayCapacity(so, propertyPath, out bool arrayResized)) + { + // Could not resolve the array path - try to find the property anyway for a better error message + var checkProp = so.FindProperty(propertyPath); + if (checkProp == null) + { + // Try to provide helpful context about what went wrong + var arrayMatch = Regex.Match(propertyPath, @"^(.+?)\.Array\.data\[(\d+)\]"); + if (arrayMatch.Success) + { + string arrayPath = arrayMatch.Groups[1].Value; + var arrayProp = so.FindProperty(arrayPath); + if (arrayProp == null) + { + return new { propertyPath, op = "set", ok = false, message = $"Array property not found: {arrayPath}" }; + } + if (!arrayProp.isArray) + { + return new { propertyPath, op = "set", ok = false, message = $"Property is not an array: {arrayPath}" }; + } + } + return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" }; + } + } + + var prop = so.FindProperty(propertyPath); + if (prop == null) + { + return new { propertyPath, op = "set", ok = false, message = $"Property not found: {propertyPath}" }; + } + + // Track if we resized - this counts as a change + if (arrayResized) + { + changed = true; + } + + if (prop.propertyType == SerializedPropertyType.ObjectReference) + { + var refObj = patchObj["ref"] as JObject; + var objRefValue = patchObj["value"]; + UnityEngine.Object newRef = null; + string refGuid = refObj?["guid"]?.ToString(); + string refPath = refObj?["path"]?.ToString(); + string resolveMethod = "explicit"; + + if (refObj == null && objRefValue?.Type == JTokenType.Null) + { + // Explicit null - clear the reference + newRef = null; + resolveMethod = "cleared"; + } + else if (!string.IsNullOrEmpty(refGuid) || !string.IsNullOrEmpty(refPath)) + { + // Traditional ref object with guid or path + string resolvedPath = !string.IsNullOrEmpty(refGuid) + ? AssetDatabase.GUIDToAssetPath(refGuid) + : AssetPathUtility.SanitizeAssetPath(refPath); + + if (!string.IsNullOrEmpty(resolvedPath)) + { + newRef = AssetDatabase.LoadAssetAtPath(resolvedPath); + } + resolveMethod = !string.IsNullOrEmpty(refGuid) ? "ref.guid" : "ref.path"; + } + else if (objRefValue?.Type == JTokenType.String) + { + // Phase 4: GUID shorthand - allow plain string value + string strVal = objRefValue.ToString(); + + // Check if it's a GUID (32 hex characters, no dashes) + if (Regex.IsMatch(strVal, @"^[0-9a-fA-F]{32}$")) + { + string guidPath = AssetDatabase.GUIDToAssetPath(strVal); + if (!string.IsNullOrEmpty(guidPath)) + { + newRef = AssetDatabase.LoadAssetAtPath(guidPath); + resolveMethod = "guid-shorthand"; + } + } + // Check if it looks like an asset path + else if (strVal.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || + strVal.Contains("/")) + { + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(strVal); + newRef = AssetDatabase.LoadAssetAtPath(sanitizedPath); + resolveMethod = "path-shorthand"; + } + } + + if (prop.objectReferenceValue != newRef) + { + prop.objectReferenceValue = newRef; + changed = true; + } + + string refMessage = newRef == null ? "Cleared reference." : $"Set reference ({resolveMethod})."; + return new { propertyPath, op = "set", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = refMessage }; + } + + var valueToken = patchObj["value"]; + if (valueToken == null) + { + return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = "Missing required field: value" }; + } + + bool ok = TrySetValue(prop, valueToken, out string message); + changed = ok; + return new { propertyPath, op = "set", ok, resolvedPropertyType = prop.propertyType.ToString(), message }; + } + + private static bool TrySetValue(SerializedProperty prop, JToken valueToken, out string message) + { + return TrySetValueRecursive(prop, valueToken, out message, 0); + } + + /// + /// Recursively sets values on SerializedProperties, supporting bulk array and object mapping. + /// + /// The property to set + /// The JSON value + /// Output message describing the result + /// Current recursion depth (for safety limits) + private static bool TrySetValueRecursive(SerializedProperty prop, JToken valueToken, out string message, int depth) + { + message = null; + const int MaxRecursionDepth = 20; + + if (depth > MaxRecursionDepth) + { + message = $"Maximum recursion depth ({MaxRecursionDepth}) exceeded. Check for circular references."; + return false; + } + + try + { + // Phase 3.1: Handle bulk array mapping - JArray value for array/list properties + if (prop.isArray && prop.propertyType != SerializedPropertyType.String && valueToken is JArray jArray) + { + // Resize the array to match the JSON array + prop.arraySize = jArray.Count; + + // Get the SerializedObject and apply so we can access elements + var so = prop.serializedObject; + so.ApplyModifiedProperties(); + so.Update(); + + int successCount = 0; + var errors = new List(); + + for (int i = 0; i < jArray.Count; i++) + { + var elementProp = prop.GetArrayElementAtIndex(i); + if (elementProp == null) + { + errors.Add($"Could not get element at index {i}"); + continue; + } + + if (TrySetValueRecursive(elementProp, jArray[i], out string elemMessage, depth + 1)) + { + successCount++; + } + else + { + errors.Add($"[{i}]: {elemMessage}"); + } + } + + so.ApplyModifiedProperties(); + + if (errors.Count > 0) + { + message = $"Set {successCount}/{jArray.Count} elements. Errors: {string.Join("; ", errors)}"; + return successCount > 0; // Partial success + } + + message = $"Set array with {jArray.Count} elements."; + return true; + } + + // Phase 3.2: Handle bulk object mapping - JObject value for Generic (struct/class) properties + if (prop.propertyType == SerializedPropertyType.Generic && !prop.isArray && valueToken is JObject jObj) + { + int successCount = 0; + var errors = new List(); + var so = prop.serializedObject; + + foreach (var kvp in jObj) + { + string childPath = prop.propertyPath + "." + kvp.Key; + var childProp = so.FindProperty(childPath); + + if (childProp == null) + { + errors.Add($"Property not found: {kvp.Key}"); + continue; + } + + if (TrySetValueRecursive(childProp, kvp.Value, out string childMessage, depth + 1)) + { + successCount++; + } + else + { + errors.Add($"{kvp.Key}: {childMessage}"); + } + } + + so.ApplyModifiedProperties(); + + if (errors.Count > 0) + { + message = $"Set {successCount}/{jObj.Count} fields. Errors: {string.Join("; ", errors)}"; + return successCount > 0; // Partial success + } + + message = $"Set struct/class with {jObj.Count} fields."; + return true; + } + + // Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color + // Using shared helpers from ParamCoercion and VectorParsing + switch (prop.propertyType) + { + case SerializedPropertyType.Integer: + // Use ParamCoercion for robust int parsing + int intVal = ParamCoercion.CoerceInt(valueToken, int.MinValue); + if (intVal == int.MinValue && valueToken?.Type != JTokenType.Integer) + { + // Double-check: if it's actually int.MinValue or failed to parse + if (valueToken == null || valueToken.Type == JTokenType.Null || + (valueToken.Type == JTokenType.String && !int.TryParse(valueToken.ToString(), out _))) + { + message = "Expected integer value."; + return false; + } + } + prop.intValue = intVal; + message = "Set int."; + return true; + + case SerializedPropertyType.Boolean: + // Use ParamCoercion for robust bool parsing (handles "true", "1", "yes", etc.) + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Expected boolean value."; + return false; + } + bool boolVal = ParamCoercion.CoerceBool(valueToken, false); + // Verify it actually looked like a bool + if (valueToken.Type != JTokenType.Boolean) + { + string strVal = valueToken.ToString().Trim().ToLowerInvariant(); + if (strVal != "true" && strVal != "false" && strVal != "1" && strVal != "0" && + strVal != "yes" && strVal != "no" && strVal != "on" && strVal != "off") + { + message = "Expected boolean value."; + return false; + } + } + prop.boolValue = boolVal; + message = "Set bool."; + return true; + + case SerializedPropertyType.Float: + // Use ParamCoercion for robust float parsing + float floatVal = ParamCoercion.CoerceFloat(valueToken, float.NaN); + if (float.IsNaN(floatVal)) + { + message = "Expected float value."; + return false; + } + prop.floatValue = floatVal; + message = "Set float."; + return true; + + case SerializedPropertyType.String: + prop.stringValue = valueToken.Type == JTokenType.Null ? null : valueToken.ToString(); + message = "Set string."; + return true; + + case SerializedPropertyType.Enum: + return TrySetEnum(prop, valueToken, out message); + + case SerializedPropertyType.Vector2: + // Use VectorParsing for Vector2 + var v2 = VectorParsing.ParseVector2(valueToken); + if (v2 == null) + { + message = "Expected Vector2 (array or object)."; + return false; + } + prop.vector2Value = v2.Value; + message = "Set Vector2."; + return true; + + case SerializedPropertyType.Vector3: + // Use VectorParsing for Vector3 + var v3 = VectorParsing.ParseVector3(valueToken); + if (v3 == null) + { + message = "Expected Vector3 (array or object)."; + return false; + } + prop.vector3Value = v3.Value; + message = "Set Vector3."; + return true; + + case SerializedPropertyType.Vector4: + // Use VectorParsing for Vector4 + var v4 = VectorParsing.ParseVector4(valueToken); + if (v4 == null) + { + message = "Expected Vector4 (array or object)."; + return false; + } + prop.vector4Value = v4.Value; + message = "Set Vector4."; + return true; + + case SerializedPropertyType.Color: + // Use VectorParsing for Color + var col = VectorParsing.ParseColor(valueToken); + if (col == null) + { + message = "Expected Color (array or object)."; + return false; + } + prop.colorValue = col.Value; + message = "Set Color."; + return true; + + case SerializedPropertyType.AnimationCurve: + return TrySetAnimationCurve(prop, valueToken, out message); + + case SerializedPropertyType.Quaternion: + return TrySetQuaternion(prop, valueToken, out message); + + case SerializedPropertyType.Generic: + // Generic properties (structs/classes) should be handled above with JObject mapping + // If we get here, the value wasn't a JObject + if (prop.isArray) + { + message = $"Expected array (JArray) for array property, got {valueToken?.Type.ToString() ?? "null"}."; + } + else + { + message = $"Expected object (JObject) for struct/class property, got {valueToken?.Type.ToString() ?? "null"}."; + } + return false; + + default: + message = $"Unsupported SerializedPropertyType: {prop.propertyType}. " + + "This type cannot be set via MCP patches. Consider editing the .asset file directly " + + "or using Unity's Inspector. For complex types, check if there's a supported alternative format."; + return false; + } + } + catch (Exception ex) + { + message = ex.Message; + return false; + } + } + + private static bool TrySetEnum(SerializedProperty prop, JToken valueToken, out string message) + { + message = null; + var names = prop.enumNames; + if (names == null || names.Length == 0) { message = "Enum has no names."; return false; } + + if (valueToken.Type == JTokenType.Integer) + { + int idx = valueToken.Value(); + if (idx < 0 || idx >= names.Length) { message = $"Enum index out of range: {idx}"; return false; } + prop.enumValueIndex = idx; message = "Set enum."; return true; + } + + string s = valueToken.ToString(); + for (int i = 0; i < names.Length; i++) + { + if (string.Equals(names[i], s, StringComparison.OrdinalIgnoreCase)) + { + prop.enumValueIndex = i; message = "Set enum."; return true; + } + } + message = $"Unknown enum name '{s}'."; + return false; + } + + /// + /// Sets an AnimationCurve property from a JSON structure. + /// + /// Supported formats: + /// + /// Wrapped: { "keys": [ { "time": 0, "value": 1.0 }, ... ] } + /// Direct array: [ { "time": 0, "value": 1.0 }, ... ] + /// Null/empty: Sets an empty AnimationCurve + /// + /// + /// Keyframe fields: + /// + /// time (float): Keyframe time position. Default: 0 + /// value (float): Keyframe value. Default: 0 + /// inSlope or inTangent (float): Incoming tangent slope. Default: 0 + /// outSlope or outTangent (float): Outgoing tangent slope. Default: 0 + /// weightedMode (int): Weighted mode enum (0=None, 1=In, 2=Out, 3=Both). Default: 0 (None) + /// inWeight (float): Incoming tangent weight. Default: 0 + /// outWeight (float): Outgoing tangent weight. Default: 0 + /// + /// + /// Note: All keyframe fields are optional. Missing fields gracefully default to 0, + /// which produces linear interpolation when both tangents are 0. + /// + /// The SerializedProperty of type AnimationCurve to set + /// JSON token containing the curve data + /// Output message describing the result + /// True if successful, false if the format is invalid + private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + // Set to empty curve + prop.animationCurveValue = new AnimationCurve(); + message = "Set AnimationCurve to empty."; + return true; + } + + JArray keysArray = null; + + // Accept either { "keys": [...] } or just [...] + if (valueToken is JObject curveObj) + { + keysArray = curveObj["keys"] as JArray; + if (keysArray == null) + { + message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }"; + return false; + } + } + else if (valueToken is JArray directArray) + { + keysArray = directArray; + } + else + { + message = "AnimationCurve requires object with 'keys' or array of keyframes. " + + "Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }"; + return false; + } + + try + { + var curve = new AnimationCurve(); + foreach (var keyToken in keysArray) + { + if (keyToken is not JObject keyObj) + { + message = "Each keyframe must be an object with 'time' and 'value'."; + return false; + } + + float time = keyObj["time"]?.Value() ?? 0f; + float value = keyObj["value"]?.Value() ?? 0f; + float inSlope = keyObj["inSlope"]?.Value() ?? keyObj["inTangent"]?.Value() ?? 0f; + float outSlope = keyObj["outSlope"]?.Value() ?? keyObj["outTangent"]?.Value() ?? 0f; + + var keyframe = new Keyframe(time, value, inSlope, outSlope); + + // Optional: weighted tangent mode (Unity 2018.1+) + if (keyObj["weightedMode"] != null) + { + int weightedMode = keyObj["weightedMode"].Value(); + keyframe.weightedMode = (WeightedMode)weightedMode; + } + if (keyObj["inWeight"] != null) + { + keyframe.inWeight = keyObj["inWeight"].Value(); + } + if (keyObj["outWeight"] != null) + { + keyframe.outWeight = keyObj["outWeight"].Value(); + } + + curve.AddKey(keyframe); + } + + prop.animationCurveValue = curve; + message = $"Set AnimationCurve with {keysArray.Count} keyframes."; + return true; + } + catch (Exception ex) + { + message = $"Failed to parse AnimationCurve: {ex.Message}"; + return false; + } + } + + /// + /// Sets a Quaternion property from JSON. + /// + /// Supported formats: + /// + /// Euler array: [x, y, z] - Euler angles in degrees + /// Raw quaternion array: [x, y, z, w] - Direct quaternion components + /// Object format: { "x": 0, "y": 0, "z": 0, "w": 1 } - Direct components + /// Explicit euler: { "euler": [x, y, z] } - Euler angles in degrees + /// Null/empty: Sets Quaternion.identity (no rotation) + /// + /// + /// Format detection: + /// + /// 3-element array → Interpreted as Euler angles (degrees) + /// 4-element array → Interpreted as raw quaternion [x, y, z, w] + /// Object with euler → Uses euler array for rotation + /// Object with x, y, z, w → Uses raw quaternion components + /// + /// + /// The SerializedProperty of type Quaternion to set + /// JSON token containing the quaternion data + /// Output message describing the result + /// True if successful, false if the format is invalid + private static bool TrySetQuaternion(SerializedProperty prop, JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + prop.quaternionValue = Quaternion.identity; + message = "Set Quaternion to identity."; + return true; + } + + try + { + if (valueToken is JArray arr) + { + if (arr.Count == 3) + { + // Euler angles [x, y, z] + var euler = new Vector3( + arr[0].Value(), + arr[1].Value(), + arr[2].Value() + ); + prop.quaternionValue = Quaternion.Euler(euler); + message = $"Set Quaternion from Euler({euler.x}, {euler.y}, {euler.z})."; + return true; + } + else if (arr.Count == 4) + { + // Raw quaternion [x, y, z, w] + prop.quaternionValue = new Quaternion( + arr[0].Value(), + arr[1].Value(), + arr[2].Value(), + arr[3].Value() + ); + message = "Set Quaternion from [x, y, z, w]."; + return true; + } + else + { + message = "Quaternion array must have 3 elements (Euler) or 4 elements (x, y, z, w)."; + return false; + } + } + else if (valueToken is JObject obj) + { + // Check for explicit euler property + if (obj["euler"] is JArray eulerArr && eulerArr.Count == 3) + { + var euler = new Vector3( + eulerArr[0].Value(), + eulerArr[1].Value(), + eulerArr[2].Value() + ); + prop.quaternionValue = Quaternion.Euler(euler); + message = $"Set Quaternion from euler: ({euler.x}, {euler.y}, {euler.z})."; + return true; + } + + // Object format { x, y, z, w } + if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null) + { + prop.quaternionValue = new Quaternion( + obj["x"].Value(), + obj["y"].Value(), + obj["z"].Value(), + obj["w"].Value() + ); + message = "Set Quaternion from { x, y, z, w }."; + return true; + } + + message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }."; + return false; + } + else + { + message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }."; + return false; + } + } + catch (Exception ex) + { + message = $"Failed to parse Quaternion: {ex.Message}"; + return false; + } + } + + private static bool TryResolveTarget(JToken targetToken, out UnityEngine.Object target, out string targetPath, out string targetGuid, out object error) + { + target = null; + targetPath = null; + targetGuid = null; + error = null; + + if (targetToken is not JObject targetObj) + { + error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must be an object with {guid|path}." }); + return false; + } + + string guid = targetObj["guid"]?.ToString(); + string path = targetObj["path"]?.ToString(); + + if (string.IsNullOrWhiteSpace(guid) && string.IsNullOrWhiteSpace(path)) + { + error = new ErrorResponse(CodeInvalidParams, new { message = "'target' must include 'guid' or 'path'." }); + return false; + } + + string resolvedPath = !string.IsNullOrWhiteSpace(guid) + ? AssetDatabase.GUIDToAssetPath(guid) + : AssetPathUtility.SanitizeAssetPath(path); + + if (string.IsNullOrWhiteSpace(resolvedPath)) + { + error = new ErrorResponse(CodeTargetNotFound, new { message = "Could not resolve target path.", guid, path }); + return false; + } + + var obj = AssetDatabase.LoadAssetAtPath(resolvedPath); + if (obj == null) + { + error = new ErrorResponse(CodeTargetNotFound, new { message = "Target asset not found.", targetPath = resolvedPath, targetGuid = guid }); + return false; + } + + target = obj; + targetPath = resolvedPath; + targetGuid = string.IsNullOrWhiteSpace(guid) ? AssetDatabase.AssetPathToGUID(resolvedPath) : guid; + return true; + } + + private static void CoerceJsonStringArrayParameter(JObject @params, string paramName) + { + var token = @params?[paramName]; + if (token != null && token.Type == JTokenType.String) + { + try + { + var parsed = JToken.Parse(token.ToString()); + if (parsed is JArray arr) + { + @params[paramName] = arr; + } + } + catch (Exception e) + { + McpLog.Warn($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}"); + } + } + } + + private static bool EnsureFolderExists(string folderPath, out string error) + { + error = null; + if (string.IsNullOrWhiteSpace(folderPath)) + { + error = "Folder path is empty."; + return false; + } + + // Expect normalized input here (Assets/... or Assets). + string sanitized = SanitizeSlashes(folderPath); + + if (!sanitized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) + && !string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase)) + { + error = "Folder path must be under Assets/."; + return false; + } + + if (string.Equals(sanitized, "Assets", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + sanitized = sanitized.TrimEnd('/'); + if (AssetDatabase.IsValidFolder(sanitized)) + { + return true; + } + + // Create recursively from Assets/ + var parts = sanitized.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || !string.Equals(parts[0], "Assets", StringComparison.OrdinalIgnoreCase)) + { + error = "Folder path must start with Assets/"; + return false; + } + + string current = "Assets"; + for (int i = 1; i < parts.Length; i++) + { + string next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + { + string guid = AssetDatabase.CreateFolder(current, parts[i]); + if (string.IsNullOrEmpty(guid)) + { + error = $"Failed to create folder: {next}"; + return false; + } + } + current = next; + } + + return AssetDatabase.IsValidFolder(sanitized); + } + + private static string SanitizeSlashes(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + var s = AssetPathUtility.NormalizeSeparators(path); + while (s.IndexOf("//", StringComparison.Ordinal) >= 0) + { + s = s.Replace("//", "/", StringComparison.Ordinal); + } + return s; + } + + private static bool TryNormalizeFolderPath(string folderPath, out string normalized, out string error) + { + normalized = null; + error = null; + + if (string.IsNullOrWhiteSpace(folderPath)) + { + error = "Folder path is empty."; + return false; + } + + var s = SanitizeSlashes(folderPath.Trim()); + + // Reject obvious non-project/invalid roots. We only support Assets/ (and relative paths that will be rooted under Assets/). + if (s.StartsWith("/", StringComparison.Ordinal) + || s.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || Regex.IsMatch(s, @"^[a-zA-Z]:")) + { + error = "Folder path must be a project-relative path under Assets/."; + return false; + } + + if (s.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("ProjectSettings/", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Library/", StringComparison.OrdinalIgnoreCase)) + { + error = "Folder path must be under Assets/."; + return false; + } + + if (string.Equals(s, "Assets", StringComparison.OrdinalIgnoreCase)) + { + normalized = "Assets"; + return true; + } + + if (s.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + normalized = s.TrimEnd('/'); + return true; + } + + // Allow relative paths like "Temp/MyFolder" and root them under Assets/. + normalized = ("Assets/" + s.TrimStart('/')).TrimEnd('/'); + return true; + } + + // NOTE: Local TryGet* helpers have been removed. + // Using shared helpers instead: ParamCoercion (for int/float/bool) and VectorParsing (for Vector2/3/4, Color) + + private static string NormalizeAction(string raw) + { + var s = raw.Trim(); + s = s.Replace("-", "").Replace("_", ""); + return s.ToLowerInvariant(); + } + + private static bool IsCreateAction(string normalized) + { + return normalized == "create" || normalized == "createso"; + } + + /// + /// Resolves a type by name. Delegates to UnityTypeResolver.ResolveAny(). + /// + private static Type ResolveType(string typeName) + { + return Helpers.UnityTypeResolver.ResolveAny(typeName); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta new file mode 100644 index 0000000..153cfe3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 9e0bb5a8c1b24b7ea8bce09ce0a1f234 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageScriptableObject.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageShader.cs b/Assets/MCPForUnity/Editor/Tools/ManageShader.cs new file mode 100644 index 0000000..87b86fe --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageShader.cs @@ -0,0 +1,344 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles CRUD operations for shader files within the Unity project. + /// + [McpForUnityTool("manage_shader", AutoRegister = false)] + public static class ManageShader + { + /// + /// Main handler for shader management actions. + /// + public static object HandleCommand(JObject @params) + { + // Extract parameters + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to decode shader contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + return new ErrorResponse("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + return new ErrorResponse( + $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Ensure path is relative to Assets/, removing any leading "Assets/" + // Set default directory to "Shaders" if path is not provided + string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = AssetPathUtility.NormalizeSeparators(relativeDir).Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + // Handle empty string case explicitly after processing + if (string.IsNullOrEmpty(relativeDir)) + { + relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" + } + + // Construct paths + string shaderFileName = $"{name}.shader"; + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); + string fullPath = Path.Combine(fullPathDir, shaderFileName); + string relativePath = AssetPathUtility.NormalizeSeparators( + Path.Combine("Assets", relativeDir, shaderFileName) + ); // Ensure "Assets/" prefix and forward slashes + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + if (!Directory.Exists(fullPathDir)) + { + Directory.CreateDirectory(fullPathDir); + // Refresh AssetDatabase to recognize new folders + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + catch (Exception e) + { + return new ErrorResponse( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateShader(fullPath, relativePath, name, contents); + case "read": + return ReadShader(fullPath, relativePath); + case "update": + return UpdateShader(fullPath, relativePath, name, contents); + case "delete": + return DeleteShader(fullPath, relativePath); + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + // Check if shader already exists + if (File.Exists(fullPath)) + { + return new ErrorResponse( + $"Shader already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Add validation for shader name conflicts in Unity + if (Shader.Find(name) != null) + { + return new ErrorResponse( + $"A shader with name '{name}' already exists in the project. Choose a different name." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultShaderContent(name); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Ensure Unity recognizes the new shader + return new SuccessResponse( + $"Shader '{name}.shader' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create shader '{relativePath}': {e.Message}"); + } + } + + private static object ReadShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse($"Shader not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + //TODO: Consider a threshold for large files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var responseData = new + { + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return new SuccessResponse( + $"Shader '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to read shader '{relativePath}': {e.Message}"); + } + } + + private static object UpdateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse( + $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return new ErrorResponse("Content is required for the 'update' action."); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + return new SuccessResponse( + $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to update shader '{relativePath}': {e.Message}"); + } + } + + private static object DeleteShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return new ErrorResponse($"Shader not found at '{relativePath}'."); + } + + try + { + // Delete the asset through Unity's AssetDatabase first + bool success = AssetDatabase.DeleteAsset(relativePath); + if (!success) + { + return new ErrorResponse($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); + } + + // If the file still exists (rare case), try direct deletion + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return new SuccessResponse($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to delete shader '{relativePath}': {e.Message}"); + } + } + + //This is a CGProgram template + //TODO: making a HLSL template as well? + private static string GenerateDefaultShaderContent(string name) + { + return @"Shader """ + name + @""" + { + Properties + { + _MainTex (""Texture"", 2D) = ""white"" {} + } + SubShader + { + Tags { ""RenderType""=""Opaque"" } + LOD 100 + + Pass + { + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include ""UnityCG.cginc"" + + struct appdata + { + float4 vertex : POSITION; + float2 uv : TEXCOORD0; + }; + + struct v2f + { + float2 uv : TEXCOORD0; + float4 vertex : SV_POSITION; + }; + + sampler2D _MainTex; + float4 _MainTex_ST; + + v2f vert (appdata v) + { + v2f o; + o.vertex = UnityObjectToClipPos(v.vertex); + o.uv = TRANSFORM_TEX(v.uv, _MainTex); + return o; + } + + fixed4 frag (v2f i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + return col; + } + ENDCG + } + } + }"; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageShader.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageShader.cs.meta new file mode 100644 index 0000000..85f096a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageShader.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: bcf4f1f3110494344b2af9324cf5c571 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageShader.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs b/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs new file mode 100644 index 0000000..86e4290 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs @@ -0,0 +1,1025 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles procedural texture generation operations. + /// Supports patterns (checkerboard, stripes, dots, grid, brick), + /// gradients, noise, and direct pixel manipulation. + /// + [McpForUnityTool("manage_texture", AutoRegister = false)] + public static class ManageTexture + { + private const int MaxTextureDimension = 1024; + private const int MaxTexturePixels = 1024 * 1024; + private const int MaxNoiseWork = 4000000; + private static readonly List ValidActions = new List + { + "create", + "modify", + "delete", + "create_sprite", + "apply_pattern", + "apply_gradient", + "apply_noise" + }; + + private static ErrorResponse ValidateDimensions(int width, int height, List warnings) + { + if (width <= 0 || height <= 0) + return new ErrorResponse($"Invalid dimensions: {width}x{height}. Must be positive."); + if (width > MaxTextureDimension || height > MaxTextureDimension) + warnings.Add($"Dimensions exceed recommended max {MaxTextureDimension} per side (got {width}x{height})."); + long totalPixels = (long)width * height; + if (totalPixels > MaxTexturePixels) + warnings.Add($"Total pixels exceed recommended max {MaxTexturePixels} (got {width}x{height})."); + return null; + } + + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + string path = @params["path"]?.ToString(); + + try + { + switch (action) + { + case "create": + case "create_sprite": + return CreateTexture(@params, action == "create_sprite"); + case "modify": + return ModifyTexture(@params); + case "delete": + return DeleteTexture(path); + case "apply_pattern": + return ApplyPattern(@params); + case "apply_gradient": + return ApplyGradient(@params); + case "apply_noise": + return ApplyNoise(@params); + default: + return new ErrorResponse($"Unknown action: '{action}'"); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageTexture] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object CreateTexture(JObject @params, bool asSprite) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for create."); + + string imagePath = @params["imagePath"]?.ToString(); + bool hasImage = !string.IsNullOrEmpty(imagePath); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + List warnings = new List(); + + // Validate dimensions + if (!hasImage) + { + var dimensionError = ValidateDimensions(width, height, warnings); + if (dimensionError != null) + return dimensionError; + } + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + try + { + var fillColorToken = @params["fillColor"]; + var patternToken = @params["pattern"]; + var pixelsToken = @params["pixels"]; + + if (hasImage && (fillColorToken != null || patternToken != null || pixelsToken != null)) + { + return new ErrorResponse("imagePath cannot be combined with fillColor, pattern, or pixels."); + } + + int patternSize = 8; + if (!hasImage && patternToken != null) + { + patternSize = @params["patternSize"]?.ToObject() ?? 8; + if (patternSize <= 0) + return new ErrorResponse("patternSize must be greater than 0."); + } + + Texture2D texture; + if (hasImage) + { + string resolvedImagePath = ResolveImagePath(imagePath); + if (!File.Exists(resolvedImagePath)) + return new ErrorResponse($"Image file not found at '{imagePath}'."); + + byte[] imageBytes = File.ReadAllBytes(resolvedImagePath); + texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!texture.LoadImage(imageBytes)) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to load image from '{imagePath}'."); + } + + width = texture.width; + height = texture.height; + var imageDimensionError = ValidateDimensions(width, height, warnings); + if (imageDimensionError != null) + { + UnityEngine.Object.DestroyImmediate(texture); + return imageDimensionError; + } + } + else + { + texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + // Check for fill color + if (fillColorToken != null && fillColorToken.Type == JTokenType.Array) + { + Color32 fillColor = TextureOps.ParseColor32(fillColorToken as JArray); + TextureOps.FillTexture(texture, fillColor); + } + + // Check for pattern + if (patternToken != null) + { + string pattern = patternToken.ToString(); + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + ApplyPatternToTexture(texture, pattern, palette, patternSize); + } + + // Check for direct pixel data + if (pixelsToken != null) + { + TextureOps.ApplyPixelData(texture, pixelsToken, width, height); + } + + // If nothing specified, create transparent texture + if (fillColorToken == null && patternToken == null && pixelsToken == null) + { + TextureOps.FillTexture(texture, new Color32(0, 0, 0, 0)); + } + } + + texture.Apply(); + + // Save to disk + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(texture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure texture importer settings if provided + JToken importSettingsToken = @params["importSettings"]; + JToken spriteSettingsToken = @params["spriteSettings"]; + + if (importSettingsToken != null) + { + ConfigureTextureImporter(fullPath, importSettingsToken); + } + else if (asSprite || spriteSettingsToken != null) + { + // Legacy sprite configuration + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + // Clean up memory + UnityEngine.Object.DestroyImmediate(texture); + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } + + return new SuccessResponse( + $"Texture created at '{fullPath}' ({width}x{height})" + (asSprite ? " as sprite" : ""), + new + { + path = fullPath, + width, + height, + asSprite = asSprite || spriteSettingsToken != null || (importSettingsToken?["textureType"]?.ToString() == "Sprite"), + warnings = warnings.Count > 0 ? warnings : null + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create texture: {e.Message}"); + } + } + + private static object ModifyTexture(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for modify."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Texture not found at path: {fullPath}"); + + try + { + Texture2D texture = AssetDatabase.LoadAssetAtPath(fullPath); + if (texture == null) + return new ErrorResponse($"Failed to load texture at path: {fullPath}"); + + // Make the texture readable + string absolutePath = GetAbsolutePath(fullPath); + byte[] fileData = File.ReadAllBytes(absolutePath); + Texture2D editableTexture = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); + editableTexture.LoadImage(fileData); + + // Apply modifications + var setPixelsToken = @params["setPixels"] as JObject; + if (setPixelsToken != null) + { + int x = setPixelsToken["x"]?.ToObject() ?? 0; + int y = setPixelsToken["y"]?.ToObject() ?? 0; + int w = setPixelsToken["width"]?.ToObject() ?? 1; + int h = setPixelsToken["height"]?.ToObject() ?? 1; + + if (w <= 0 || h <= 0) + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse("setPixels width and height must be positive."); + } + + var pixelsToken = setPixelsToken["pixels"]; + var colorToken = setPixelsToken["color"]; + + if (pixelsToken != null) + { + TextureOps.ApplyPixelDataToRegion(editableTexture, pixelsToken, x, y, w, h); + } + else if (colorToken != null) + { + Color32 color = TextureOps.ParseColor32(colorToken as JArray); + int startX = Mathf.Max(0, x); + int startY = Mathf.Max(0, y); + int endX = Mathf.Min(x + w, editableTexture.width); + int endY = Mathf.Min(y + h, editableTexture.height); + + for (int py = startY; py < endY; py++) + { + for (int px = startX; px < endX; px++) + { + editableTexture.SetPixel(px, py, color); + } + } + } + else + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse("setPixels requires 'color' or 'pixels'."); + } + } + + editableTexture.Apply(); + + // Save back to disk + byte[] imageData = TextureOps.EncodeTexture(editableTexture, fullPath); + if (imageData == null || imageData.Length == 0) + { + UnityEngine.Object.DestroyImmediate(editableTexture); + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(absolutePath, imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + UnityEngine.Object.DestroyImmediate(editableTexture); + + return new SuccessResponse($"Texture modified: {fullPath}"); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to modify texture: {e.Message}"); + } + } + + private static object DeleteTexture(string path) + { + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for delete."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return new ErrorResponse($"Texture not found at path: {fullPath}"); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + return new SuccessResponse($"Texture deleted: {fullPath}"); + else + return new ErrorResponse($"Failed to delete texture: {fullPath}"); + } + catch (Exception e) + { + return new ErrorResponse($"Error deleting texture: {e.Message}"); + } + } + + private static object ApplyPattern(JObject @params) + { + // Reuse CreateTexture with pattern + return CreateTexture(@params, false); + } + + private static object ApplyGradient(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for apply_gradient."); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + List warnings = new List(); + var dimensionError = ValidateDimensions(width, height, warnings); + if (dimensionError != null) + return dimensionError; + string gradientType = @params["gradientType"]?.ToString() ?? "linear"; + float angle = @params["gradientAngle"]?.ToObject() ?? 0f; + + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + if (palette == null || palette.Count < 2) + { + // Default gradient palette + palette = new List { new Color32(0, 0, 0, 255), new Color32(255, 255, 255, 255) }; + } + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + Texture2D texture = null; + try + { + texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + + if (gradientType == "radial") + { + ApplyRadialGradient(texture, palette); + } + else + { + ApplyLinearGradient(texture, palette, angle); + } + + texture.Apply(); + + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure as sprite if requested + JToken spriteSettingsToken = @params["spriteSettings"]; + if (spriteSettingsToken != null) + { + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } + + return new SuccessResponse( + $"Gradient texture created at '{fullPath}' ({width}x{height})", + new + { + path = fullPath, + width, + height, + gradientType, + warnings = warnings.Count > 0 ? warnings : null + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create gradient texture: {e.Message}"); + } + finally + { + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + } + } + + private static object ApplyNoise(JObject @params) + { + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' is required for apply_noise."); + + int width = @params["width"]?.ToObject() ?? 64; + int height = @params["height"]?.ToObject() ?? 64; + List warnings = new List(); + var dimensionError = ValidateDimensions(width, height, warnings); + if (dimensionError != null) + return dimensionError; + float scale = @params["noiseScale"]?.ToObject() ?? 0.1f; + int octaves = @params["octaves"]?.ToObject() ?? 1; + if (octaves <= 0) + return new ErrorResponse("octaves must be greater than 0."); + long noiseWork = (long)width * height * octaves; + if (noiseWork > MaxNoiseWork) + warnings.Add($"Noise workload exceeds recommended max {MaxNoiseWork} (got {width}x{height}x{octaves})."); + + var palette = TextureOps.ParsePalette(@params["palette"] as JArray); + if (palette == null || palette.Count < 2) + { + palette = new List { new Color32(0, 0, 0, 255), new Color32(255, 255, 255, 255) }; + } + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + EnsureDirectoryExists(fullPath); + + Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + try + { + ApplyPerlinNoise(texture, palette, scale, octaves); + + texture.Apply(); + + byte[] imageData = TextureOps.EncodeTexture(texture, fullPath); + if (imageData == null || imageData.Length == 0) + { + return new ErrorResponse($"Failed to encode texture for '{fullPath}'"); + } + File.WriteAllBytes(GetAbsolutePath(fullPath), imageData); + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + + // Configure as sprite if requested + JToken spriteSettingsToken = @params["spriteSettings"]; + if (spriteSettingsToken != null) + { + ConfigureAsSprite(fullPath, spriteSettingsToken); + } + + foreach (var warning in warnings) + { + McpLog.Warn($"[ManageTexture] {warning}"); + } + + return new SuccessResponse( + $"Noise texture created at '{fullPath}' ({width}x{height})", + new + { + path = fullPath, + width, + height, + noiseScale = scale, + octaves, + warnings = warnings.Count > 0 ? warnings : null + } + ); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create noise texture: {e.Message}"); + } + finally + { + if (texture != null) + UnityEngine.Object.DestroyImmediate(texture); + } + } + + // --- Pattern Helpers --- + + private static void ApplyPatternToTexture(Texture2D texture, string pattern, List palette, int patternSize) + { + if (palette == null || palette.Count == 0) + { + palette = new List { new Color32(255, 255, 255, 255), new Color32(0, 0, 0, 255) }; + } + + int width = texture.width; + int height = texture.height; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + Color32 color = GetPatternColor(x, y, pattern, palette, patternSize, width, height); + texture.SetPixel(x, y, color); + } + } + } + + private static Color32 GetPatternColor(int x, int y, string pattern, List palette, int size, int width, int height) + { + int colorIndex = 0; + + switch (pattern.ToLower()) + { + case "checkerboard": + colorIndex = ((x / size) + (y / size)) % 2; + break; + + case "stripes": + case "stripes_v": + colorIndex = (x / size) % palette.Count; + break; + + case "stripes_h": + colorIndex = (y / size) % palette.Count; + break; + + case "stripes_diag": + colorIndex = ((x + y) / size) % palette.Count; + break; + + case "dots": + int cx = (x % (size * 2)) - size; + int cy = (y % (size * 2)) - size; + bool inDot = (cx * cx + cy * cy) < (size * size / 4); + colorIndex = inDot ? 1 : 0; + break; + + case "grid": + bool onGridLine = (x % size == 0) || (y % size == 0); + colorIndex = onGridLine ? 1 : 0; + break; + + case "brick": + int row = y / size; + int offset = (row % 2) * (size / 2); + bool onBorder = ((x + offset) % size == 0) || (y % size == 0); + colorIndex = onBorder ? 1 : 0; + break; + + default: + colorIndex = 0; + break; + } + + return palette[Mathf.Clamp(colorIndex, 0, palette.Count - 1)]; + } + + // --- Gradient Helpers --- + + private static void ApplyLinearGradient(Texture2D texture, List palette, float angle) + { + int width = texture.width; + int height = texture.height; + float radians = angle * Mathf.Deg2Rad; + Vector2 dir = new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)); + float denomX = Mathf.Max(1, width - 1); + float denomY = Mathf.Max(1, height - 1); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float nx = x / denomX; + float ny = y / denomY; + float t = Vector2.Dot(new Vector2(nx, ny), dir); + t = Mathf.Clamp01((t + 1f) / 2f); + + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static void ApplyRadialGradient(Texture2D texture, List palette) + { + int width = texture.width; + int height = texture.height; + float cx = width / 2f; + float cy = height / 2f; + float maxDist = Mathf.Sqrt(cx * cx + cy * cy); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float dx = x - cx; + float dy = y - cy; + float dist = Mathf.Sqrt(dx * dx + dy * dy); + float t = Mathf.Clamp01(dist / maxDist); + + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static Color32 LerpPalette(List palette, float t) + { + if (palette.Count == 1) return palette[0]; + if (t <= 0) return palette[0]; + if (t >= 1) return palette[palette.Count - 1]; + + float scaledT = t * (palette.Count - 1); + int index = Mathf.FloorToInt(scaledT); + float localT = scaledT - index; + + if (index >= palette.Count - 1) + return palette[palette.Count - 1]; + + Color c1 = palette[index]; + Color c2 = palette[index + 1]; + return Color.Lerp(c1, c2, localT); + } + + // --- Noise Helpers --- + + private static void ApplyPerlinNoise(Texture2D texture, List palette, float scale, int octaves) + { + int width = texture.width; + int height = texture.height; + + // Random offset to ensure different patterns + float offsetX = UnityEngine.Random.Range(0f, 1000f); + float offsetY = UnityEngine.Random.Range(0f, 1000f); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float noiseValue = 0f; + float amplitude = 1f; + float frequency = 1f; + float maxValue = 0f; + + for (int o = 0; o < octaves; o++) + { + float sampleX = (x + offsetX) * scale * frequency; + float sampleY = (y + offsetY) * scale * frequency; + noiseValue += Mathf.PerlinNoise(sampleX, sampleY) * amplitude; + maxValue += amplitude; + amplitude *= 0.5f; + frequency *= 2f; + } + + float t = Mathf.Clamp01(noiseValue / maxValue); + Color32 color = LerpPalette(palette, t); + texture.SetPixel(x, y, color); + } + } + } + + private static void ConfigureAsSprite(string path, JToken spriteSettings) + { + TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; + if (importer == null) + { + McpLog.Warn($"[ManageTexture] Could not get TextureImporter for {path}"); + return; + } + + importer.textureType = TextureImporterType.Sprite; + importer.spriteImportMode = SpriteImportMode.Single; + + if (spriteSettings != null && spriteSettings.Type == JTokenType.Object) + { + var settings = spriteSettings as JObject; + + // Pivot + var pivotToken = settings["pivot"]; + if (pivotToken is JArray pivotArray && pivotArray.Count >= 2) + { + importer.spritePivot = new Vector2( + pivotArray[0].ToObject(), + pivotArray[1].ToObject() + ); + } + + // Pixels per unit + var ppuToken = settings["pixelsPerUnit"]; + if (ppuToken != null) + { + importer.spritePixelsPerUnit = ppuToken.ToObject(); + } + } + + importer.SaveAndReimport(); + } + + private static void ConfigureTextureImporter(string path, JToken importSettings) + { + TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; + if (importer == null) + { + McpLog.Warn($"[ManageTexture] Could not get TextureImporter for {path}"); + return; + } + + if (importSettings == null || importSettings.Type != JTokenType.Object) + { + return; + } + + var settings = importSettings as JObject; + + // Texture Type + var textureTypeToken = settings["textureType"]; + if (textureTypeToken != null) + { + string typeStr = textureTypeToken.ToString(); + if (TryParseEnum(typeStr, out var textureType)) + { + importer.textureType = textureType; + } + } + + // Texture Shape + var textureShapeToken = settings["textureShape"]; + if (textureShapeToken != null) + { + string shapeStr = textureShapeToken.ToString(); + if (TryParseEnum(shapeStr, out var textureShape)) + { + importer.textureShape = textureShape; + } + } + + // sRGB + var srgbToken = settings["sRGBTexture"]; + if (srgbToken != null) + { + importer.sRGBTexture = srgbToken.ToObject(); + } + + // Alpha Source + var alphaSourceToken = settings["alphaSource"]; + if (alphaSourceToken != null) + { + string alphaStr = alphaSourceToken.ToString(); + if (TryParseEnum(alphaStr, out var alphaSource)) + { + importer.alphaSource = alphaSource; + } + } + + // Alpha Is Transparency + var alphaTransToken = settings["alphaIsTransparency"]; + if (alphaTransToken != null) + { + importer.alphaIsTransparency = alphaTransToken.ToObject(); + } + + // Readable + var readableToken = settings["isReadable"]; + if (readableToken != null) + { + importer.isReadable = readableToken.ToObject(); + } + + // Mipmaps + var mipmapToken = settings["mipmapEnabled"]; + if (mipmapToken != null) + { + importer.mipmapEnabled = mipmapToken.ToObject(); + } + + // Mipmap Filter + var mipmapFilterToken = settings["mipmapFilter"]; + if (mipmapFilterToken != null) + { + string filterStr = mipmapFilterToken.ToString(); + if (TryParseEnum(filterStr, out var mipmapFilter)) + { + importer.mipmapFilter = mipmapFilter; + } + } + + // Wrap Mode + var wrapModeToken = settings["wrapMode"]; + if (wrapModeToken != null) + { + string wrapStr = wrapModeToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapMode = wrapMode; + } + } + + // Wrap Mode U + var wrapModeUToken = settings["wrapModeU"]; + if (wrapModeUToken != null) + { + string wrapStr = wrapModeUToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapModeU = wrapMode; + } + } + + // Wrap Mode V + var wrapModeVToken = settings["wrapModeV"]; + if (wrapModeVToken != null) + { + string wrapStr = wrapModeVToken.ToString(); + if (TryParseEnum(wrapStr, out var wrapMode)) + { + importer.wrapModeV = wrapMode; + } + } + + // Filter Mode + var filterModeToken = settings["filterMode"]; + if (filterModeToken != null) + { + string filterStr = filterModeToken.ToString(); + if (TryParseEnum(filterStr, out var filterMode)) + { + importer.filterMode = filterMode; + } + } + + // Aniso Level + var anisoToken = settings["anisoLevel"]; + if (anisoToken != null) + { + importer.anisoLevel = anisoToken.ToObject(); + } + + // Max Texture Size + var maxSizeToken = settings["maxTextureSize"]; + if (maxSizeToken != null) + { + importer.maxTextureSize = maxSizeToken.ToObject(); + } + + // Compression + var compressionToken = settings["textureCompression"]; + if (compressionToken != null) + { + string compStr = compressionToken.ToString(); + if (TryParseEnum(compStr, out var compression)) + { + importer.textureCompression = compression; + } + } + + // Crunched Compression + var crunchedToken = settings["crunchedCompression"]; + if (crunchedToken != null) + { + importer.crunchedCompression = crunchedToken.ToObject(); + } + + // Compression Quality + var qualityToken = settings["compressionQuality"]; + if (qualityToken != null) + { + importer.compressionQuality = qualityToken.ToObject(); + } + + // --- Sprite-specific settings --- + + // Sprite Import Mode + var spriteModeToken = settings["spriteImportMode"]; + if (spriteModeToken != null) + { + string modeStr = spriteModeToken.ToString(); + if (TryParseEnum(modeStr, out var spriteMode)) + { + importer.spriteImportMode = spriteMode; + } + } + + // Sprite Pixels Per Unit + var ppuToken = settings["spritePixelsPerUnit"]; + if (ppuToken != null) + { + importer.spritePixelsPerUnit = ppuToken.ToObject(); + } + + // Sprite Pivot + var pivotToken = settings["spritePivot"]; + if (pivotToken is JArray pivotArray && pivotArray.Count >= 2) + { + importer.spritePivot = new Vector2( + pivotArray[0].ToObject(), + pivotArray[1].ToObject() + ); + } + + // Apply sprite settings using TextureImporterSettings helper + TextureImporterSettings importerSettings = new TextureImporterSettings(); + importer.ReadTextureSettings(importerSettings); + + bool settingsChanged = false; + + // Sprite Mesh Type + var meshTypeToken = settings["spriteMeshType"]; + if (meshTypeToken != null) + { + string meshStr = meshTypeToken.ToString(); + if (TryParseEnum(meshStr, out var meshType)) + { + importerSettings.spriteMeshType = meshType; + settingsChanged = true; + } + } + + // Sprite Extrude + var extrudeToken = settings["spriteExtrude"]; + if (extrudeToken != null) + { + importerSettings.spriteExtrude = (uint)extrudeToken.ToObject(); + settingsChanged = true; + } + + if (settingsChanged) + { + importer.SetTextureSettings(importerSettings); + } + + importer.SaveAndReimport(); + } + + private static bool TryParseEnum(string value, out T result) where T : struct + { + // Try exact match first + if (Enum.TryParse(value, true, out result)) + { + return true; + } + + // Try without common prefixes/suffixes + string cleanValue = value.Replace("_", "").Replace("-", ""); + if (Enum.TryParse(cleanValue, true, out result)) + { + return true; + } + + result = default; + return false; + } + + private static bool AssetExists(string path) + { + return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(path)); + } + + private static void EnsureDirectoryExists(string assetPath) + { + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(GetAbsolutePath(directory))) + { + Directory.CreateDirectory(GetAbsolutePath(directory)); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + } + } + + private static string GetAbsolutePath(string assetPath) + { + return Path.Combine(Directory.GetCurrentDirectory(), assetPath); + } + + private static string ResolveImagePath(string imagePath) + { + if (Path.IsPathRooted(imagePath)) + return imagePath; + + return Path.Combine(Directory.GetCurrentDirectory(), imagePath); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs.meta b/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs.meta new file mode 100644 index 0000000..597e8c4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ManageTexture.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 8028b64102744ea5aad53a762d48079a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ManageTexture.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs new file mode 100644 index 0000000..e4db3a4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -0,0 +1,107 @@ +using System; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Marks a class as an MCP tool handler + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class McpForUnityToolAttribute : Attribute + { + /// + /// Tool name (if null, derived from class name) + /// + public string Name { get; set; } + + /// + /// Tool description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this tool returns structured output + /// + public bool StructuredOutput { get; set; } = true; + + /// + /// Controls whether this tool is automatically registered with FastMCP. + /// Defaults to true so most tools opt-in automatically. Set to false + /// for legacy/built-in tools that already exist server-side. + /// + public bool AutoRegister { get; set; } = true; + + /// + /// Enables the polling middleware for long-running tools. When true, Unity + /// should return a PendingResponse and the Python side will poll using + /// until completion. + /// + public bool RequiresPolling { get; set; } = false; + + /// + /// The action name to use when polling for status. Defaults to "status". + /// + public string PollAction { get; set; } = "status"; + + /// + /// The command name used to route requests to this tool. + /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// Kept for backward compatibility. + /// + public string CommandName + { + get => Name; + set => Name = value; + } + + /// + /// Create an MCP tool attribute with auto-generated command name. + /// The command name will be derived from the class name (PascalCase → snake_case). + /// Example: ManageAsset → manage_asset + /// + public McpForUnityToolAttribute() + { + Name = null; // Will be auto-generated + } + + /// + /// Create an MCP tool attribute with explicit command name. + /// + /// The command name (e.g., "manage_asset") + public McpForUnityToolAttribute(string name = null) + { + Name = name; + } + } + + /// + /// Describes a tool parameter + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] + public class ToolParameterAttribute : Attribute + { + /// + /// Parameter name (if null, derived from property/field name) + /// + public string Name { get; } + + /// + /// Parameter description for LLM + /// + public string Description { get; set; } + + /// + /// Whether this parameter is required + /// + public bool Required { get; set; } = true; + + /// + /// Default value (as string) + /// + public string DefaultValue { get; set; } + + public ToolParameterAttribute(string description) + { + Description = description; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta b/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta new file mode 100644 index 0000000..70dad0a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 804d07b886f4e4eb39316bbef34687c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Prefabs.meta b/Assets/MCPForUnity/Editor/Tools/Prefabs.meta new file mode 100644 index 0000000..4fb95c5 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bd48a1b7555c46bba168078ce0291cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs new file mode 100644 index 0000000..6df9d47 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -0,0 +1,982 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.Prefabs +{ + [McpForUnityTool("manage_prefabs", AutoRegister = false)] + /// + /// Tool to manage Unity Prefabs: create, inspect, and modify prefab assets. + /// Uses headless editing (no UI, no dialogs) for reliable automated workflows. + /// + public static class ManagePrefabs + { + // Action constants + private const string ACTION_CREATE_FROM_GAMEOBJECT = "create_from_gameobject"; + private const string ACTION_GET_INFO = "get_info"; + private const string ACTION_GET_HIERARCHY = "get_hierarchy"; + private const string ACTION_MODIFY_CONTENTS = "modify_contents"; + private const string SupportedActions = ACTION_CREATE_FROM_GAMEOBJECT + ", " + ACTION_GET_INFO + ", " + ACTION_GET_HIERARCHY + ", " + ACTION_MODIFY_CONTENTS; + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse($"Action parameter is required. Valid actions are: {SupportedActions}."); + } + + try + { + switch (action) + { + case ACTION_CREATE_FROM_GAMEOBJECT: + return CreatePrefabFromGameObject(@params); + case ACTION_GET_INFO: + return GetInfo(@params); + case ACTION_GET_HIERARCHY: + return GetHierarchy(@params); + case ACTION_MODIFY_CONTENTS: + return ModifyContents(@params); + default: + return new ErrorResponse($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error: {e.Message}"); + } + } + + #region Create Prefab from GameObject + + /// + /// Creates a prefab asset from a GameObject in the scene. + /// + private static object CreatePrefabFromGameObject(JObject @params) + { + // 1. Validate and parse parameters + var validation = ValidateCreatePrefabParams(@params); + if (!validation.isValid) + { + return new ErrorResponse(validation.errorMessage); + } + + string targetName = validation.targetName; + string finalPath = validation.finalPath; + bool includeInactive = validation.includeInactive; + bool replaceExisting = validation.replaceExisting; + bool unlinkIfInstance = validation.unlinkIfInstance; + + // 2. Find the source object + GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); + if (sourceObject == null) + { + return new ErrorResponse($"GameObject '{targetName}' not found in the active scene or prefab stage{(includeInactive ? " (including inactive objects)" : "")}."); + } + + // 3. Validate source object state + var objectValidation = ValidateSourceObjectForPrefab(sourceObject, unlinkIfInstance); + if (!objectValidation.isValid) + { + return new ErrorResponse(objectValidation.errorMessage); + } + + // 4. Check for path conflicts and track if file will be replaced + bool fileExistedAtPath = AssetDatabase.LoadAssetAtPath(finalPath) != null; + + if (!replaceExisting && fileExistedAtPath) + { + finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); + McpLog.Info($"[ManagePrefabs] Generated unique path: {finalPath}"); + } + + // 5. Ensure directory exists + EnsureAssetDirectoryExists(finalPath); + + // 6. Unlink from existing prefab if needed + if (unlinkIfInstance && objectValidation.shouldUnlink) + { + try + { + // UnpackPrefabInstance requires the prefab instance root, not a child object + GameObject rootToUnlink = PrefabUtility.GetOutermostPrefabInstanceRoot(sourceObject); + if (rootToUnlink != null) + { + PrefabUtility.UnpackPrefabInstance(rootToUnlink, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + McpLog.Info($"[ManagePrefabs] Unpacked prefab instance '{rootToUnlink.name}' before creating new prefab."); + } + } + catch (Exception e) + { + return new ErrorResponse($"Failed to unlink prefab instance: {e.Message}"); + } + } + + // 7. Create the prefab + try + { + GameObject result = CreatePrefabAsset(sourceObject, finalPath, replaceExisting); + + if (result == null) + { + return new ErrorResponse($"Failed to create prefab asset at '{finalPath}'."); + } + + // 8. Select the newly created instance + Selection.activeGameObject = result; + + return new SuccessResponse( + $"Prefab created at '{finalPath}' and instance linked.", + new + { + prefabPath = finalPath, + instanceId = result.GetInstanceID(), + instanceName = result.name, + wasUnlinked = unlinkIfInstance && objectValidation.shouldUnlink, + wasReplaced = replaceExisting && fileExistedAtPath, + componentCount = result.GetComponents().Length, + childCount = result.transform.childCount + } + ); + } + catch (Exception e) + { + McpLog.Error($"[ManagePrefabs] Error creating prefab at '{finalPath}': {e}"); + return new ErrorResponse($"Error saving prefab asset: {e.Message}"); + } + } + + /// + /// Validates parameters for creating a prefab from GameObject. + /// + private static (bool isValid, string errorMessage, string targetName, string finalPath, bool includeInactive, bool replaceExisting, bool unlinkIfInstance) + ValidateCreatePrefabParams(JObject @params) + { + string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); + if (string.IsNullOrEmpty(targetName)) + { + return (false, "'target' parameter is required for create_from_gameobject.", null, null, false, false, false); + } + + string requestedPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return (false, "'prefabPath' parameter is required for create_from_gameobject.", targetName, null, false, false, false); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (sanitizedPath == null) + { + return (false, $"Invalid prefab path (path traversal detected): '{requestedPath}'", targetName, null, false, false, false); + } + if (string.IsNullOrEmpty(sanitizedPath)) + { + return (false, $"Invalid prefab path '{requestedPath}'. Path cannot be empty.", targetName, null, false, false, false); + } + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + sanitizedPath += ".prefab"; + } + + // Validate path is within Assets folder + if (!sanitizedPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return (false, $"Prefab path must be within the Assets folder. Got: '{sanitizedPath}'", targetName, null, false, false, false); + } + + bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; + bool replaceExisting = @params["allowOverwrite"]?.ToObject() ?? false; + bool unlinkIfInstance = @params["unlinkIfInstance"]?.ToObject() ?? false; + + return (true, null, targetName, sanitizedPath, includeInactive, replaceExisting, unlinkIfInstance); + } + + /// + /// Validates source object can be converted to prefab. + /// + private static (bool isValid, string errorMessage, bool shouldUnlink, string existingPrefabPath) + ValidateSourceObjectForPrefab(GameObject sourceObject, bool unlinkIfInstance) + { + // Check if this is a Prefab Asset (the .prefab file itself in the editor) + if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + { + return (false, + $"GameObject '{sourceObject.name}' is part of a prefab asset. " + + "Open the prefab stage to save changes instead.", + false, null); + } + + // Check if this is already a Prefab Instance + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); + if (status != PrefabInstanceStatus.NotAPrefab) + { + string existingPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(sourceObject); + + if (!unlinkIfInstance) + { + return (false, + $"GameObject '{sourceObject.name}' is already linked to prefab '{existingPath}'. " + + "Set 'unlinkIfInstance' to true to unlink it first, or modify the existing prefab instead.", + false, existingPath); + } + + // Needs to be unlinked + return (true, null, true, existingPath); + } + + return (true, null, false, null); + } + + /// + /// Creates a prefab asset from a GameObject. + /// + private static GameObject CreatePrefabAsset(GameObject sourceObject, string path, bool replaceExisting) + { + GameObject result = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, + path, + InteractionMode.AutomatedAction + ); + + string action = replaceExisting ? "Replaced existing" : "Created new"; + McpLog.Info($"[ManagePrefabs] {action} prefab at '{path}'."); + + if (result != null) + { + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + return result; + } + + #endregion + + /// + /// Ensures the directory for an asset path exists, creating it if necessary. + /// + private static void EnsureAssetDirectoryExists(string assetPath) + { + string directory = Path.GetDirectoryName(assetPath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + // Use Application.dataPath for more reliable path resolution + // Application.dataPath points to the Assets folder (e.g., ".../ProjectName/Assets") + string assetsPath = Application.dataPath; + string projectRoot = Path.GetDirectoryName(assetsPath); + string fullDirectory = Path.Combine(projectRoot, directory); + + if (!Directory.Exists(fullDirectory)) + { + Directory.CreateDirectory(fullDirectory); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + McpLog.Info($"[ManagePrefabs] Created directory: {directory}"); + } + } + + /// + /// Finds a GameObject by name in the active scene or current prefab stage. + /// + private static GameObject FindSceneObjectByName(string name, bool includeInactive) + { + // First check if we're in Prefab Stage + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage?.prefabContentsRoot != null) + { + foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren(includeInactive)) + { + if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) + { + return transform.gameObject; + } + } + } + + // Search in the active scene + Scene activeScene = SceneManager.GetActiveScene(); + foreach (GameObject root in activeScene.GetRootGameObjects()) + { + // Check the root object itself + if (root.name == name && (includeInactive || root.activeSelf)) + { + return root; + } + + // Check children + foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) + { + if (transform.name == name && (includeInactive || transform.gameObject.activeSelf)) + { + return transform.gameObject; + } + } + } + + return null; + } + + #region Read Operations + + /// + /// Gets basic metadata information about a prefab asset. + /// + private static object GetInfo(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for get_info."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path: '{prefabPath}'."); + } + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (prefabAsset == null) + { + return new ErrorResponse($"No prefab asset found at path '{sanitizedPath}'."); + } + + string guid = PrefabUtilityHelper.GetPrefabGUID(sanitizedPath); + PrefabAssetType assetType = PrefabUtility.GetPrefabAssetType(prefabAsset); + string prefabTypeString = assetType.ToString(); + var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(prefabAsset); + int childCount = PrefabUtilityHelper.CountChildrenRecursive(prefabAsset.transform); + var (isVariant, parentPrefab, _) = PrefabUtilityHelper.GetVariantInfo(prefabAsset); + + return new SuccessResponse( + $"Successfully retrieved prefab info.", + new + { + assetPath = sanitizedPath, + guid = guid, + prefabType = prefabTypeString, + rootObjectName = prefabAsset.name, + rootComponentTypes = componentTypes, + childCount = childCount, + isVariant = isVariant, + parentPrefab = parentPrefab + } + ); + } + + /// + /// Gets the hierarchical structure of a prefab asset. + /// Returns all objects in the prefab for full client-side filtering and search. + /// + private static object GetHierarchy(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for get_hierarchy."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed."); + } + + // Load prefab contents in background (without opening stage UI) + GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath); + if (prefabContents == null) + { + return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'."); + } + + try + { + // Build complete hierarchy items (no pagination) + var allItems = BuildHierarchyItems(prefabContents.transform, sanitizedPath); + + return new SuccessResponse( + $"Successfully retrieved prefab hierarchy. Found {allItems.Count} objects.", + new + { + prefabPath = sanitizedPath, + total = allItems.Count, + items = allItems + } + ); + } + finally + { + // Always unload prefab contents to free memory + PrefabUtility.UnloadPrefabContents(prefabContents); + } + } + + #endregion + + #region Headless Prefab Editing + + /// + /// Modifies a prefab's contents directly without opening the prefab stage. + /// This is ideal for automated/agentic workflows as it avoids UI, dirty flags, and dialogs. + /// + private static object ModifyContents(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString() ?? @params["path"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return new ErrorResponse("'prefabPath' parameter is required for modify_contents."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + if (string.IsNullOrEmpty(sanitizedPath)) + { + return new ErrorResponse($"Invalid prefab path '{prefabPath}'. Path traversal sequences are not allowed."); + } + + // Load prefab contents in isolated context (no UI) + GameObject prefabContents = PrefabUtility.LoadPrefabContents(sanitizedPath); + if (prefabContents == null) + { + return new ErrorResponse($"Failed to load prefab contents from '{sanitizedPath}'."); + } + + try + { + // Find target object within the prefab (defaults to root) + string targetName = @params["target"]?.ToString(); + GameObject targetGo = FindInPrefabContents(prefabContents, targetName); + + if (targetGo == null) + { + string searchedFor = string.IsNullOrEmpty(targetName) ? "root" : $"'{targetName}'"; + return new ErrorResponse($"Target {searchedFor} not found in prefab '{sanitizedPath}'."); + } + + // Apply modifications + var modifyResult = ApplyModificationsToPrefabObject(targetGo, @params, prefabContents); + if (modifyResult.error != null) + { + return modifyResult.error; + } + + // Skip saving when no modifications were made to avoid unnecessary asset writes + if (!modifyResult.modified) + { + return new SuccessResponse( + $"Prefab '{sanitizedPath}' is already up to date; no changes were applied.", + new + { + prefabPath = sanitizedPath, + targetName = targetGo.name, + modified = false + } + ); + } + + // Save the prefab + bool success; + PrefabUtility.SaveAsPrefabAsset(prefabContents, sanitizedPath, out success); + + if (!success) + { + return new ErrorResponse($"Failed to save prefab asset at '{sanitizedPath}'."); + } + + AssetDatabase.Refresh(); + + McpLog.Info($"[ManagePrefabs] Successfully modified and saved prefab '{sanitizedPath}' (headless)."); + + return new SuccessResponse( + $"Prefab '{sanitizedPath}' modified and saved successfully.", + new + { + prefabPath = sanitizedPath, + targetName = targetGo.name, + modified = modifyResult.modified, + transform = new + { + position = new { x = targetGo.transform.localPosition.x, y = targetGo.transform.localPosition.y, z = targetGo.transform.localPosition.z }, + rotation = new { x = targetGo.transform.localEulerAngles.x, y = targetGo.transform.localEulerAngles.y, z = targetGo.transform.localEulerAngles.z }, + scale = new { x = targetGo.transform.localScale.x, y = targetGo.transform.localScale.y, z = targetGo.transform.localScale.z } + }, + componentTypes = PrefabUtilityHelper.GetComponentTypeNames(targetGo) + } + ); + } + finally + { + // Always unload prefab contents to free memory + PrefabUtility.UnloadPrefabContents(prefabContents); + } + } + + /// + /// Finds a GameObject within loaded prefab contents by name or path. + /// + private static GameObject FindInPrefabContents(GameObject prefabContents, string target) + { + if (string.IsNullOrEmpty(target)) + { + // Return root if no target specified + return prefabContents; + } + + // Try to find by path first (e.g., "Parent/Child/Target") + if (target.Contains("/")) + { + Transform found = prefabContents.transform.Find(target); + if (found != null) + { + return found.gameObject; + } + + // If path starts with root name, try without it + if (target.StartsWith(prefabContents.name + "/")) + { + string relativePath = target.Substring(prefabContents.name.Length + 1); + found = prefabContents.transform.Find(relativePath); + if (found != null) + { + return found.gameObject; + } + } + } + + // Check if target matches root name + if (prefabContents.name == target) + { + return prefabContents; + } + + // Search by name in hierarchy + foreach (Transform t in prefabContents.GetComponentsInChildren(true)) + { + if (t.gameObject.name == target) + { + return t.gameObject; + } + } + + return null; + } + + /// + /// Applies modifications to a GameObject within loaded prefab contents. + /// Returns (modified: bool, error: ErrorResponse or null). + /// + private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabObject(GameObject targetGo, JObject @params, GameObject prefabRoot) + { + bool modified = false; + + // Name change + string newName = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(newName) && targetGo.name != newName) + { + // If renaming the root, this will affect the prefab asset name on save + targetGo.name = newName; + modified = true; + } + + // Active state + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + // Tag + string tag = @params["tag"]?.ToString(); + if (tag != null && targetGo.tag != tag) + { + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + targetGo.tag = tagToSet; + modified = true; + } + catch (Exception ex) + { + return (false, new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}")); + } + } + + // Layer + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1) + { + return (false, new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name.")); + } + if (targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + // Transform: position, rotation, scale + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + // Parent change (within prefab hierarchy) + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + string parentTarget = parentToken.ToString(); + Transform newParent = null; + + if (!string.IsNullOrEmpty(parentTarget)) + { + GameObject parentGo = FindInPrefabContents(prefabRoot, parentTarget); + if (parentGo == null) + { + return (false, new ErrorResponse($"Parent '{parentTarget}' not found in prefab.")); + } + if (parentGo.transform.IsChildOf(targetGo.transform)) + { + return (false, new ErrorResponse($"Cannot parent '{targetGo.name}' to '{parentGo.name}' as it would create a hierarchy loop.")); + } + newParent = parentGo.transform; + } + + if (targetGo.transform.parent != newParent) + { + targetGo.transform.SetParent(newParent, true); + modified = true; + } + } + + // Components to add + if (@params["componentsToAdd"] is JArray componentsToAdd) + { + foreach (var compToken in componentsToAdd) + { + string typeName = compToken.Type == JTokenType.String + ? compToken.ToString() + : (compToken as JObject)?["typeName"]?.ToString(); + + if (!string.IsNullOrEmpty(typeName)) + { + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}")); + } + targetGo.AddComponent(componentType); + modified = true; + } + } + } + + // Components to remove + if (@params["componentsToRemove"] is JArray componentsToRemove) + { + foreach (var compToken in componentsToRemove) + { + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + return (false, new ErrorResponse($"Component type '{typeName}' not found: {error}")); + } + Component comp = targetGo.GetComponent(componentType); + if (comp != null) + { + UnityEngine.Object.DestroyImmediate(comp); + modified = true; + } + } + } + } + + // Create child GameObjects (supports single object or array) + JToken createChildToken = @params["createChild"] ?? @params["create_child"]; + if (createChildToken != null) + { + // Handle array of children + if (createChildToken is JArray childArray) + { + foreach (var childToken in childArray) + { + var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + else + { + // Handle single child object + var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + + return (modified, null); + } + + /// + /// Creates a single child GameObject within the prefab contents. + /// + private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot) + { + JObject childParams; + if (createChildToken is JObject obj) + { + childParams = obj; + } + else + { + return (false, new ErrorResponse("'create_child' must be an object with child properties.")); + } + + // Required: name + string childName = childParams["name"]?.ToString(); + if (string.IsNullOrEmpty(childName)) + { + return (false, new ErrorResponse("'create_child.name' is required.")); + } + + // Optional: parent (defaults to the target object) + string parentName = childParams["parent"]?.ToString(); + Transform parentTransform = defaultParent.transform; + if (!string.IsNullOrEmpty(parentName)) + { + GameObject parentGo = FindInPrefabContents(prefabRoot, parentName); + if (parentGo == null) + { + return (false, new ErrorResponse($"Parent '{parentName}' not found in prefab for create_child.")); + } + parentTransform = parentGo.transform; + } + + // Create the GameObject + GameObject newChild; + string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString(); + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newChild = GameObject.CreatePrimitive(type); + newChild.name = childName; + } + catch (ArgumentException) + { + return (false, new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}")); + } + } + else + { + newChild = new GameObject(childName); + } + + // Set parent + newChild.transform.SetParent(parentTransform, false); + + // Apply transform properties + Vector3? position = VectorParsing.ParseVector3(childParams["position"]); + Vector3? rotation = VectorParsing.ParseVector3(childParams["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(childParams["scale"]); + + if (position.HasValue) + { + newChild.transform.localPosition = position.Value; + } + if (rotation.HasValue) + { + newChild.transform.localEulerAngles = rotation.Value; + } + if (scale.HasValue) + { + newChild.transform.localScale = scale.Value; + } + + // Add components + JArray componentsToAdd = childParams["componentsToAdd"] as JArray ?? childParams["components_to_add"] as JArray; + if (componentsToAdd != null) + { + for (int i = 0; i < componentsToAdd.Count; i++) + { + var compToken = componentsToAdd[i]; + string typeName = compToken.Type == JTokenType.String + ? compToken.ToString() + : (compToken as JObject)?["typeName"]?.ToString(); + + if (string.IsNullOrEmpty(typeName)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"create_child.components_to_add[{i}] must be a string or object with 'typeName' field, got {compToken.Type}")); + } + + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}")); + } + newChild.AddComponent(componentType); + } + } + + // Set tag if specified + string tag = childParams["tag"]?.ToString(); + if (!string.IsNullOrEmpty(tag)) + { + try + { + newChild.tag = tag; + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Failed to set tag '{tag}' on child '{childName}': {ex.Message}")); + } + } + + // Set layer if specified + string layerName = childParams["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1) + { + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Invalid layer '{layerName}' for child '{childName}'. Use a valid layer name.")); + } + newChild.layer = layerId; + } + + // Set active state + bool? setActive = childParams["setActive"]?.ToObject() ?? childParams["set_active"]?.ToObject(); + if (setActive.HasValue) + { + newChild.SetActive(setActive.Value); + } + + McpLog.Info($"[ManagePrefabs] Created child '{childName}' under '{parentTransform.name}' in prefab."); + return (true, null); + } + + #endregion + + #region Hierarchy Builder + + /// + /// Builds a flat list of hierarchy items from a transform root. + /// + /// The root transform of the prefab. + /// Asset path of the main prefab. + /// List of hierarchy items with prefab information. + private static List BuildHierarchyItems(Transform root, string mainPrefabPath) + { + var items = new List(); + BuildHierarchyItemsRecursive(root, root, mainPrefabPath, "", items); + return items; + } + + /// + /// Recursively builds hierarchy items. + /// + /// Current transform being processed. + /// Root transform of the main prefab asset. + /// Asset path of the main prefab. + /// Parent path for building full hierarchy path. + /// List to accumulate hierarchy items. + private static void BuildHierarchyItemsRecursive(Transform transform, Transform mainPrefabRoot, string mainPrefabPath, string parentPath, List items) + { + if (transform == null) return; + + string name = transform.gameObject.name; + string path = string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}"; + int instanceId = transform.gameObject.GetInstanceID(); + bool activeSelf = transform.gameObject.activeSelf; + int childCount = transform.childCount; + var componentTypes = PrefabUtilityHelper.GetComponentTypeNames(transform.gameObject); + + // Prefab information + bool isNestedPrefab = PrefabUtility.IsAnyPrefabInstanceRoot(transform.gameObject); + bool isPrefabRoot = transform == mainPrefabRoot; + int nestingDepth = isPrefabRoot ? 0 : PrefabUtilityHelper.GetPrefabNestingDepth(transform.gameObject, mainPrefabRoot); + string parentPrefabPath = isNestedPrefab && !isPrefabRoot + ? PrefabUtilityHelper.GetParentPrefabPath(transform.gameObject, mainPrefabRoot) + : null; + string nestedPrefabPath = isNestedPrefab ? PrefabUtilityHelper.GetNestedPrefabPath(transform.gameObject) : null; + + var item = new + { + name = name, + instanceId = instanceId, + path = path, + activeSelf = activeSelf, + childCount = childCount, + componentTypes = componentTypes, + prefab = new + { + isRoot = isPrefabRoot, + isNestedRoot = isNestedPrefab, + nestingDepth = nestingDepth, + assetPath = isNestedPrefab ? nestedPrefabPath : mainPrefabPath, + parentPath = parentPrefabPath + } + }; + + items.Add(item); + + // Recursively process children + foreach (Transform child in transform) + { + BuildHierarchyItemsRecursive(child, mainPrefabRoot, mainPrefabPath, path, items); + } + } + + #endregion + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta b/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta new file mode 100644 index 0000000..0a87366 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c14e76b2aa7bb4570a88903b061e946e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs b/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs new file mode 100644 index 0000000..342f7b1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; // For Response class +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles reading and clearing Unity Editor console log entries. + /// Uses reflection to access internal LogEntry methods/properties. + /// + [McpForUnityTool("read_console", AutoRegister = false)] + public static class ReadConsole + { + // (Calibration removed) + + // Reflection members for accessing internal LogEntry data + // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection + private static MethodInfo _startGettingEntriesMethod; + private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... + private static MethodInfo _clearMethod; + private static MethodInfo _getCountMethod; + private static MethodInfo _getEntryMethod; + private static FieldInfo _modeField; + private static FieldInfo _messageField; + private static FieldInfo _fileField; + private static FieldInfo _lineField; + private static FieldInfo _instanceIdField; + + // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? + + // Static constructor for reflection setup + static ReadConsole() + { + try + { + Type logEntriesType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntries" + ); + if (logEntriesType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntries"); + + + + // Include NonPublic binding flags as internal APIs might change accessibility + BindingFlags staticFlags = + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + BindingFlags instanceFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + _startGettingEntriesMethod = logEntriesType.GetMethod( + "StartGettingEntries", + staticFlags + ); + if (_startGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); + + // Try reflecting EndGettingEntries based on warning message + _endGettingEntriesMethod = logEntriesType.GetMethod( + "EndGettingEntries", + staticFlags + ); + if (_endGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); + + _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); + if (_clearMethod == null) + throw new Exception("Failed to reflect LogEntries.Clear"); + + _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); + if (_getCountMethod == null) + throw new Exception("Failed to reflect LogEntries.GetCount"); + + _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); + if (_getEntryMethod == null) + throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); + + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntry"); + + _modeField = logEntryType.GetField("mode", instanceFlags); + if (_modeField == null) + throw new Exception("Failed to reflect LogEntry.mode"); + + _messageField = logEntryType.GetField("message", instanceFlags); + if (_messageField == null) + throw new Exception("Failed to reflect LogEntry.message"); + + _fileField = logEntryType.GetField("file", instanceFlags); + if (_fileField == null) + throw new Exception("Failed to reflect LogEntry.file"); + + _lineField = logEntryType.GetField("line", instanceFlags); + if (_lineField == null) + throw new Exception("Failed to reflect LogEntry.line"); + + _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); + if (_instanceIdField == null) + throw new Exception("Failed to reflect LogEntry.instanceID"); + + // (Calibration removed) + + } + catch (Exception e) + { + McpLog.Error( + $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" + ); + // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. + _startGettingEntriesMethod = + _endGettingEntriesMethod = + _clearMethod = + _getCountMethod = + _getEntryMethod = + null; + _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; + } + } + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + // Check if ALL required reflection members were successfully initialized. + if ( + _startGettingEntriesMethod == null + || _endGettingEntriesMethod == null + || _clearMethod == null + || _getCountMethod == null + || _getEntryMethod == null + || _modeField == null + || _messageField == null + || _fileField == null + || _lineField == null + || _instanceIdField == null + ) + { + // Log the error here as well for easier debugging in Unity Console + McpLog.Error( + "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." + ); + return new ErrorResponse( + "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." + ); + } + + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + var p = new ToolParams(@params); + string action = p.Get("action", "get").ToLower(); + + try + { + if (action == "clear") + { + return ClearConsole(); + } + else if (action == "get") + { + // Extract parameters for 'get' + var types = + (p.GetRaw("types") as JArray)?.Select(t => t.ToString().ToLower()).ToList() + ?? new List { "error", "warning" }; + int? count = p.GetInt("count"); + int? pageSize = p.GetInt("pageSize"); + int? cursor = p.GetInt("cursor"); + string filterText = p.Get("filterText"); + string sinceTimestampStr = p.Get("sinceTimestamp"); // TODO: Implement timestamp filtering + string format = p.Get("format", "plain").ToLower(); + bool includeStacktrace = p.GetBool("includeStacktrace", false); + + if (types.Contains("all")) + { + types = new List { "error", "warning", "log" }; // Expand 'all' + } + + if (!string.IsNullOrEmpty(sinceTimestampStr)) + { + McpLog.Warn( + "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." + ); + // Need a way to get timestamp per log entry. + } + + return GetConsoleEntries( + types, + count, + pageSize, + cursor, + filterText, + format, + includeStacktrace + ); + } + else + { + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." + ); + } + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object ClearConsole() + { + try + { + _clearMethod.Invoke(null, null); // Static method, no instance, no parameters + return new SuccessResponse("Console cleared successfully."); + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Failed to clear console: {e}"); + return new ErrorResponse($"Failed to clear console: {e.Message}"); + } + } + + /// + /// Retrieves console log entries with optional filtering and paging. + /// + /// Log types to include (e.g., "error", "warning", "log"). + /// Maximum entries to return in non-paging mode. Ignored when paging is active. + /// Number of entries per page. Defaults to 50 when omitted. + /// Starting index for paging (0-based). Defaults to 0. + /// Optional text filter (case-insensitive substring match). + /// Output format: "plain", "detailed", or "json". + /// Whether to include stack traces in the output. + /// A success response with entries, or an error response. + private static object GetConsoleEntries( + List types, + int? count, + int? pageSize, + int? cursor, + string filterText, + string format, + bool includeStacktrace + ) + { + List formattedEntries = new List(); + int retrievedCount = 0; + int totalMatches = 0; + bool usePaging = pageSize.HasValue || cursor.HasValue; + // pageSize defaults to 50 when omitted; count is the overall non-paging limit only + int resolvedPageSize = Mathf.Clamp(pageSize ?? 50, 1, 500); + int resolvedCursor = Mathf.Max(0, cursor ?? 0); + int pageEndExclusive = resolvedCursor + resolvedPageSize; + + try + { + // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal + _startGettingEntriesMethod.Invoke(null, null); + + int totalEntries = (int)_getCountMethod.Invoke(null, null); + // Create instance to pass to GetEntryInternal - Ensure the type is correct + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception( + "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." + ); + object logEntryInstance = Activator.CreateInstance(logEntryType); + + for (int i = 0; i < totalEntries; i++) + { + // Get the entry data into our instance using reflection + _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); + + // Extract data using reflection + int mode = (int)_modeField.GetValue(logEntryInstance); + string message = (string)_messageField.GetValue(logEntryInstance); + string file = (string)_fileField.GetValue(logEntryInstance); + + int line = (int)_lineField.GetValue(logEntryInstance); + // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); + + if (string.IsNullOrEmpty(message)) + { + continue; // Skip empty messages + } + + // (Calibration removed) + + // --- Filtering --- + // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed + LogType unityType = InferTypeFromMessage(message); + bool isExplicitDebug = IsExplicitDebugLog(message); + if (!isExplicitDebug && unityType == LogType.Log) + { + unityType = GetLogTypeFromMode(mode); + } + + bool want; + // Treat Exception/Assert as errors for filtering convenience + if (unityType == LogType.Exception) + { + want = types.Contains("error") || types.Contains("exception"); + } + else if (unityType == LogType.Assert) + { + want = types.Contains("error") || types.Contains("assert"); + } + else + { + want = types.Contains(unityType.ToString().ToLowerInvariant()); + } + + if (!want) continue; + + // Filter by text (case-insensitive) + if ( + !string.IsNullOrEmpty(filterText) + && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 + ) + { + continue; + } + + // TODO: Filter by timestamp (requires timestamp data) + + // --- Formatting --- + string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; + // Always get first line for the message, use full message only if no stack trace exists + string[] messageLines = message.Split( + new[] { '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries + ); + string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; + + // If not including stacktrace, ensure we only show the first line + if (!includeStacktrace) + { + stackTrace = null; + } + + object formattedEntry = null; + switch (format) + { + case "plain": + formattedEntry = messageOnly; + break; + case "json": + case "detailed": // Treat detailed as json for structured return + default: + formattedEntry = new + { + type = unityType.ToString(), + message = messageOnly, + file = file, + line = line, + // timestamp = "", // TODO + stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found + }; + break; + } + + totalMatches++; + + if (usePaging) + { + if (totalMatches > resolvedCursor && totalMatches <= pageEndExclusive) + { + formattedEntries.Add(formattedEntry); + retrievedCount++; + } + // Early exit: we've filled the page and only need to check if more exist + else if (totalMatches > pageEndExclusive) + { + // We've passed the page; totalMatches now indicates truncation + break; + } + } + else + { + formattedEntries.Add(formattedEntry); + retrievedCount++; + + // Apply count limit (after filtering) + if (count.HasValue && retrievedCount >= count.Value) + { + break; + } + } + } + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Error while retrieving log entries: {e}"); + // EndGettingEntries will be called in the finally block + return new ErrorResponse($"Error retrieving log entries: {e.Message}"); + } + finally + { + // Ensure we always call EndGettingEntries + try + { + _endGettingEntriesMethod.Invoke(null, null); + } + catch (Exception e) + { + McpLog.Error($"[ReadConsole] Failed to call EndGettingEntries: {e}"); + // Don't return error here as we might have valid data, but log it. + } + } + + if (usePaging) + { + bool truncated = totalMatches > pageEndExclusive; + string nextCursor = truncated ? pageEndExclusive.ToString() : null; + var payload = new + { + cursor = resolvedCursor, + pageSize = resolvedPageSize, + nextCursor = nextCursor, + truncated = truncated, + total = totalMatches, + items = formattedEntries, + }; + + return new SuccessResponse( + $"Retrieved {formattedEntries.Count} log entries.", + payload + ); + } + + // Return the filtered and formatted list (might be empty) + return new SuccessResponse( + $"Retrieved {formattedEntries.Count} log entries.", + formattedEntries + ); + } + + // --- Internal Helpers --- + + // Mapping bits from LogEntry.mode. These may vary by Unity version. + private const int ModeBitError = 1 << 0; + private const int ModeBitAssert = 1 << 1; + private const int ModeBitWarning = 1 << 2; + private const int ModeBitLog = 1 << 3; + private const int ModeBitException = 1 << 4; // often combined with Error bits + private const int ModeBitScriptingError = 1 << 9; + private const int ModeBitScriptingWarning = 1 << 10; + private const int ModeBitScriptingLog = 1 << 11; + private const int ModeBitScriptingException = 1 << 18; + private const int ModeBitScriptingAssertion = 1 << 22; + + private static LogType GetLogTypeFromMode(int mode) + { + // Preserve Unity's real type (no remapping); bits may vary by version + if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; + if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; + if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; + if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; + return LogType.Log; + } + + // (Calibration helpers removed) + + /// + /// Classifies severity using message/stacktrace content. Works across Unity versions. + /// + private static LogType InferTypeFromMessage(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; + + // Fast path: look for explicit Debug API names in the appended stack trace + // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" + if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + + // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" + if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + + // Exceptions (avoid misclassifying compiler diagnostics) + if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Exception; + + // Unity assertions + if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Assert; + + return LogType.Log; + } + + private static bool IsExplicitDebugLog(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return false; + if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; + } + + /// + /// Applies the "one level lower" remapping for filtering, like the old version. + /// This ensures compatibility with the filtering logic that expects remapped types. + /// + private static LogType GetRemappedTypeForFiltering(LogType unityType) + { + switch (unityType) + { + case LogType.Error: + return LogType.Warning; // Error becomes Warning + case LogType.Warning: + return LogType.Log; // Warning becomes Log + case LogType.Assert: + return LogType.Assert; // Assert remains Assert + case LogType.Log: + return LogType.Log; // Log remains Log + case LogType.Exception: + return LogType.Warning; // Exception becomes Warning + default: + return LogType.Log; // Default fallback + } + } + + /// + /// Attempts to extract the stack trace part from a log message. + /// Unity log messages often have the stack trace appended after the main message, + /// starting on a new line and typically indented or beginning with "at ". + /// + /// The complete log message including potential stack trace. + /// The extracted stack trace string, or null if none is found. + private static string ExtractStackTrace(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) + return null; + + // Split into lines, removing empty ones to handle different line endings gracefully. + // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. + string[] lines = fullMessage.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries + ); + + // If there's only one line or less, there's no separate stack trace. + if (lines.Length <= 1) + return null; + + int stackStartIndex = -1; + + // Start checking from the second line onwards. + for (int i = 1; i < lines.Length; ++i) + { + // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. + string trimmedLine = lines[i].TrimStart(); + + // Check for common stack trace patterns. + if ( + trimmedLine.StartsWith("at ") + || trimmedLine.StartsWith("UnityEngine.") + || trimmedLine.StartsWith("UnityEditor.") + || trimmedLine.Contains("(at ") + || // Covers "(at Assets/..." pattern + // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) + ( + trimmedLine.Length > 0 + && char.IsUpper(trimmedLine[0]) + && trimmedLine.Contains('.') + ) + ) + { + stackStartIndex = i; + break; // Found the likely start of the stack trace + } + } + + // If a potential start index was found... + if (stackStartIndex > 0) + { + // Join the lines from the stack start index onwards using standard newline characters. + // This reconstructs the stack trace part of the message. + return string.Join("\n", lines.Skip(stackStartIndex)); + } + + // No clear stack trace found based on the patterns. + return null; + } + + /* LogEntry.mode bits exploration (based on Unity decompilation/observation): + May change between versions. + + Basic Types: + kError = 1 << 0 (1) + kAssert = 1 << 1 (2) + kWarning = 1 << 2 (4) + kLog = 1 << 3 (8) + kFatal = 1 << 4 (16) - Often treated as Exception/Error + + Modifiers/Context: + kAssetImportError = 1 << 7 (128) + kAssetImportWarning = 1 << 8 (256) + kScriptingError = 1 << 9 (512) + kScriptingWarning = 1 << 10 (1024) + kScriptingLog = 1 << 11 (2048) + kScriptCompileError = 1 << 12 (4096) + kScriptCompileWarning = 1 << 13 (8192) + kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play + kMayIgnoreLineNumber = 1 << 15 (32768) + kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button + kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) + kScriptingException = 1 << 18 (262144) + kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI + kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior + kGraphCompileError = 1 << 21 (2097152) + kScriptingAssertion = 1 << 22 (4194304) + kVisualScriptingError = 1 << 23 (8388608) + + Example observed values: + Log: 2048 (ScriptingLog) or 8 (Log) + Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) + Error: 513 (ScriptingError | Error) or 1 (Error) + Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination + Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) + */ + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs.meta b/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs.meta new file mode 100644 index 0000000..6be1d6f --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/ReadConsole.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 46c4f3614ed61f547ba823f0b2790267 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/ReadConsole.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs b/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs new file mode 100644 index 0000000..537472a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -0,0 +1,171 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.Compilation; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Explicitly refreshes Unity's asset database and optionally requests a script compilation. + /// This is side-effectful and should be treated as a tool. + /// + [McpForUnityTool("refresh_unity", AutoRegister = false)] + public static class RefreshUnity + { + private const int DefaultWaitTimeoutSeconds = 60; + + public static async Task HandleCommand(JObject @params) + { + string mode = @params?["mode"]?.ToString() ?? "if_dirty"; + string scope = @params?["scope"]?.ToString() ?? "all"; + string compile = @params?["compile"]?.ToString() ?? "none"; + bool waitForReady = ParamCoercion.CoerceBool(@params?["wait_for_ready"], false); + + if (TestRunStatus.IsRunning) + { + return new ErrorResponse("tests_running", new + { + reason = "tests_running", + retry_after_ms = 5000 + }); + } + + bool refreshTriggered = false; + bool compileRequested = false; + + try + { + // Best-effort semantics: if_dirty currently behaves like force unless future dirty signals are added. + bool shouldRefresh = string.Equals(mode, "force", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "if_dirty", StringComparison.OrdinalIgnoreCase); + + if (shouldRefresh) + { + if (string.Equals(scope, "scripts", StringComparison.OrdinalIgnoreCase)) + { + // For scripts, requesting compilation is usually the meaningful action. + // We avoid a heavyweight full refresh by default. + } + else + { + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + refreshTriggered = true; + } + } + + if (string.Equals(compile, "request", StringComparison.OrdinalIgnoreCase)) + { + CompilationPipeline.RequestScriptCompilation(); + compileRequested = true; + } + + if (string.Equals(scope, "all", StringComparison.OrdinalIgnoreCase) && !refreshTriggered) + { + // If the caller asked for "all" and we skipped refresh above (e.g., scripts-only path), + // do a lightweight refresh now. Use ForceSynchronousImport to ensure the refresh + // completes before returning, preventing stalls when Unity is backgrounded. + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + refreshTriggered = true; + } + } + catch (Exception ex) + { + return new ErrorResponse($"refresh_failed: {ex.Message}"); + } + + // Unity 6+ fix: Skip wait_for_ready when compile was requested. + // The EditorApplication.update polling in WaitForUnityReadyAsync doesn't survive + // domain reloads properly in Unity 6+, causing infinite compilation loops. + // When compilation is requested, return immediately and let client poll editor_state. + // Earlier Unity versions retain the original behavior. +#if UNITY_6000_0_OR_NEWER + bool shouldWaitForReady = waitForReady && !compileRequested; +#else + bool shouldWaitForReady = waitForReady; +#endif + if (shouldWaitForReady) + { + try + { + await WaitForUnityReadyAsync( + TimeSpan.FromSeconds(DefaultWaitTimeoutSeconds)).ConfigureAwait(true); + } + catch (TimeoutException) + { + return new ErrorResponse("refresh_timeout_waiting_for_ready", new + { + refresh_triggered = refreshTriggered, + compile_requested = compileRequested, + resulting_state = "unknown", + }); + } + catch (Exception ex) + { + return new ErrorResponse($"refresh_wait_failed: {ex.Message}"); + } + } + + string resultingState = EditorApplication.isCompiling + ? "compiling" + : (EditorApplication.isUpdating ? "asset_import" : "idle"); + + return new SuccessResponse("Refresh requested.", new + { + refresh_triggered = refreshTriggered, + compile_requested = compileRequested, + resulting_state = resultingState, + hint = shouldWaitForReady + ? "Unity refresh completed; editor should be ready." + : "If Unity enters compilation/domain reload, poll editor_state until ready_for_tools is true." + }); + } + + private static Task WaitForUnityReadyAsync(TimeSpan timeout) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var start = DateTime.UtcNow; + + void Tick() + { + try + { + if (tcs.Task.IsCompleted) + { + EditorApplication.update -= Tick; + return; + } + + if ((DateTime.UtcNow - start) > timeout) + { + EditorApplication.update -= Tick; + tcs.TrySetException(new TimeoutException()); + return; + } + + if (!EditorApplication.isCompiling + && !EditorApplication.isUpdating + && !TestRunStatus.IsRunning + && !EditorApplication.isPlayingOrWillChangePlaymode) + { + EditorApplication.update -= Tick; + tcs.TrySetResult(true); + } + } + catch (Exception ex) + { + EditorApplication.update -= Tick; + tcs.TrySetException(ex); + } + } + + EditorApplication.update += Tick; + // Nudge Unity to pump once in case update is throttled. + try { EditorApplication.QueuePlayerLoopUpdate(); } catch { } + return tcs.Task; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs.meta b/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs.meta new file mode 100644 index 0000000..5b94eaa --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: c2c02170faca940d09c813706493ecb3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/RefreshUnity.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/RunTests.cs b/Assets/MCPForUnity/Editor/Tools/RunTests.cs new file mode 100644 index 0000000..e9b55f2 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/RunTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Resources.Tests; +using MCPForUnity.Editor.Services; +using Newtonsoft.Json.Linq; +using UnityEditor.TestTools.TestRunner.Api; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Starts a Unity Test Runner run asynchronously and returns a job id immediately. + /// Use get_test_job(job_id) to poll status/results. + /// + [McpForUnityTool("run_tests", AutoRegister = false)] + public static class RunTests + { + public static Task HandleCommand(JObject @params) + { + try + { + // Check for clear_stuck action first + if (ParamCoercion.CoerceBool(@params?["clear_stuck"], false)) + { + bool wasCleared = TestJobManager.ClearStuckJob(); + return Task.FromResult(new SuccessResponse( + wasCleared ? "Stuck job cleared." : "No running job to clear.", + new { cleared = wasCleared } + )); + } + + string modeStr = @params?["mode"]?.ToString(); + if (string.IsNullOrWhiteSpace(modeStr)) + { + modeStr = "EditMode"; + } + + if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) + { + return Task.FromResult(new ErrorResponse(parseError)); + } + + bool includeDetails = ParamCoercion.CoerceBool(@params?["includeDetails"], false); + bool includeFailedTests = ParamCoercion.CoerceBool(@params?["includeFailedTests"], false); + + var filterOptions = GetFilterOptions(@params); + string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions); + + return Task.FromResult(new SuccessResponse("Test job started.", new + { + job_id = jobId, + status = "running", + mode = parsedMode.Value.ToString(), + include_details = includeDetails, + include_failed_tests = includeFailedTests + })); + } + catch (Exception ex) + { + // Normalize the already-running case to a stable error token. + if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0) + { + return Task.FromResult(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 })); + } + return Task.FromResult(new ErrorResponse($"Failed to start test job: {ex.Message}")); + } + } + + private static TestFilterOptions GetFilterOptions(JObject @params) + { + if (@params == null) + { + return null; + } + + string[] ParseStringArray(string key) + { + var token = @params[key]; + if (token == null) return null; + if (token.Type == JTokenType.String) + { + var value = token.ToString(); + return string.IsNullOrWhiteSpace(value) ? null : new[] { value }; + } + if (token.Type == JTokenType.Array) + { + var array = token as JArray; + if (array == null || array.Count == 0) return null; + var values = array + .Values() + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + return values.Length > 0 ? values : null; + } + return null; + } + + var testNames = ParseStringArray("testNames"); + var groupNames = ParseStringArray("groupNames"); + var categoryNames = ParseStringArray("categoryNames"); + var assemblyNames = ParseStringArray("assemblyNames"); + + if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null) + { + return null; + } + + return new TestFilterOptions + { + TestNames = testNames, + GroupNames = groupNames, + CategoryNames = categoryNames, + AssemblyNames = assemblyNames + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/RunTests.cs.meta b/Assets/MCPForUnity/Editor/Tools/RunTests.cs.meta new file mode 100644 index 0000000..c30adc1 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/RunTests.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/RunTests.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx.meta b/Assets/MCPForUnity/Editor/Tools/Vfx.meta new file mode 100644 index 0000000..b128ae3 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1805768600c6a4228bae31231f2a4a9f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs new file mode 100644 index 0000000..fcaf55b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs @@ -0,0 +1,220 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class LineCreate + { + public static object CreateLine(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]); + Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]); + + Undo.RecordObject(lr, "Create Line"); + lr.positionCount = 2; + lr.SetPosition(0, start); + lr.SetPosition(1, end); + + RendererHelpers.EnsureMaterial(lr); + + // Apply optional width + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + lr.startWidth = w; + lr.endWidth = w; + } + if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject(); + if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject(); + + // Apply optional color + if (@params["color"] != null) + { + Color c = ManageVfxCommon.ParseColor(@params["color"]); + lr.startColor = c; + lr.endColor = c; + } + if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]); + if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]); + + EditorUtility.SetDirty(lr); + + return new { success = true, message = "Created line" }; + } + + public static object CreateCircle(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]); + float radius = @params["radius"]?.ToObject() ?? 1f; + int segments = @params["segments"]?.ToObject() ?? 32; + Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up; + + Vector3 right = Vector3.Cross(normal, Vector3.forward); + if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); + right = right.normalized; + Vector3 forward = Vector3.Cross(right, normal).normalized; + + Undo.RecordObject(lr, "Create Circle"); + lr.positionCount = segments; + lr.loop = true; + + for (int i = 0; i < segments; i++) + { + float angle = (float)i / segments * Mathf.PI * 2f; + Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; + lr.SetPosition(i, point); + } + + RendererHelpers.EnsureMaterial(lr); + + // Apply optional width + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + lr.startWidth = w; + lr.endWidth = w; + } + if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject(); + if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject(); + + // Apply optional color + if (@params["color"] != null) + { + Color c = ManageVfxCommon.ParseColor(@params["color"]); + lr.startColor = c; + lr.endColor = c; + } + if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]); + if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created circle with {segments} segments" }; + } + + public static object CreateArc(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]); + float radius = @params["radius"]?.ToObject() ?? 1f; + float startAngle = (@params["startAngle"]?.ToObject() ?? 0f) * Mathf.Deg2Rad; + float endAngle = (@params["endAngle"]?.ToObject() ?? 180f) * Mathf.Deg2Rad; + int segments = @params["segments"]?.ToObject() ?? 16; + Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up; + + Vector3 right = Vector3.Cross(normal, Vector3.forward); + if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); + right = right.normalized; + Vector3 forward = Vector3.Cross(right, normal).normalized; + + Undo.RecordObject(lr, "Create Arc"); + lr.positionCount = segments + 1; + lr.loop = false; + + for (int i = 0; i <= segments; i++) + { + float t = (float)i / segments; + float angle = Mathf.Lerp(startAngle, endAngle, t); + Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; + lr.SetPosition(i, point); + } + + RendererHelpers.EnsureMaterial(lr); + + // Apply optional width + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + lr.startWidth = w; + lr.endWidth = w; + } + if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject(); + if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject(); + + // Apply optional color + if (@params["color"] != null) + { + Color c = ManageVfxCommon.ParseColor(@params["color"]); + lr.startColor = c; + lr.endColor = c; + } + if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]); + if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created arc with {segments} segments" }; + } + + public static object CreateBezier(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]); + Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]); + Vector3 cp1 = ManageVfxCommon.ParseVector3(@params["controlPoint1"] ?? @params["control1"]); + Vector3 cp2 = @params["controlPoint2"] != null || @params["control2"] != null + ? ManageVfxCommon.ParseVector3(@params["controlPoint2"] ?? @params["control2"]) + : cp1; + int segments = @params["segments"]?.ToObject() ?? 32; + bool isQuadratic = @params["controlPoint2"] == null && @params["control2"] == null; + + Undo.RecordObject(lr, "Create Bezier"); + lr.positionCount = segments + 1; + lr.loop = false; + + for (int i = 0; i <= segments; i++) + { + float t = (float)i / segments; + Vector3 point; + + if (isQuadratic) + { + float u = 1 - t; + point = u * u * start + 2 * u * t * cp1 + t * t * end; + } + else + { + float u = 1 - t; + point = u * u * u * start + 3 * u * u * t * cp1 + 3 * u * t * t * cp2 + t * t * t * end; + } + + lr.SetPosition(i, point); + } + + RendererHelpers.EnsureMaterial(lr); + + // Apply optional width + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + lr.startWidth = w; + lr.endWidth = w; + } + if (@params["startWidth"] != null) lr.startWidth = @params["startWidth"].ToObject(); + if (@params["endWidth"] != null) lr.endWidth = @params["endWidth"].ToObject(); + + // Apply optional color + if (@params["color"] != null) + { + Color c = ManageVfxCommon.ParseColor(@params["color"]); + lr.startColor = c; + lr.endColor = c; + } + if (@params["startColor"] != null) lr.startColor = ManageVfxCommon.ParseColor(@params["startColor"]); + if (@params["endColor"] != null) lr.endColor = ManageVfxCommon.ParseColor(@params["endColor"]); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created {(isQuadratic ? "quadratic" : "cubic")} Bezier" }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta new file mode 100644 index 0000000..ca49cb0 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 6d553d3837ecc4d999225bc9b3160a26 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs new file mode 100644 index 0000000..3dd0c06 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class LineRead + { + public static LineRenderer FindLineRenderer(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static object GetInfo(JObject @params) + { + LineRenderer lr = FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + var positions = new Vector3[lr.positionCount]; + lr.GetPositions(positions); + + return new + { + success = true, + data = new + { + gameObject = lr.gameObject.name, + positionCount = lr.positionCount, + positions = positions.Select(p => new { x = p.x, y = p.y, z = p.z }).ToArray(), + startWidth = lr.startWidth, + endWidth = lr.endWidth, + loop = lr.loop, + useWorldSpace = lr.useWorldSpace, + alignment = lr.alignment.ToString(), + textureMode = lr.textureMode.ToString(), + numCornerVertices = lr.numCornerVertices, + numCapVertices = lr.numCapVertices, + generateLightingData = lr.generateLightingData, + material = lr.sharedMaterial?.name, + shadowCastingMode = lr.shadowCastingMode.ToString(), + receiveShadows = lr.receiveShadows, + lightProbeUsage = lr.lightProbeUsage.ToString(), + reflectionProbeUsage = lr.reflectionProbeUsage.ToString(), + sortingOrder = lr.sortingOrder, + sortingLayerName = lr.sortingLayerName, + renderingLayerMask = lr.renderingLayerMask + } + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta new file mode 100644 index 0000000..c89d0ed --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: df77cf0ca14344b0cb2f1b84c5eb15e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/LineRead.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs new file mode 100644 index 0000000..339e9c8 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs @@ -0,0 +1,189 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class LineWrite + { + public static object SetPositions(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + JArray posArr = @params["positions"] as JArray; + if (posArr == null) return new { success = false, message = "Positions array required" }; + + var positions = new Vector3[posArr.Count]; + for (int i = 0; i < posArr.Count; i++) + { + positions[i] = ManageVfxCommon.ParseVector3(posArr[i]); + } + + Undo.RecordObject(lr, "Set Line Positions"); + lr.positionCount = positions.Length; + lr.SetPositions(positions); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Set {positions.Length} positions" }; + } + + public static object AddPosition(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + + Undo.RecordObject(lr, "Add Line Position"); + int idx = lr.positionCount; + lr.positionCount = idx + 1; + lr.SetPosition(idx, pos); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Added position at index {idx}", index = idx }; + } + + public static object SetPosition(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + int index = @params["index"]?.ToObject() ?? -1; + if (index < 0 || index >= lr.positionCount) return new { success = false, message = $"Invalid index {index}" }; + + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + + Undo.RecordObject(lr, "Set Line Position"); + lr.SetPosition(index, pos); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Set position at index {index}" }; + } + + public static object SetWidth(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + Undo.RecordObject(lr, "Set Line Width"); + var changes = new List(); + + RendererHelpers.ApplyWidthProperties(@params, changes, + v => lr.startWidth = v, v => lr.endWidth = v, + v => lr.widthCurve = v, v => lr.widthMultiplier = v, + ManageVfxCommon.ParseAnimationCurve); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetColor(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + Undo.RecordObject(lr, "Set Line Color"); + var changes = new List(); + + RendererHelpers.ApplyColorProperties(@params, changes, + v => lr.startColor = v, v => lr.endColor = v, + v => lr.colorGradient = v, + ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: false); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetMaterial(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + return RendererHelpers.SetRendererMaterial(lr, @params, "Set Line Material", ManageVfxCommon.FindMaterialByPath); + } + + public static object SetProperties(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + RendererHelpers.EnsureMaterial(lr); + + Undo.RecordObject(lr, "Set Line Properties"); + var changes = new List(); + + // Handle material if provided + if (@params["materialPath"] != null) + { + Material mat = ManageVfxCommon.FindMaterialByPath(@params["materialPath"].ToString()); + if (mat != null) + { + lr.sharedMaterial = mat; + changes.Add($"material={mat.name}"); + } + else + { + McpLog.Warn($"Material not found: {@params["materialPath"]}"); + } + } + + // Handle positions if provided + if (@params["positions"] != null) + { + JArray posArr = @params["positions"] as JArray; + if (posArr != null && posArr.Count > 0) + { + var positions = new Vector3[posArr.Count]; + for (int i = 0; i < posArr.Count; i++) + { + positions[i] = ManageVfxCommon.ParseVector3(posArr[i]); + } + lr.positionCount = positions.Length; + lr.SetPositions(positions); + changes.Add($"positions({positions.Length})"); + } + } + else if (@params["positionCount"] != null) + { + int count = @params["positionCount"].ToObject(); + lr.positionCount = count; + changes.Add("positionCount"); + } + + RendererHelpers.ApplyLineTrailProperties(@params, changes, + v => lr.loop = v, v => lr.useWorldSpace = v, + v => lr.numCornerVertices = v, v => lr.numCapVertices = v, + v => lr.alignment = v, v => lr.textureMode = v, + v => lr.generateLightingData = v); + + RendererHelpers.ApplyCommonRendererProperties(lr, @params, changes); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object Clear(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + int count = lr.positionCount; + Undo.RecordObject(lr, "Clear Line"); + lr.positionCount = 0; + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Cleared {count} positions" }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta new file mode 100644 index 0000000..a0ec5ad --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 3911acc5a6a6a494cb88a647e0426d67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs new file mode 100644 index 0000000..55e7b38 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEngine; +using UnityEditor; + +#if UNITY_VFX_GRAPH //Please enable the symbol in the project settings for VisualEffectGraph to work +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Tool for managing Unity VFX components: + /// - ParticleSystem (legacy particle effects) + /// - Visual Effect Graph (modern GPU particles, currently only support HDRP, other SRPs may not work) + /// - LineRenderer (lines, bezier curves, shapes) + /// - TrailRenderer (motion trails) + /// + /// COMPONENT REQUIREMENTS: + /// - particle_* actions require ParticleSystem component on target GameObject + /// - vfx_* actions require VisualEffect component (+ com.unity.visualeffectgraph package) + /// - line_* actions require LineRenderer component + /// - trail_* actions require TrailRenderer component + /// + /// TARGETING: + /// Use 'target' parameter with optional 'searchMethod': + /// - by_name (default): "Fire" finds first GameObject named "Fire" + /// - by_path: "Effects/Fire" finds GameObject at hierarchy path + /// - by_id: "12345" finds GameObject by instance ID (most reliable) + /// - by_tag: "Enemy" finds first GameObject with tag + /// + /// AUTOMATIC MATERIAL ASSIGNMENT: + /// VFX components (ParticleSystem, LineRenderer, TrailRenderer) automatically receive + /// appropriate default materials based on the active rendering pipeline when no material + /// is explicitly specified: + /// - Built-in Pipeline: Uses Unity's built-in Default-Particle.mat and Default-Line.mat + /// - URP/HDRP: Creates materials with pipeline-appropriate unlit shaders + /// - Materials are cached to avoid recreation + /// - Explicit materialPath parameter always overrides auto-assignment + /// - Auto-assigned materials are logged for transparency + /// + /// AVAILABLE ACTIONS: + /// + /// ParticleSystem (particle_*): + /// - particle_get_info: Get system info and current state + /// - particle_set_main: Set main module (duration, looping, startLifetime, startSpeed, startSize, startColor, gravityModifier, maxParticles, simulationSpace, playOnAwake, etc.) + /// - particle_set_emission: Set emission module (rateOverTime, rateOverDistance) + /// - particle_set_shape: Set shape module (shapeType, radius, angle, arc, position, rotation, scale) + /// - particle_set_color_over_lifetime: Set color gradient over particle lifetime + /// - particle_set_size_over_lifetime: Set size curve over particle lifetime + /// - particle_set_velocity_over_lifetime: Set velocity (x, y, z, speedModifier, space) + /// - particle_set_noise: Set noise turbulence (strength, frequency, scrollSpeed, damping, octaveCount, quality) + /// - particle_set_renderer: Set renderer (renderMode, material, sortMode, minParticleSize, maxParticleSize, etc.) + /// - particle_enable_module: Enable/disable modules by name + /// - particle_play/stop/pause/restart/clear: Playback control (withChildren optional) + /// - particle_add_burst: Add emission burst (time, count, cycles, interval, probability) + /// - particle_clear_bursts: Clear all bursts + /// + /// Visual Effect Graph (vfx_*): + /// Asset Management: + /// - vfx_create_asset: Create new VFX asset file (assetName, folderPath, template, overwrite) + /// - vfx_assign_asset: Assign VFX asset to VisualEffect component (target, assetPath) + /// - vfx_list_templates: List available VFX templates in project and packages + /// - vfx_list_assets: List all VFX assets (folder, search filters) + /// Runtime Control: + /// - vfx_get_info: Get VFX info including exposed parameters + /// - vfx_set_float/int/bool: Set exposed scalar parameters (parameter, value) + /// - vfx_set_vector2/vector3/vector4: Set exposed vector parameters (parameter, value as array) + /// - vfx_set_color: Set exposed color (parameter, color as [r,g,b,a]) + /// - vfx_set_gradient: Set exposed gradient (parameter, gradient) + /// - vfx_set_texture: Set exposed texture (parameter, texturePath) + /// - vfx_set_mesh: Set exposed mesh (parameter, meshPath) + /// - vfx_set_curve: Set exposed animation curve (parameter, curve) + /// - vfx_send_event: Send event with attributes (eventName, position, velocity, color, size, lifetime) + /// - vfx_play/stop/pause/reinit: Playback control + /// - vfx_set_playback_speed: Set playback speed multiplier (playRate) + /// - vfx_set_seed: Set random seed (seed, resetSeedOnPlay) + /// + /// LineRenderer (line_*): + /// - line_get_info: Get line info (position count, width, color, etc.) + /// - line_set_positions: Set all positions (positions as [[x,y,z], ...]) + /// - line_add_position: Add position at end (position as [x,y,z]) + /// - line_set_position: Set specific position (index, position) + /// - line_set_width: Set width (width, startWidth, endWidth, widthCurve, widthMultiplier) + /// - line_set_color: Set color (color, gradient, startColor, endColor) + /// - line_set_material: Set material (materialPath) + /// - line_set_properties: Set renderer properties (loop, useWorldSpace, alignment, textureMode, numCornerVertices, numCapVertices, etc.) + /// - line_clear: Clear all positions + /// Shape Creation: + /// - line_create_line: Create simple line (start, end, segments) + /// - line_create_circle: Create circle (center, radius, segments, normal) + /// - line_create_arc: Create arc (center, radius, startAngle, endAngle, segments, normal) + /// - line_create_bezier: Create Bezier curve (start, end, controlPoint1, controlPoint2, segments) + /// + /// TrailRenderer (trail_*): + /// - trail_get_info: Get trail info + /// - trail_set_time: Set trail duration (time) + /// - trail_set_width: Set width (width, startWidth, endWidth, widthCurve, widthMultiplier) + /// - trail_set_color: Set color (color, gradient, startColor, endColor) + /// - trail_set_material: Set material (materialPath) + /// - trail_set_properties: Set properties (minVertexDistance, autodestruct, emitting, alignment, textureMode, etc.) + /// - trail_clear: Clear trail + /// - trail_emit: Emit point at current position (Unity 2021.1+) + /// + /// COMMON PARAMETERS: + /// - target (string): GameObject identifier + /// - searchMethod (string): "by_id" | "by_name" | "by_path" | "by_tag" | "by_layer" + /// - materialPath (string): Asset path to material (e.g., "Assets/Materials/Fire.mat") + /// - color (array): Color as [r, g, b, a] with values 0-1 + /// - position (array): 3D position as [x, y, z] + /// - gradient (object): {colorKeys: [{color: [r,g,b,a], time: 0-1}], alphaKeys: [{alpha: 0-1, time: 0-1}]} + /// - curve (object): {keys: [{time: 0-1, value: number, inTangent: number, outTangent: number}]} + /// + /// For full parameter details, refer to Unity documentation for each component type. + /// + [McpForUnityTool("manage_vfx", AutoRegister = false)] + public static class ManageVFX + { + private static readonly Dictionary ParamAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "size_over_lifetime", "size" }, + { "start_color_line", "startColor" }, + { "sorting_layer_id", "sortingLayerID" }, + { "material", "materialPath" }, + }; + + private static JObject NormalizeParams(JObject source) + { + if (source == null) + { + return new JObject(); + } + + var normalized = new JObject(); + var properties = ExtractProperties(source); + if (properties != null) + { + foreach (var prop in properties.Properties()) + { + normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value); + } + } + + foreach (var prop in source.Properties()) + { + if (string.Equals(prop.Name, "properties", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + normalized[NormalizeKey(prop.Name, true)] = NormalizeToken(prop.Value); + } + + return normalized; + } + + private static JObject ExtractProperties(JObject source) + { + if (source == null) + { + return null; + } + + if (!source.TryGetValue("properties", StringComparison.OrdinalIgnoreCase, out var token)) + { + return null; + } + + if (token == null || token.Type == JTokenType.Null) + { + return null; + } + + if (token is JObject obj) + { + return obj; + } + + if (token.Type == JTokenType.String) + { + try + { + return JToken.Parse(token.ToString()) as JObject; + } + catch (JsonException ex) + { + throw new JsonException( + $"Failed to parse 'properties' JSON string. Raw value: {token}", + ex); + } + } + + return null; + } + + private static string NormalizeKey(string key, bool allowAliases) + { + if (string.IsNullOrEmpty(key)) + { + return key; + } + if (string.Equals(key, "action", StringComparison.OrdinalIgnoreCase)) + { + return "action"; + } + if (allowAliases && ParamAliases.TryGetValue(key, out var alias)) + { + return alias; + } + if (key.IndexOf('_') >= 0) + { + return ToCamelCase(key); + } + return key; + } + + private static JToken NormalizeToken(JToken token) + { + if (token == null) + { + return null; + } + + if (token is JObject obj) + { + var normalized = new JObject(); + foreach (var prop in obj.Properties()) + { + normalized[NormalizeKey(prop.Name, false)] = NormalizeToken(prop.Value); + } + return normalized; + } + + if (token is JArray array) + { + var normalized = new JArray(); + foreach (var item in array) + { + normalized.Add(NormalizeToken(item)); + } + return normalized; + } + + return token; + } + + private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); + + public static object HandleCommand(JObject @params) + { + JObject normalizedParams = NormalizeParams(@params); + string action = normalizedParams["action"]?.ToString(); + if (string.IsNullOrEmpty(action)) + { + return new { success = false, message = "Action is required" }; + } + + try + { + string actionLower = action.ToLowerInvariant(); + + // Route to appropriate handler based on action prefix + if (actionLower == "ping") + { + return new { success = true, tool = "manage_vfx", components = new[] { "ParticleSystem", "VisualEffect", "LineRenderer", "TrailRenderer" } }; + } + + // ParticleSystem actions (particle_*) + if (actionLower.StartsWith("particle_")) + { + return HandleParticleSystemAction(normalizedParams, actionLower.Substring(9)); + } + + // VFX Graph actions (vfx_*) + if (actionLower.StartsWith("vfx_")) + { + return HandleVFXGraphAction(normalizedParams, actionLower.Substring(4)); + } + + // LineRenderer actions (line_*) + if (actionLower.StartsWith("line_")) + { + return HandleLineRendererAction(normalizedParams, actionLower.Substring(5)); + } + + // TrailRenderer actions (trail_*) + if (actionLower.StartsWith("trail_")) + { + return HandleTrailRendererAction(normalizedParams, actionLower.Substring(6)); + } + + return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: particle_, vfx_, line_, or trail_" }; + } + catch (Exception ex) + { + return new { success = false, message = ex.Message, stackTrace = ex.StackTrace }; + } + } + + private static object HandleParticleSystemAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return ParticleRead.GetInfo(@params); + case "set_main": return ParticleWrite.SetMain(@params); + case "set_emission": return ParticleWrite.SetEmission(@params); + case "set_shape": return ParticleWrite.SetShape(@params); + case "set_color_over_lifetime": return ParticleWrite.SetColorOverLifetime(@params); + case "set_size_over_lifetime": return ParticleWrite.SetSizeOverLifetime(@params); + case "set_velocity_over_lifetime": return ParticleWrite.SetVelocityOverLifetime(@params); + case "set_noise": return ParticleWrite.SetNoise(@params); + case "set_renderer": return ParticleWrite.SetRenderer(@params); + case "enable_module": return ParticleControl.EnableModule(@params); + case "play": return ParticleControl.Control(@params, "play"); + case "stop": return ParticleControl.Control(@params, "stop"); + case "pause": return ParticleControl.Control(@params, "pause"); + case "restart": return ParticleControl.Control(@params, "restart"); + case "clear": return ParticleControl.Control(@params, "clear"); + case "add_burst": return ParticleControl.AddBurst(@params); + case "clear_bursts": return ParticleControl.ClearBursts(@params); + default: + return new { success = false, message = $"Unknown particle action: {action}. Valid: get_info, set_main, set_emission, set_shape, set_color_over_lifetime, set_size_over_lifetime, set_velocity_over_lifetime, set_noise, set_renderer, enable_module, play, stop, pause, restart, clear, add_burst, clear_bursts" }; + } + } + + // ==================== VFX GRAPH ==================== + #region VFX Graph + + private static object HandleVFXGraphAction(JObject @params, string action) + { +#if !UNITY_VFX_GRAPH + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; +#else + switch (action) + { + // Asset management + case "create_asset": return VfxGraphAssets.CreateAsset(@params); + case "assign_asset": return VfxGraphAssets.AssignAsset(@params); + case "list_templates": return VfxGraphAssets.ListTemplates(@params); + case "list_assets": return VfxGraphAssets.ListAssets(@params); + + // Runtime parameter control + case "get_info": return VfxGraphRead.GetInfo(@params); + case "set_float": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetFloat(n, v)); + case "set_int": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetInt(n, v)); + case "set_bool": return VfxGraphWrite.SetParameter(@params, (vfx, n, v) => vfx.SetBool(n, v)); + case "set_vector2": return VfxGraphWrite.SetVector(@params, 2); + case "set_vector3": return VfxGraphWrite.SetVector(@params, 3); + case "set_vector4": return VfxGraphWrite.SetVector(@params, 4); + case "set_color": return VfxGraphWrite.SetColor(@params); + case "set_gradient": return VfxGraphWrite.SetGradient(@params); + case "set_texture": return VfxGraphWrite.SetTexture(@params); + case "set_mesh": return VfxGraphWrite.SetMesh(@params); + case "set_curve": return VfxGraphWrite.SetCurve(@params); + case "send_event": return VfxGraphWrite.SendEvent(@params); + case "play": return VfxGraphControl.Control(@params, "play"); + case "stop": return VfxGraphControl.Control(@params, "stop"); + case "pause": return VfxGraphControl.Control(@params, "pause"); + case "reinit": return VfxGraphControl.Control(@params, "reinit"); + case "set_playback_speed": return VfxGraphControl.SetPlaybackSpeed(@params); + case "set_seed": return VfxGraphControl.SetSeed(@params); + default: + return new { success = false, message = $"Unknown vfx action: {action}. Valid: create_asset, assign_asset, list_templates, list_assets, get_info, set_float, set_int, set_bool, set_vector2/3/4, set_color, set_gradient, set_texture, set_mesh, set_curve, send_event, play, stop, pause, reinit, set_playback_speed, set_seed" }; + } +#endif + } + + + #endregion + + private static object HandleLineRendererAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return LineRead.GetInfo(@params); + case "set_positions": return LineWrite.SetPositions(@params); + case "add_position": return LineWrite.AddPosition(@params); + case "set_position": return LineWrite.SetPosition(@params); + case "set_width": return LineWrite.SetWidth(@params); + case "set_color": return LineWrite.SetColor(@params); + case "set_material": return LineWrite.SetMaterial(@params); + case "set_properties": return LineWrite.SetProperties(@params); + case "clear": return LineWrite.Clear(@params); + case "create_line": return LineCreate.CreateLine(@params); + case "create_circle": return LineCreate.CreateCircle(@params); + case "create_arc": return LineCreate.CreateArc(@params); + case "create_bezier": return LineCreate.CreateBezier(@params); + default: + return new { success = false, message = $"Unknown line action: {action}. Valid: get_info, set_positions, add_position, set_position, set_width, set_color, set_material, set_properties, clear, create_line, create_circle, create_arc, create_bezier" }; + } + } + + private static object HandleTrailRendererAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return TrailRead.GetInfo(@params); + case "set_time": return TrailWrite.SetTime(@params); + case "set_width": return TrailWrite.SetWidth(@params); + case "set_color": return TrailWrite.SetColor(@params); + case "set_material": return TrailWrite.SetMaterial(@params); + case "set_properties": return TrailWrite.SetProperties(@params); + case "clear": return TrailControl.Clear(@params); + case "emit": return TrailControl.Emit(@params); + default: + return new { success = false, message = $"Unknown trail action: {action}. Valid: get_info, set_time, set_width, set_color, set_material, set_properties, clear, emit" }; + } + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta new file mode 100644 index 0000000..0ae7e7b --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: a8f3d2c1e9b74f6a8c5d0e2f1a3b4c5d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs new file mode 100644 index 0000000..aa2d668 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class ManageVfxCommon + { + public static Color ParseColor(JToken token) => VectorParsing.ParseColorOrDefault(token); + public static Vector3 ParseVector3(JToken token) => VectorParsing.ParseVector3OrDefault(token); + public static Vector4 ParseVector4(JToken token) => VectorParsing.ParseVector4OrDefault(token); + public static Gradient ParseGradient(JToken token) => VectorParsing.ParseGradientOrDefault(token); + public static AnimationCurve ParseAnimationCurve(JToken token, float defaultValue = 1f) + => VectorParsing.ParseAnimationCurveOrDefault(token, defaultValue); + + public static GameObject FindTargetGameObject(JObject @params) + => ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString()); + + public static Material FindMaterialByPath(string path) + => ObjectResolver.ResolveMaterial(path); + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta new file mode 100644 index 0000000..764f2ec --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 1c5e603b26d2f47529394c1ec6b8ed79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs new file mode 100644 index 0000000..b941873 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class ParticleCommon + { + public static ParticleSystem FindParticleSystem(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static ParticleSystem.MinMaxCurve ParseMinMaxCurve(JToken token, float defaultValue = 1f) + { + if (token == null) + return new ParticleSystem.MinMaxCurve(defaultValue); + + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + { + return new ParticleSystem.MinMaxCurve(token.ToObject()); + } + + if (token is JObject obj) + { + string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "constant"; + + switch (mode) + { + case "constant": + float constant = obj["value"]?.ToObject() ?? defaultValue; + return new ParticleSystem.MinMaxCurve(constant); + + case "random_between_constants": + case "two_constants": + float min = obj["min"]?.ToObject() ?? 0f; + float max = obj["max"]?.ToObject() ?? 1f; + return new ParticleSystem.MinMaxCurve(min, max); + + case "curve": + AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(obj, defaultValue); + return new ParticleSystem.MinMaxCurve(obj["multiplier"]?.ToObject() ?? 1f, curve); + + default: + return new ParticleSystem.MinMaxCurve(defaultValue); + } + } + + return new ParticleSystem.MinMaxCurve(defaultValue); + } + + public static ParticleSystem.MinMaxGradient ParseMinMaxGradient(JToken token) + { + if (token == null) + return new ParticleSystem.MinMaxGradient(Color.white); + + if (token is JArray arr && arr.Count >= 3) + { + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(arr)); + } + + if (token is JObject obj) + { + string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "color"; + + switch (mode) + { + case "color": + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(obj["color"])); + + case "two_colors": + Color colorMin = ManageVfxCommon.ParseColor(obj["colorMin"]); + Color colorMax = ManageVfxCommon.ParseColor(obj["colorMax"]); + return new ParticleSystem.MinMaxGradient(colorMin, colorMax); + + case "gradient": + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseGradient(obj)); + + default: + return new ParticleSystem.MinMaxGradient(Color.white); + } + } + + return new ParticleSystem.MinMaxGradient(Color.white); + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta new file mode 100644 index 0000000..a75d829 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: a3a91aa6f6b9c4121a2ccc1a8147bbf9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs new file mode 100644 index 0000000..4fb8584 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs @@ -0,0 +1,121 @@ +using System; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class ParticleControl + { + public static object EnableModule(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + string moduleName = @params["module"]?.ToString()?.ToLowerInvariant(); + bool enabled = @params["enabled"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(moduleName)) return new { success = false, message = "Module name required" }; + + Undo.RecordObject(ps, $"Toggle {moduleName}"); + + switch (moduleName.Replace("_", "")) + { + case "emission": var em = ps.emission; em.enabled = enabled; break; + case "shape": var sh = ps.shape; sh.enabled = enabled; break; + case "coloroverlifetime": var col = ps.colorOverLifetime; col.enabled = enabled; break; + case "sizeoverlifetime": var sol = ps.sizeOverLifetime; sol.enabled = enabled; break; + case "velocityoverlifetime": var vol = ps.velocityOverLifetime; vol.enabled = enabled; break; + case "noise": var n = ps.noise; n.enabled = enabled; break; + case "collision": var coll = ps.collision; coll.enabled = enabled; break; + case "trails": var tr = ps.trails; tr.enabled = enabled; break; + case "lights": var li = ps.lights; li.enabled = enabled; break; + default: return new { success = false, message = $"Unknown module: {moduleName}" }; + } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Module '{moduleName}' {(enabled ? "enabled" : "disabled")}" }; + } + + public static object Control(JObject @params, string action) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned before playing + if (action == "play" || action == "restart") + { + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + } + + bool withChildren = @params["withChildren"]?.ToObject() ?? true; + + switch (action) + { + case "play": ps.Play(withChildren); break; + case "stop": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmitting); break; + case "pause": ps.Pause(withChildren); break; + case "restart": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(withChildren); break; + case "clear": ps.Clear(withChildren); break; + default: return new { success = false, message = $"Unknown action: {action}" }; + } + + return new { success = true, message = $"ParticleSystem {action}" }; + } + + public static object AddBurst(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Add Burst"); + var emission = ps.emission; + + float time = @params["time"]?.ToObject() ?? 0f; + int minCountRaw = @params["minCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30; + int maxCountRaw = @params["maxCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30; + short minCount = (short)Math.Clamp(minCountRaw, 0, short.MaxValue); + short maxCount = (short)Math.Clamp(maxCountRaw, 0, short.MaxValue); + int cycles = @params["cycles"]?.ToObject() ?? 1; + float interval = @params["interval"]?.ToObject() ?? 0.01f; + + var burst = new ParticleSystem.Burst(time, minCount, maxCount, cycles, interval); + burst.probability = @params["probability"]?.ToObject() ?? 1f; + + int idx = emission.burstCount; + var bursts = new ParticleSystem.Burst[idx + 1]; + emission.GetBursts(bursts); + bursts[idx] = burst; + emission.SetBursts(bursts); + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Added burst at t={time}", burstIndex = idx }; + } + + public static object ClearBursts(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Clear Bursts"); + var emission = ps.emission; + int count = emission.burstCount; + emission.SetBursts(new ParticleSystem.Burst[0]); + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Cleared {count} bursts" }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta new file mode 100644 index 0000000..de48a3e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 04e1bfb655f184337943edd5a3fbbcdb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs new file mode 100644 index 0000000..65afcd4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs @@ -0,0 +1,153 @@ +using Newtonsoft.Json.Linq; +using System.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class ParticleRead + { + private static object SerializeAnimationCurve(AnimationCurve curve) + { + if (curve == null) + { + return null; + } + + return new + { + keys = curve.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray() + }; + } + + private static object SerializeMinMaxCurve(ParticleSystem.MinMaxCurve curve) + { + switch (curve.mode) + { + case ParticleSystemCurveMode.Constant: + return new + { + mode = "constant", + value = curve.constant + }; + + case ParticleSystemCurveMode.TwoConstants: + return new + { + mode = "two_constants", + min = curve.constantMin, + max = curve.constantMax + }; + + case ParticleSystemCurveMode.Curve: + return new + { + mode = "curve", + multiplier = curve.curveMultiplier, + keys = curve.curve.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray() + }; + + case ParticleSystemCurveMode.TwoCurves: + return new + { + mode = "curve", + multiplier = curve.curveMultiplier, + keys = curve.curveMax.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray(), + originalMode = "two_curves", + curveMin = SerializeAnimationCurve(curve.curveMin), + curveMax = SerializeAnimationCurve(curve.curveMax) + }; + + default: + return new + { + mode = "constant", + value = curve.constant + }; + } + } + + public static object GetInfo(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) + { + return new { success = false, message = "ParticleSystem not found" }; + } + + var main = ps.main; + var emission = ps.emission; + var shape = ps.shape; + var renderer = ps.GetComponent(); + + return new + { + success = true, + data = new + { + gameObject = ps.gameObject.name, + isPlaying = ps.isPlaying, + isPaused = ps.isPaused, + particleCount = ps.particleCount, + main = new + { + duration = main.duration, + looping = main.loop, + startLifetime = SerializeMinMaxCurve(main.startLifetime), + startSpeed = SerializeMinMaxCurve(main.startSpeed), + startSize = SerializeMinMaxCurve(main.startSize), + gravityModifier = SerializeMinMaxCurve(main.gravityModifier), + simulationSpace = main.simulationSpace.ToString(), + maxParticles = main.maxParticles + }, + emission = new + { + enabled = emission.enabled, + rateOverTime = SerializeMinMaxCurve(emission.rateOverTime), + burstCount = emission.burstCount + }, + shape = new + { + enabled = shape.enabled, + shapeType = shape.shapeType.ToString(), + radius = shape.radius, + angle = shape.angle + }, + renderer = renderer != null ? new + { + renderMode = renderer.renderMode.ToString(), + sortMode = renderer.sortMode.ToString(), + material = renderer.sharedMaterial?.name, + trailMaterial = renderer.trailMaterial?.name, + minParticleSize = renderer.minParticleSize, + maxParticleSize = renderer.maxParticleSize, + shadowCastingMode = renderer.shadowCastingMode.ToString(), + receiveShadows = renderer.receiveShadows, + lightProbeUsage = renderer.lightProbeUsage.ToString(), + reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(), + sortingOrder = renderer.sortingOrder, + sortingLayerName = renderer.sortingLayerName, + renderingLayerMask = renderer.renderingLayerMask + } : null + } + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta new file mode 100644 index 0000000..e1e5b82 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 74bb7c48a4e1944bcba43b3619653cb9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs new file mode 100644 index 0000000..21c0384 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class ParticleWrite + { + public static object SetMain(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned before any configuration + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + // Stop particle system if it's playing and duration needs to be changed + bool wasPlaying = ps.isPlaying; + bool needsStop = @params["duration"] != null && wasPlaying; + if (needsStop) + { + ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + } + + Undo.RecordObject(ps, "Set ParticleSystem Main"); + var main = ps.main; + var changes = new List(); + + if (@params["duration"] != null) { main.duration = @params["duration"].ToObject(); changes.Add("duration"); } + if (@params["looping"] != null) { main.loop = @params["looping"].ToObject(); changes.Add("looping"); } + if (@params["prewarm"] != null) { main.prewarm = @params["prewarm"].ToObject(); changes.Add("prewarm"); } + if (@params["startDelay"] != null) { main.startDelay = ParticleCommon.ParseMinMaxCurve(@params["startDelay"], 0f); changes.Add("startDelay"); } + if (@params["startLifetime"] != null) { main.startLifetime = ParticleCommon.ParseMinMaxCurve(@params["startLifetime"], 5f); changes.Add("startLifetime"); } + if (@params["startSpeed"] != null) { main.startSpeed = ParticleCommon.ParseMinMaxCurve(@params["startSpeed"], 5f); changes.Add("startSpeed"); } + if (@params["startSize"] != null) { main.startSize = ParticleCommon.ParseMinMaxCurve(@params["startSize"], 1f); changes.Add("startSize"); } + if (@params["startRotation"] != null) { main.startRotation = ParticleCommon.ParseMinMaxCurve(@params["startRotation"], 0f); changes.Add("startRotation"); } + if (@params["startColor"] != null) { main.startColor = ParticleCommon.ParseMinMaxGradient(@params["startColor"]); changes.Add("startColor"); } + if (@params["gravityModifier"] != null) { main.gravityModifier = ParticleCommon.ParseMinMaxCurve(@params["gravityModifier"], 0f); changes.Add("gravityModifier"); } + if (@params["simulationSpace"] != null && Enum.TryParse(@params["simulationSpace"].ToString(), true, out var simSpace)) { main.simulationSpace = simSpace; changes.Add("simulationSpace"); } + if (@params["scalingMode"] != null && Enum.TryParse(@params["scalingMode"].ToString(), true, out var scaleMode)) { main.scalingMode = scaleMode; changes.Add("scalingMode"); } + if (@params["playOnAwake"] != null) { main.playOnAwake = @params["playOnAwake"].ToObject(); changes.Add("playOnAwake"); } + if (@params["maxParticles"] != null) { main.maxParticles = @params["maxParticles"].ToObject(); changes.Add("maxParticles"); } + + EditorUtility.SetDirty(ps); + + // Restart particle system if it was playing + if (needsStop && wasPlaying) + { + ps.Play(true); + changes.Add("(restarted after duration change)"); + } + + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetEmission(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Emission"); + var emission = ps.emission; + var changes = new List(); + + if (@params["enabled"] != null) { emission.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["rateOverTime"] != null) { emission.rateOverTime = ParticleCommon.ParseMinMaxCurve(@params["rateOverTime"], 10f); changes.Add("rateOverTime"); } + if (@params["rateOverDistance"] != null) { emission.rateOverDistance = ParticleCommon.ParseMinMaxCurve(@params["rateOverDistance"], 0f); changes.Add("rateOverDistance"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated emission: {string.Join(", ", changes)}" }; + } + + public static object SetShape(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Shape"); + var shape = ps.shape; + var changes = new List(); + + if (@params["enabled"] != null) { shape.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["shapeType"] != null && Enum.TryParse(@params["shapeType"].ToString(), true, out var shapeType)) { shape.shapeType = shapeType; changes.Add("shapeType"); } + if (@params["radius"] != null) { shape.radius = @params["radius"].ToObject(); changes.Add("radius"); } + if (@params["radiusThickness"] != null) { shape.radiusThickness = @params["radiusThickness"].ToObject(); changes.Add("radiusThickness"); } + if (@params["angle"] != null) { shape.angle = @params["angle"].ToObject(); changes.Add("angle"); } + if (@params["arc"] != null) { shape.arc = @params["arc"].ToObject(); changes.Add("arc"); } + if (@params["position"] != null) { shape.position = ManageVfxCommon.ParseVector3(@params["position"]); changes.Add("position"); } + if (@params["rotation"] != null) { shape.rotation = ManageVfxCommon.ParseVector3(@params["rotation"]); changes.Add("rotation"); } + if (@params["scale"] != null) { shape.scale = ManageVfxCommon.ParseVector3(@params["scale"]); changes.Add("scale"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated shape: {string.Join(", ", changes)}" }; + } + + public static object SetColorOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Color Over Lifetime"); + var col = ps.colorOverLifetime; + var changes = new List(); + + if (@params["enabled"] != null) { col.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["color"] != null) { col.color = ParticleCommon.ParseMinMaxGradient(@params["color"]); changes.Add("color"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetSizeOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Size Over Lifetime"); + var sol = ps.sizeOverLifetime; + var changes = new List(); + + bool hasSizeProperty = @params["size"] != null || @params["sizeX"] != null || + @params["sizeY"] != null || @params["sizeZ"] != null; + if (hasSizeProperty && @params["enabled"] == null && !sol.enabled) + { + sol.enabled = true; + changes.Add("enabled"); + } + else if (@params["enabled"] != null) + { + sol.enabled = @params["enabled"].ToObject(); + changes.Add("enabled"); + } + + if (@params["separateAxes"] != null) { sol.separateAxes = @params["separateAxes"].ToObject(); changes.Add("separateAxes"); } + if (@params["size"] != null) { sol.size = ParticleCommon.ParseMinMaxCurve(@params["size"], 1f); changes.Add("size"); } + if (@params["sizeX"] != null) { sol.x = ParticleCommon.ParseMinMaxCurve(@params["sizeX"], 1f); changes.Add("sizeX"); } + if (@params["sizeY"] != null) { sol.y = ParticleCommon.ParseMinMaxCurve(@params["sizeY"], 1f); changes.Add("sizeY"); } + if (@params["sizeZ"] != null) { sol.z = ParticleCommon.ParseMinMaxCurve(@params["sizeZ"], 1f); changes.Add("sizeZ"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetVelocityOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Velocity Over Lifetime"); + var vol = ps.velocityOverLifetime; + var changes = new List(); + + if (@params["enabled"] != null) { vol.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["space"] != null && Enum.TryParse(@params["space"].ToString(), true, out var space)) { vol.space = space; changes.Add("space"); } + if (@params["x"] != null) { vol.x = ParticleCommon.ParseMinMaxCurve(@params["x"], 0f); changes.Add("x"); } + if (@params["y"] != null) { vol.y = ParticleCommon.ParseMinMaxCurve(@params["y"], 0f); changes.Add("y"); } + if (@params["z"] != null) { vol.z = ParticleCommon.ParseMinMaxCurve(@params["z"], 0f); changes.Add("z"); } + if (@params["speedModifier"] != null) { vol.speedModifier = ParticleCommon.ParseMinMaxCurve(@params["speedModifier"], 1f); changes.Add("speedModifier"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetNoise(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + // Ensure material is assigned + var renderer = ps.GetComponent(); + if (renderer != null) + { + RendererHelpers.EnsureMaterial(renderer); + } + + Undo.RecordObject(ps, "Set ParticleSystem Noise"); + var noise = ps.noise; + var changes = new List(); + + if (@params["enabled"] != null) { noise.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["strength"] != null) { noise.strength = ParticleCommon.ParseMinMaxCurve(@params["strength"], 1f); changes.Add("strength"); } + if (@params["frequency"] != null) { noise.frequency = @params["frequency"].ToObject(); changes.Add("frequency"); } + if (@params["scrollSpeed"] != null) { noise.scrollSpeed = ParticleCommon.ParseMinMaxCurve(@params["scrollSpeed"], 0f); changes.Add("scrollSpeed"); } + if (@params["damping"] != null) { noise.damping = @params["damping"].ToObject(); changes.Add("damping"); } + if (@params["octaveCount"] != null) { noise.octaveCount = @params["octaveCount"].ToObject(); changes.Add("octaveCount"); } + if (@params["quality"] != null && Enum.TryParse(@params["quality"].ToString(), true, out var quality)) { noise.quality = quality; changes.Add("quality"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated noise: {string.Join(", ", changes)}" }; + } + + public static object SetRenderer(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + var renderer = ps.GetComponent(); + if (renderer == null) return new { success = false, message = "ParticleSystemRenderer not found" }; + + // Ensure material is set before any other operations + RendererHelpers.EnsureMaterial(renderer); + + Undo.RecordObject(renderer, "Set ParticleSystem Renderer"); + var changes = new List(); + + if (@params["renderMode"] != null && Enum.TryParse(@params["renderMode"].ToString(), true, out var renderMode)) { renderer.renderMode = renderMode; changes.Add("renderMode"); } + if (@params["sortMode"] != null && Enum.TryParse(@params["sortMode"].ToString(), true, out var sortMode)) { renderer.sortMode = sortMode; changes.Add("sortMode"); } + + if (@params["minParticleSize"] != null) { renderer.minParticleSize = @params["minParticleSize"].ToObject(); changes.Add("minParticleSize"); } + if (@params["maxParticleSize"] != null) { renderer.maxParticleSize = @params["maxParticleSize"].ToObject(); changes.Add("maxParticleSize"); } + + if (@params["lengthScale"] != null) { renderer.lengthScale = @params["lengthScale"].ToObject(); changes.Add("lengthScale"); } + if (@params["velocityScale"] != null) { renderer.velocityScale = @params["velocityScale"].ToObject(); changes.Add("velocityScale"); } + if (@params["cameraVelocityScale"] != null) { renderer.cameraVelocityScale = @params["cameraVelocityScale"].ToObject(); changes.Add("cameraVelocityScale"); } + if (@params["normalDirection"] != null) { renderer.normalDirection = @params["normalDirection"].ToObject(); changes.Add("normalDirection"); } + + if (@params["alignment"] != null && Enum.TryParse(@params["alignment"].ToString(), true, out var alignment)) { renderer.alignment = alignment; changes.Add("alignment"); } + if (@params["pivot"] != null) { renderer.pivot = ManageVfxCommon.ParseVector3(@params["pivot"]); changes.Add("pivot"); } + if (@params["flip"] != null) { renderer.flip = ManageVfxCommon.ParseVector3(@params["flip"]); changes.Add("flip"); } + if (@params["allowRoll"] != null) { renderer.allowRoll = @params["allowRoll"].ToObject(); changes.Add("allowRoll"); } + + if (@params["shadowBias"] != null) { renderer.shadowBias = @params["shadowBias"].ToObject(); changes.Add("shadowBias"); } + + RendererHelpers.ApplyCommonRendererProperties(renderer, @params, changes); + + if (@params["materialPath"] != null) + { + string matPath = @params["materialPath"].ToString(); + var findInst = new JObject { ["find"] = matPath }; + Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material; + if (mat != null) + { + renderer.sharedMaterial = mat; + changes.Add($"material={mat.name}"); + } + else + { + McpLog.Warn($"Material not found at path: {matPath}. Keeping existing material."); + } + } + + if (@params["trailMaterialPath"] != null) + { + var findInst = new JObject { ["find"] = @params["trailMaterialPath"].ToString() }; + Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material; + if (mat != null) { renderer.trailMaterial = mat; changes.Add("trailMaterial"); } + } + + EditorUtility.SetDirty(renderer); + return new { success = true, message = $"Updated renderer: {string.Join(", ", changes)}" }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta new file mode 100644 index 0000000..a89dc39 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2a68818a59fac4e2c83ad23433ddc9c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs new file mode 100644 index 0000000..ad6acc6 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class TrailControl + { + public static object Clear(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + Undo.RecordObject(tr, "Clear Trail"); + tr.Clear(); + return new { success = true, message = "Trail cleared" }; + } + + public static object Emit(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + RendererHelpers.EnsureMaterial(tr); + +#if UNITY_2021_1_OR_NEWER + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + tr.AddPosition(pos); + return new { success = true, message = $"Emitted at ({pos.x}, {pos.y}, {pos.z})" }; +#else + return new { success = false, message = "AddPosition requires Unity 2021.1+" }; +#endif + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta new file mode 100644 index 0000000..4cfb51e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: edebad99699494d5585418395a2bf518 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs new file mode 100644 index 0000000..4fae75a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class TrailRead + { + public static TrailRenderer FindTrailRenderer(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static object GetInfo(JObject @params) + { + TrailRenderer tr = FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + return new + { + success = true, + data = new + { + gameObject = tr.gameObject.name, + time = tr.time, + startWidth = tr.startWidth, + endWidth = tr.endWidth, + minVertexDistance = tr.minVertexDistance, + emitting = tr.emitting, + autodestruct = tr.autodestruct, + positionCount = tr.positionCount, + alignment = tr.alignment.ToString(), + textureMode = tr.textureMode.ToString(), + numCornerVertices = tr.numCornerVertices, + numCapVertices = tr.numCapVertices, + generateLightingData = tr.generateLightingData, + material = tr.sharedMaterial?.name, + shadowCastingMode = tr.shadowCastingMode.ToString(), + receiveShadows = tr.receiveShadows, + lightProbeUsage = tr.lightProbeUsage.ToString(), + reflectionProbeUsage = tr.reflectionProbeUsage.ToString(), + sortingOrder = tr.sortingOrder, + sortingLayerName = tr.sortingLayerName, + renderingLayerMask = tr.renderingLayerMask + } + }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta new file mode 100644 index 0000000..f71666d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 2921f0042777b4ebbaec4c79c60908a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs new file mode 100644 index 0000000..fa11bbf --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Vfx +{ + internal static class TrailWrite + { + public static object SetTime(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + RendererHelpers.EnsureMaterial(tr); + + float time = @params["time"]?.ToObject() ?? 5f; + + Undo.RecordObject(tr, "Set Trail Time"); + tr.time = time; + EditorUtility.SetDirty(tr); + + return new { success = true, message = $"Set trail time to {time}s" }; + } + + public static object SetWidth(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + RendererHelpers.EnsureMaterial(tr); + + Undo.RecordObject(tr, "Set Trail Width"); + var changes = new List(); + + RendererHelpers.ApplyWidthProperties(@params, changes, + v => tr.startWidth = v, v => tr.endWidth = v, + v => tr.widthCurve = v, v => tr.widthMultiplier = v, + ManageVfxCommon.ParseAnimationCurve); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetColor(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + RendererHelpers.EnsureMaterial(tr); + + Undo.RecordObject(tr, "Set Trail Color"); + var changes = new List(); + + RendererHelpers.ApplyColorProperties(@params, changes, + v => tr.startColor = v, v => tr.endColor = v, + v => tr.colorGradient = v, + ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: true); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetMaterial(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + return RendererHelpers.SetRendererMaterial(tr, @params, "Set Trail Material", ManageVfxCommon.FindMaterialByPath); + } + + public static object SetProperties(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + RendererHelpers.EnsureMaterial(tr); + + Undo.RecordObject(tr, "Set Trail Properties"); + var changes = new List(); + + // Handle material if provided + if (@params["materialPath"] != null) + { + Material mat = ManageVfxCommon.FindMaterialByPath(@params["materialPath"].ToString()); + if (mat != null) + { + tr.sharedMaterial = mat; + changes.Add($"material={mat.name}"); + } + else + { + McpLog.Warn($"Material not found: {@params["materialPath"]}"); + } + } + + // Handle time if provided + if (@params["time"] != null) { tr.time = @params["time"].ToObject(); changes.Add("time"); } + + // Handle width properties if provided + if (@params["width"] != null || @params["startWidth"] != null || @params["endWidth"] != null) + { + if (@params["width"] != null) + { + float w = @params["width"].ToObject(); + tr.startWidth = w; + tr.endWidth = w; + changes.Add("width"); + } + if (@params["startWidth"] != null) { tr.startWidth = @params["startWidth"].ToObject(); changes.Add("startWidth"); } + if (@params["endWidth"] != null) { tr.endWidth = @params["endWidth"].ToObject(); changes.Add("endWidth"); } + } + + if (@params["minVertexDistance"] != null) { tr.minVertexDistance = @params["minVertexDistance"].ToObject(); changes.Add("minVertexDistance"); } + if (@params["autodestruct"] != null) { tr.autodestruct = @params["autodestruct"].ToObject(); changes.Add("autodestruct"); } + if (@params["emitting"] != null) { tr.emitting = @params["emitting"].ToObject(); changes.Add("emitting"); } + + RendererHelpers.ApplyLineTrailProperties(@params, changes, + null, null, + v => tr.numCornerVertices = v, v => tr.numCapVertices = v, + v => tr.alignment = v, v => tr.textureMode = v, + v => tr.generateLightingData = v); + + RendererHelpers.ApplyCommonRendererProperties(tr, @params, changes); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta new file mode 100644 index 0000000..c5e4cfc --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 33ba432240c134206a4f71ab24f0fb3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs new file mode 100644 index 0000000..5f2e575 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Asset management operations for VFX Graph. + /// Handles creating, assigning, and listing VFX assets. + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphAssets + { +#if !UNITY_VFX_GRAPH + public static object CreateAsset(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object AssignAsset(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object ListTemplates(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object ListAssets(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + private static readonly string[] SupportedVfxGraphVersions = { "12.1" }; + + /// + /// Creates a new VFX Graph asset file from a template. + /// + public static object CreateAsset(JObject @params) + { + string assetName = @params["assetName"]?.ToString(); + string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX"; + string template = @params["template"]?.ToString() ?? "empty"; + + if (string.IsNullOrEmpty(assetName)) + { + return new { success = false, message = "assetName is required" }; + } + + string versionError = ValidateVfxGraphVersion(); + if (!string.IsNullOrEmpty(versionError)) + { + return new { success = false, message = versionError }; + } + + // Ensure folder exists + if (!AssetDatabase.IsValidFolder(folderPath)) + { + string[] folders = folderPath.Split('/'); + string currentPath = folders[0]; + for (int i = 1; i < folders.Length; i++) + { + string newPath = currentPath + "/" + folders[i]; + if (!AssetDatabase.IsValidFolder(newPath)) + { + AssetDatabase.CreateFolder(currentPath, folders[i]); + } + currentPath = newPath; + } + } + + string assetPath = $"{folderPath}/{assetName}.vfx"; + + // Check if asset already exists + if (AssetDatabase.LoadAssetAtPath(assetPath) != null) + { + bool overwrite = @params["overwrite"]?.ToObject() ?? false; + if (!overwrite) + { + return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." }; + } + AssetDatabase.DeleteAsset(assetPath); + } + + // Find template asset and copy it + string templatePath = FindTemplate(template); + string templateAssetPath = TryGetAssetPathFromFileSystem(templatePath); + VisualEffectAsset newAsset = null; + + if (!string.IsNullOrEmpty(templateAssetPath)) + { + // Copy the asset to create a new VFX Graph asset + if (!AssetDatabase.CopyAsset(templateAssetPath, assetPath)) + { + return new { success = false, message = $"Failed to copy VFX template from {templateAssetPath}" }; + } + AssetDatabase.Refresh(); + newAsset = AssetDatabase.LoadAssetAtPath(assetPath); + } + else + { + return new { success = false, message = "VFX template not found. Add a .vfx template asset or install VFX Graph templates." }; + } + + if (newAsset == null) + { + return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." }; + } + + return new + { + success = true, + message = $"Created VFX asset: {assetPath}", + data = new + { + assetPath = assetPath, + assetName = newAsset.name, + template = template + } + }; + } + + /// + /// Finds VFX template path by name. + /// + private static string FindTemplate(string templateName) + { + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + + string[] templatePatterns = new[] + { + $"{templateName}.vfx", + $"VFX{templateName}.vfx", + $"Simple{templateName}.vfx", + $"{templateName}VFX.vfx" + }; + + foreach (string basePath in searchPaths) + { + string searchRoot = basePath; + if (basePath.StartsWith("Assets/")) + { + searchRoot = System.IO.Path.Combine(UnityEngine.Application.dataPath, basePath.Substring("Assets/".Length)); + } + + if (!System.IO.Directory.Exists(searchRoot)) + { + continue; + } + + foreach (string pattern in templatePatterns) + { + string[] files = System.IO.Directory.GetFiles(searchRoot, pattern, System.IO.SearchOption.AllDirectories); + if (files.Length > 0) + { + return files[0]; + } + } + + // Also search by partial match + try + { + string[] allVfxFiles = System.IO.Directory.GetFiles(searchRoot, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in allVfxFiles) + { + if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower())) + { + return file; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to search VFX templates under '{searchRoot}': {ex.Message}"); + } + } + + // Search in project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName); + if (guids.Length > 0) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + // Convert asset path (e.g., "Assets/...") to absolute filesystem path + if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Assets/")) + { + return System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length)); + } + if (!string.IsNullOrEmpty(assetPath) && assetPath.StartsWith("Packages/")) + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(assetPath); + if (info != null) + { + string relPath = assetPath.Substring(("Packages/" + info.name + "/").Length); + return System.IO.Path.Combine(info.resolvedPath, relPath); + } + } + return null; + } + + return null; + } + + /// + /// Assigns a VFX asset to a VisualEffect component. + /// + public static object AssignAsset(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect component not found" }; + } + + string assetPath = @params["assetPath"]?.ToString(); + if (string.IsNullOrEmpty(assetPath)) + { + return new { success = false, message = "assetPath is required" }; + } + + // Validate and normalize path + // Reject absolute paths, parent directory traversal, and backslashes + if (assetPath.Contains("\\") || assetPath.Contains("..") || System.IO.Path.IsPathRooted(assetPath)) + { + return new { success = false, message = "Invalid assetPath: traversal and absolute paths are not allowed" }; + } + + if (assetPath.StartsWith("Packages/")) + { + return new { success = false, message = "Invalid assetPath: VFX assets must live under Assets/." }; + } + + if (!assetPath.StartsWith("Assets/")) + { + assetPath = "Assets/" + assetPath; + } + if (!assetPath.EndsWith(".vfx")) + { + assetPath += ".vfx"; + } + + // Verify the normalized path doesn't escape the project + string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, assetPath.Substring("Assets/".Length)); + string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath); + string canonicalAssetPath = System.IO.Path.GetFullPath(fullPath); + if (!canonicalAssetPath.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) && + canonicalAssetPath != canonicalProjectRoot) + { + return new { success = false, message = "Invalid assetPath: would escape project directory" }; + } + + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset == null) + { + // Try searching by name + string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath); + string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}"); + if (guids.Length > 0) + { + assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + asset = AssetDatabase.LoadAssetAtPath(assetPath); + } + } + + if (asset == null) + { + return new { success = false, message = $"VFX asset not found: {assetPath}" }; + } + + Undo.RecordObject(vfx, "Assign VFX Asset"); + vfx.visualEffectAsset = asset; + EditorUtility.SetDirty(vfx); + + return new + { + success = true, + message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}", + data = new + { + gameObject = vfx.gameObject.name, + assetName = asset.name, + assetPath = assetPath + } + }; + } + + /// + /// Lists available VFX templates. + /// + public static object ListTemplates(JObject @params) + { + var templates = new List(); + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + searchPaths.Add("Assets/VFX"); + + // Precompute normalized package path for comparison + string normalizedPackagePath = null; + if (packageInfo != null) + { + normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/"); + } + + // Precompute the Assets base path for converting absolute paths to project-relative + string assetsBasePath = Application.dataPath.Replace("\\", "/"); + + foreach (string basePath in searchPaths) + { + if (!System.IO.Directory.Exists(basePath)) + { + continue; + } + + try + { + string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in vfxFiles) + { + string absolutePath = file.Replace("\\", "/"); + string name = System.IO.Path.GetFileNameWithoutExtension(file); + bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath); + + // Convert absolute path to project-relative path + string projectRelativePath; + if (isPackage) + { + // For package paths, convert to Packages/... format + projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length); + } + else if (absolutePath.StartsWith(assetsBasePath)) + { + // For project assets, convert to Assets/... format + projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length); + } + else + { + // Fallback: use the absolute path if we can't determine the relative path + projectRelativePath = absolutePath; + } + + string normalizedPath = projectRelativePath.Replace("\\", "/"); + if (seenPaths.Add(normalizedPath)) + { + templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" }); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to list VFX templates under '{basePath}': {ex.Message}"); + } + } + + // Also search project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset"); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + string normalizedPath = path.Replace("\\", "/"); + if (seenPaths.Add(normalizedPath)) + { + string name = System.IO.Path.GetFileNameWithoutExtension(path); + templates.Add(new { name = name, path = path, source = "project" }); + } + } + + return new + { + success = true, + data = new + { + count = templates.Count, + templates = templates + } + }; + } + + /// + /// Lists all VFX assets in the project. + /// + public static object ListAssets(JObject @params) + { + string searchFolder = @params["folder"]?.ToString(); + string searchPattern = @params["search"]?.ToString(); + + string filter = "t:VisualEffectAsset"; + if (!string.IsNullOrEmpty(searchPattern)) + { + filter += " " + searchPattern; + } + + string[] guids; + if (!string.IsNullOrEmpty(searchFolder)) + { + if (searchFolder.Contains("\\") || searchFolder.Contains("..") || System.IO.Path.IsPathRooted(searchFolder)) + { + return new { success = false, message = "Invalid folder: traversal and absolute paths are not allowed" }; + } + + if (searchFolder.StartsWith("Packages/")) + { + return new { success = false, message = "Invalid folder: VFX assets must live under Assets/." }; + } + + if (!searchFolder.StartsWith("Assets/")) + { + searchFolder = "Assets/" + searchFolder; + } + + string fullPath = System.IO.Path.Combine(UnityEngine.Application.dataPath, searchFolder.Substring("Assets/".Length)); + string canonicalProjectRoot = System.IO.Path.GetFullPath(UnityEngine.Application.dataPath); + string canonicalSearchFolder = System.IO.Path.GetFullPath(fullPath); + if (!canonicalSearchFolder.StartsWith(canonicalProjectRoot + System.IO.Path.DirectorySeparatorChar) && + canonicalSearchFolder != canonicalProjectRoot) + { + return new { success = false, message = "Invalid folder: would escape project directory" }; + } + + guids = AssetDatabase.FindAssets(filter, new[] { searchFolder }); + } + else + { + guids = AssetDatabase.FindAssets(filter); + } + + var assets = new List(); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + assets.Add(new + { + name = asset.name, + path = path, + guid = guid + }); + } + } + + return new + { + success = true, + data = new + { + count = assets.Count, + assets = assets + } + }; + } + + private static string ValidateVfxGraphVersion() + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + if (info == null) + { + return "VFX Graph package (com.unity.visualeffectgraph) not installed"; + } + + if (IsVersionSupported(info.version)) + { + return null; + } + + string supported = string.Join(", ", SupportedVfxGraphVersions.Select(version => $"{version}.x")); + return $"Unsupported VFX Graph version {info.version}. Supported versions: {supported}."; + } + + private static bool IsVersionSupported(string installedVersion) + { + if (string.IsNullOrEmpty(installedVersion)) + { + return false; + } + + string normalized = installedVersion; + int suffixIndex = normalized.IndexOfAny(new[] { '-', '+' }); + if (suffixIndex >= 0) + { + normalized = normalized.Substring(0, suffixIndex); + } + + if (!Version.TryParse(normalized, out Version installed)) + { + return false; + } + + foreach (string supported in SupportedVfxGraphVersions) + { + if (!Version.TryParse(supported, out Version target)) + { + continue; + } + + if (installed.Major == target.Major && installed.Minor == target.Minor) + { + return true; + } + } + + return false; + } + + private static string TryGetAssetPathFromFileSystem(string templatePath) + { + if (string.IsNullOrEmpty(templatePath)) + { + return null; + } + + string normalized = templatePath.Replace("\\", "/"); + string assetsRoot = Application.dataPath.Replace("\\", "/"); + + if (normalized.StartsWith(assetsRoot + "/")) + { + return "Assets/" + normalized.Substring(assetsRoot.Length + 1); + } + + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + if (packageInfo != null) + { + string packageRoot = packageInfo.resolvedPath.Replace("\\", "/"); + if (normalized.StartsWith(packageRoot + "/")) + { + return "Packages/" + packageInfo.name + "/" + normalized.Substring(packageRoot.Length + 1); + } + } + + return null; + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta new file mode 100644 index 0000000..3aab85d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: a1dfb51f038764a6da23619cac60f299 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphAssets.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs new file mode 100644 index 0000000..d342085 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Common utilities for VFX Graph operations. + /// + internal static class VfxGraphCommon + { +#if UNITY_VFX_GRAPH + /// + /// Finds a VisualEffect component on the target GameObject. + /// + public static VisualEffect FindVisualEffect(JObject @params) + { + if (@params == null) + return null; + + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta new file mode 100644 index 0000000..f4ac784 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 0a6dbf78125194cf29b98d658af1039a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphCommon.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs new file mode 100644 index 0000000..e906481 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs @@ -0,0 +1,89 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Playback control operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphControl + { +#if !UNITY_VFX_GRAPH + public static object Control(JObject @params, string action) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetPlaybackSpeed(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetSeed(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object Control(JObject @params, string action) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + switch (action) + { + case "play": vfx.Play(); break; + case "stop": vfx.Stop(); break; + case "pause": vfx.pause = !vfx.pause; break; + case "reinit": vfx.Reinit(); break; + default: + return new { success = false, message = $"Unknown VFX action: {action}" }; + } + + return new { success = true, message = $"VFX {action}", isPaused = vfx.pause }; + } + + public static object SetPlaybackSpeed(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + float rate = @params["playRate"]?.ToObject() ?? 1f; + Undo.RecordObject(vfx, "Set VFX Play Rate"); + vfx.playRate = rate; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set play rate = {rate}" }; + } + + public static object SetSeed(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + uint seed = @params["seed"]?.ToObject() ?? 0; + bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject() ?? true; + + Undo.RecordObject(vfx, "Set VFX Seed"); + vfx.startSeed = seed; + vfx.resetSeedOnPlay = resetOnPlay; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set seed = {seed}" }; + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta new file mode 100644 index 0000000..20fa26d --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 4720d53b13bc14989803670a788a1eaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphControl.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs new file mode 100644 index 0000000..e3cb8f4 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Read operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphRead + { +#if !UNITY_VFX_GRAPH + public static object GetInfo(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object GetInfo(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + return new + { + success = true, + data = new + { + gameObject = vfx.gameObject.name, + assetName = vfx.visualEffectAsset?.name ?? "None", + aliveParticleCount = vfx.aliveParticleCount, + culled = vfx.culled, + pause = vfx.pause, + playRate = vfx.playRate, + startSeed = vfx.startSeed + } + }; + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta new file mode 100644 index 0000000..810950a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 419e293a95ea64af5ad6984b1d02b9b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphRead.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs new file mode 100644 index 0000000..530bde9 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs @@ -0,0 +1,310 @@ +using System; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +#if UNITY_VFX_GRAPH +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools.Vfx +{ + /// + /// Parameter setter operations for VFX Graph (VisualEffect component). + /// Requires com.unity.visualeffectgraph package and UNITY_VFX_GRAPH symbol. + /// + internal static class VfxGraphWrite + { +#if !UNITY_VFX_GRAPH + public static object SetParameter(JObject @params, Action setter) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetVector(JObject @params, int dims) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetColor(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetGradient(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetTexture(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetMesh(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SetCurve(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } + + public static object SendEvent(JObject @params) + { + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; + } +#else + public static object SetParameter(JObject @params, Action setter) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + JToken valueToken = @params["value"]; + if (valueToken == null) + { + return new { success = false, message = "Value required" }; + } + + // Safely deserialize the value + T value; + try + { + value = valueToken.ToObject(); + } + catch (JsonException ex) + { + return new { success = false, message = $"Invalid value for {param}: {ex.Message}" }; + } + catch (InvalidCastException ex) + { + return new { success = false, message = $"Invalid value type for {param}: {ex.Message}" }; + } + + Undo.RecordObject(vfx, $"Set VFX {param}"); + setter(vfx, param, value); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set {param} = {value}" }; + } + + public static object SetVector(JObject @params, int dims) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + if (dims != 2 && dims != 3 && dims != 4) + { + return new { success = false, message = $"Unsupported vector dimension: {dims}. Expected 2, 3, or 4." }; + } + + Vector4 vec = ManageVfxCommon.ParseVector4(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX {param}"); + + switch (dims) + { + case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break; + case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break; + case 4: vfx.SetVector4(param, vec); break; + } + + EditorUtility.SetDirty(vfx); + return new { success = true, message = $"Set {param}" }; + } + + public static object SetColor(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + Color color = ManageVfxCommon.ParseColor(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX Color {param}"); + vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a)); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set color {param}" }; + } + + public static object SetGradient(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + Gradient gradient = ManageVfxCommon.ParseGradient(@params["gradient"]); + Undo.RecordObject(vfx, $"Set VFX Gradient {param}"); + vfx.SetGradient(param, gradient); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set gradient {param}" }; + } + + public static object SetTexture(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + string path = @params["texturePath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) + { + return new { success = false, message = "Parameter and texturePath required" }; + } + + var findInst = new JObject { ["find"] = path }; + Texture tex = ObjectResolver.Resolve(findInst, typeof(Texture)) as Texture; + if (tex == null) + { + return new { success = false, message = $"Texture not found: {path}" }; + } + + Undo.RecordObject(vfx, $"Set VFX Texture {param}"); + vfx.SetTexture(param, tex); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set texture {param} = {tex.name}" }; + } + + public static object SetMesh(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + string path = @params["meshPath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) + { + return new { success = false, message = "Parameter and meshPath required" }; + } + + var findInst = new JObject { ["find"] = path }; + Mesh mesh = ObjectResolver.Resolve(findInst, typeof(Mesh)) as Mesh; + if (mesh == null) + { + return new { success = false, message = $"Mesh not found: {path}" }; + } + + Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); + vfx.SetMesh(param, mesh); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set mesh {param} = {mesh.name}" }; + } + + public static object SetCurve(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) + { + return new { success = false, message = "Parameter name required" }; + } + + AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(@params["curve"], 1f); + Undo.RecordObject(vfx, $"Set VFX Curve {param}"); + vfx.SetAnimationCurve(param, curve); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set curve {param}" }; + } + + public static object SendEvent(JObject @params) + { + VisualEffect vfx = VfxGraphCommon.FindVisualEffect(@params); + if (vfx == null) + { + return new { success = false, message = "VisualEffect not found" }; + } + + string eventName = @params["eventName"]?.ToString(); + if (string.IsNullOrEmpty(eventName)) + { + return new { success = false, message = "Event name required" }; + } + + VFXEventAttribute attr = vfx.CreateVFXEventAttribute(); + if (@params["position"] != null) + { + attr.SetVector3("position", ManageVfxCommon.ParseVector3(@params["position"])); + } + if (@params["velocity"] != null) + { + attr.SetVector3("velocity", ManageVfxCommon.ParseVector3(@params["velocity"])); + } + if (@params["color"] != null) + { + var c = ManageVfxCommon.ParseColor(@params["color"]); + attr.SetVector3("color", new Vector3(c.r, c.g, c.b)); + } + if (@params["size"] != null) + { + float? sizeValue = @params["size"].Value(); + if (sizeValue.HasValue) + { + attr.SetFloat("size", sizeValue.Value); + } + } + if (@params["lifetime"] != null) + { + float? lifetimeValue = @params["lifetime"].Value(); + if (lifetimeValue.HasValue) + { + attr.SetFloat("lifetime", lifetimeValue.Value); + } + } + + vfx.SendEvent(eventName, attr); + return new { success = true, message = $"Sent event '{eventName}'" }; + } +#endif + } +} diff --git a/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta new file mode 100644 index 0000000..7aed20a --- /dev/null +++ b/Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs.meta @@ -0,0 +1,18 @@ +fileFormatVersion: 2 +guid: 7516cdde6a4b648c9a2def6c26103cc4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: +AssetOrigin: + serializedVersion: 1 + productId: 329908 + packageName: MCP for Unity | AI Driven Development + packageVersion: 9.0.3 + assetPath: Assets/MCPForUnity/Editor/Tools/Vfx/VfxGraphWrite.cs + uploadId: 855486 diff --git a/Assets/MCPForUnity/Editor/Windows.meta b/Assets/MCPForUnity/Editor/Windows.meta new file mode 100644 index 0000000..eda016e --- /dev/null +++ b/Assets/MCPForUnity/Editor/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d2ee39f5d4171184eb208e865c1ef4c1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Windows/Components.meta b/Assets/MCPForUnity/Editor/Windows/Components.meta new file mode 100644 index 0000000..716e0fa --- /dev/null +++ b/Assets/MCPForUnity/Editor/Windows/Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 82074be914aefa84cb557c599d2319b3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Windows/Components/Advanced.meta b/Assets/MCPForUnity/Editor/Windows/Components/Advanced.meta new file mode 100644 index 0000000..9c3d024 --- /dev/null +++ b/Assets/MCPForUnity/Editor/Windows/Components/Advanced.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7723ed5eaaccb104e93acb9fd2d8cd32 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs b/Assets/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs new file mode 100644 index 0000000..8898fcf --- /dev/null +++ b/Assets/MCPForUnity/Editor/Windows/Components/Advanced/McpAdvancedSection.cs @@ -0,0 +1,467 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows.Components.Advanced +{ + /// + /// Controller for the Advanced Settings section. + /// Handles path overrides, server source configuration, dev mode, and package deployment. + /// + public class McpAdvancedSection + { + // UI Elements + private TextField uvxPathOverride; + private Button browseUvxButton; + private Button clearUvxButton; + private VisualElement uvxPathStatus; + private TextField gitUrlOverride; + private Button browseGitUrlButton; + private Button clearGitUrlButton; + private Toggle debugLogsToggle; + private Toggle devModeForceRefreshToggle; + private Toggle useBetaServerToggle; + private TextField deploySourcePath; + private Button browseDeploySourceButton; + private Button clearDeploySourceButton; + private Button deployButton; + private Button deployRestoreButton; + private Label deployTargetLabel; + private Label deployBackupLabel; + private Label deployStatusLabel; + private VisualElement healthIndicator; + private Label healthStatus; + private Button testConnectionButton; + + // Events + public event Action OnGitUrlChanged; + public event Action OnHttpServerCommandUpdateRequested; + public event Action OnTestConnectionRequested; + public event Action OnBetaModeChanged; + + public VisualElement Root { get; private set; } + + public McpAdvancedSection(VisualElement root) + { + Root = root; + CacheUIElements(); + InitializeUI(); + RegisterCallbacks(); + } + + private void CacheUIElements() + { + uvxPathOverride = Root.Q("uv-path-override"); + browseUvxButton = Root.Q