v9 pre-release pruning (#528)

* refactor: Split ParseColorOrDefault into two overloads and change default to Color.white

* Auto-format Python code

* Remove unused Python module

* Refactored VFX functionality into multiple files

Tested everything, works like a charm

* Rename ManageVfx folder to just Vfx

We know what it's managing

* Clean up whitespace on plugin tools and resources

* Make ManageGameObject less of a monolith by splitting it out into different files

* Remove obsolete FindObjectByInstruction method

We also update the namespace for ManageVFX

* refactor: Consolidate editor state resources into single canonical implementation

Merged EditorStateV2 into EditorState, making get_editor_state the canonical resource. Updated Unity C# to use EditorStateCache directly. Enhanced Python implementation with advice/staleness enrichment, external changes detection, and instance ID inference. Removed duplicate EditorStateV2 resource and legacy fallback mapping.

* Validate editor state with Pydantic models in both C# and Python

Added strongly-typed Pydantic models for EditorStateV2 schema in Python and corresponding C# classes with JsonProperty attributes. Updated C# to serialize using typed classes instead of anonymous objects. Python now validates the editor state payload before returning it, catching schema mismatches early.

* Consolidate run_tests and run_tests_async into single async implementation

Merged run_tests_async into run_tests, making async job-based execution the default behavior. Removed synchronous blocking test execution. Updated RunTests.cs to start test jobs immediately and return job_id for polling. Changed TestJobManager methods to internal visibility. Updated README to reflect single run_tests_async tool. Python implementation now uses async job pattern exclusively.

* Validate test job responses with Pydantic models in Python

* Change resources URI from unity:// to mcpforunity://

It should reduce conflicts with other Unity MCPs that users try, and to comply with Unity's requests regarding use of their company and product name

* Update README with all tools + better listing for resources

* Update other references to resources

* Updated translated doc - unfortunately I cannot verify

* Update the Chinese translation of the dev docks

* Change menu item from Setup Window to Local Setup Window

We now differentiate whether it's HTTP local or remote

* Fix URIs for menu items and tests

* Shouldn't have removed it

* Minor edits from CodeRabbit feedback

* Don't use reflection which takes longer

* Fix failing python tests

* Add serialization helpers for ParticleSystem curves and MinMaxCurve types

Added SerializeAnimationCurve and SerializeMinMaxCurve helper methods to properly serialize Unity's curve types. Updated GetInfo to use these helpers for startLifetime, startSpeed, startSize, gravityModifier, and rateOverTime instead of only reading constant values.

* Use ctx param

* Update Server/src/services/tools/run_tests.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Minor fixes

* Rename anything EditorStateV2 to just EditorState

It's the default, there's no old version

* Make infer_single_instance_id public by removing underscore prefix

* Fix Python tests, again

* Replace AI generated .meta files with actual Unity ones

* ## Pre-Launch Enhancements: Testing Infrastructure & Tool Improvements (#8)

* Add local test harness for fast developer iteration

Scripts for running the NL/T/GO test suites locally against a GUI Unity
Editor, complementing the CI workflows in .github/workflows/.

Benefits:
- 10-100x faster than CI (no Docker startup)
- Real-time Unity console debugging
- Single test execution for rapid iteration
- Auto-detects HTTP vs stdio transport

Usage:
  ./scripts/local-test/setup.sh           # One-time setup
  ./scripts/local-test/quick-test.sh NL-0 # Run single test
  ./scripts/local-test/run-nl-suite-local.sh  # Full suite

See scripts/local-test/README.md for details.

Also updated .gitignore to:
- Allow scripts/local-test/ to be tracked
- Ignore generated artifacts (reports/*.xml, .claude/local/, .unity-mcp/)

* Fix issue #525: Save dirty scenes for all test modes

Move SaveDirtyScenesIfNeeded() call outside the PlayMode conditional
so EditMode tests don't get blocked by Unity's "Save Scene" modal dialog.

This prevents MCP from timing out when running EditMode tests with unsaved
scene changes.

* fix: add missing FAST_FAIL_TIMEOUT constant in PluginHub

The FAST_FAIL_TIMEOUT class attribute was referenced on line 149 but never
defined, causing AttributeError on every ping attempt. This error was silently
caught by the broad 'except Exception' handler, causing all fast-fail commands
(read_console, get_editor_state, ping) to fail after 6 seconds of retries with
'ping not answered' error.

Added FAST_FAIL_TIMEOUT = 10 to define a 10-second timeout for fast-fail
commands, matching the intent of the existing fast-fail infrastructure.

* feat(ScriptableObject): enhance dry-run validation for AnimationCurve and Quaternion

Dry-run validation now validates value formats, not just property existence:

- AnimationCurve: Validates structure ({keys:[...]} or direct array), checks
  each keyframe is an object, validates numeric fields (time, value, inSlope,
  outSlope, inWeight, outWeight) and integer fields (weightedMode)
- Quaternion: Validates array length (3 for Euler, 4 for raw) or object
  structure ({x,y,z,w} or {euler:[x,y,z]}), ensures all components are numeric

Refactored shared validation helpers into appropriate locations:
- ParamCoercion: IsNumericToken, ValidateNumericField, ValidateIntegerField
- VectorParsing: ValidateAnimationCurveFormat, ValidateQuaternionFormat

Added comprehensive XML documentation clarifying keyframe field defaults
(all default to 0 except as noted).

Added 5 new dry-run validation tests covering valid and invalid formats
for both AnimationCurve and Quaternion properties.

* test: fix integration tests after merge

- test_refresh_unity_retry_recovery: Mock now handles both refresh_unity and
  get_editor_state commands (refresh_unity internally calls get_editor_state
  when wait_for_ready=True)
- test_run_tests_async_forwards_params: Mock response now includes required
  'mode' field for RunTestsStartResponse Pydantic validation
- test_get_test_job_forwards_job_id: Updated to handle GetTestJobResponse as
  Pydantic model instead of dict (use model_dump() for assertions)

* Update warning message to apply to all test modes

Follow-up to PR #527: Since SaveDirtyScenesIfNeeded() now runs for all test modes, update the warning message to say 'tests' instead of 'PlayMode tests'.

* feat(run_tests): add wait_timeout to get_test_job to avoid client loop detection

When polling for test completion, MCP clients like Cursor can detect the
repeated get_test_job calls as 'looping' and terminate the agent.

Added wait_timeout parameter that makes the server wait internally for tests
to complete (polling Unity every 2s) before returning. This dramatically
reduces client-side tool calls from 10-20 down to 1-2, avoiding loop detection.

Usage: get_test_job(job_id='xxx', wait_timeout=30)
- Returns immediately if tests complete within timeout
- Returns current status if timeout expires (client can call again)
- Recommended: 30-60 seconds

* fix: use Pydantic attribute access in test_run_tests_async for merge compatibility

* revert: remove local test harness - will be submitted in separate PR

---------

Co-authored-by: Scott Jennings <scott.jennings+CIGINT@cloudimperiumgames.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: dsarno <david@lighthaus.us>
Co-authored-by: Scott Jennings <scott.jennings+CIGINT@cloudimperiumgames.com>
main
Marcus Sanatan 2026-01-07 18:51:51 -04:00 committed by GitHub
parent 7de4d0fab4
commit c0fd7d50d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
149 changed files with 6374 additions and 5497 deletions

View File

@ -51,7 +51,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec
**Goal**: Test reading a single GameObject via resource **Goal**: Test reading a single GameObject via resource
**Actions**: **Actions**:
- Use the instance ID from GO-1 - Use the instance ID from GO-1
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID - Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID
- Verify response includes: instanceID, name, tag, layer, transform, path - Verify response includes: instanceID, name, tag, layer, transform, path
- **Pass criteria**: All expected fields present - **Pass criteria**: All expected fields present
@ -59,7 +59,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec
**Goal**: Test reading components via resource **Goal**: Test reading components via resource
**Actions**: **Actions**:
- Use the instance ID from GO-1 - Use the instance ID from GO-1
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID - Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID
- Verify response includes paginated component list in `data.items` - Verify response includes paginated component list in `data.items`
- Verify at least one component has typeName and instanceID - Verify at least one component has typeName and instanceID
- **Pass criteria**: Components list returned with proper pagination - **Pass criteria**: Components list returned with proper pagination
@ -94,7 +94,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec
**Goal**: Test reading a single component via resource **Goal**: Test reading a single component via resource
**Actions**: **Actions**:
- Get instance ID of GO_Test_Object from GO-5 - Get instance ID of GO_Test_Object from GO-5
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID} - Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID}
- Verify response includes component data with typeName="Rigidbody" - Verify response includes component data with typeName="Rigidbody"
- Verify mass property is 5.0 (set in GO-4) - Verify mass property is 5.0 (set in GO-4)
- **Pass criteria**: Component data returned with correct properties - **Pass criteria**: Component data returned with correct properties
@ -131,9 +131,9 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec
- `manage_components(action, target, component_type?, properties?)` - Add/remove/set_property/get_all/get_single - `manage_components(action, target, component_type?, properties?)` - Add/remove/set_property/get_all/get_single
### New Resources ### New Resources
- `unity://scene/gameobject/{instanceID}` - Single GameObject data - `mcpforunity://scene/gameobject/{instanceID}` - Single GameObject data
- `unity://scene/gameobject/{instanceID}/components` - All components (paginated) - `mcpforunity://scene/gameobject/{instanceID}/components` - All components (paginated)
- `unity://scene/gameobject/{instanceID}/component/{componentName}` - Single component - `mcpforunity://scene/gameobject/{instanceID}/component/{componentName}` - Single component
### Updated Resources ### Updated Resources
- `manage_scene(action="get_hierarchy")` - Now includes `componentTypes` array in each item - `manage_scene(action="get_hierarchy")` - Now includes `componentTypes` array in each item

View File

@ -9,7 +9,7 @@ AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_
## Mission ## Mission
1) Pick target file (prefer): 1) Pick target file (prefer):
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other. 2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other.
3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`. 3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`.
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`. 4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
@ -38,7 +38,7 @@ AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_
## Environment & Paths (CI) ## Environment & Paths (CI)
- Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate.
- **Canonical URIs only**: - **Canonical URIs only**:
- Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) - Primary: `mcpforunity://path/Assets/...` (never embed `project_root` in the URI)
- Relative (when supported): `Assets/...` - Relative (when supported): `Assets/...`
CI provides: CI provides:

View File

@ -8,7 +8,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,m
## Mission ## Mission
1) Pick target file (prefer): 1) Pick target file (prefer):
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
2) Execute T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state. 2) Execute T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state.
3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`. 3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`.
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`. 4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
@ -37,7 +37,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,m
## Environment & Paths (CI) ## Environment & Paths (CI)
- Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate.
- **Canonical URIs only**: - **Canonical URIs only**:
- Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) - Primary: `mcpforunity://path/Assets/...` (never embed `project_root` in the URI)
- Relative (when supported): `Assets/...` - Relative (when supported): `Assets/...`
CI provides: CI provides:
@ -151,7 +151,7 @@ STRICT OP GUARDRAILS
### T-G. Path Normalization Test (No State Change) ### T-G. Path Normalization Test (No State Change)
**Goal**: Verify URI forms work equivalently on modified file **Goal**: Verify URI forms work equivalently on modified file
**Actions**: **Actions**:
- Make identical edit using `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - Make identical edit using `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
- Then using `Assets/Scripts/LongUnityScriptClaudeTest.cs` - Then using `Assets/Scripts/LongUnityScriptClaudeTest.cs`
- Second should return `stale_file`, retry with updated SHA - Second should return `stale_file`, retry with updated SHA
- Verify both URI forms target same file - Verify both URI forms target same file

View File

@ -24,7 +24,7 @@ PATTERNS = [
r"^MCP resources list is empty$", r"^MCP resources list is empty$",
r"No MCP resources detected", r"No MCP resources detected",
r"aggregator.*returned\s*\[\s*\]", r"aggregator.*returned\s*\[\s*\]",
r"Unknown resource:\s*unity://", r"Unknown resource:\s*mcpforunity://",
r"Input should be a valid dictionary.*ctx", r"Input should be a valid dictionary.*ctx",
r"validation error .* ctx", r"validation error .* ctx",
] ]

View File

@ -136,7 +136,7 @@ namespace MCPForUnity.Editor.Dependencies
if (result.GetMissingRequired().Count > 0) if (result.GetMissingRequired().Count > 0)
{ {
result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Setup Window) for guided installation."); result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Local Setup Window) for guided installation.");
} }
} }
} }

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: f6789012345678901234abcdef012345 guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 789012345678901234abcdef01234567 guid: f6df82faa423f4e9ebb13a3dcee8ba19
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 6789012345678901234abcdef0123456 guid: ddeeeca2f876f4083a84417404175199
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 9012345678901234abcdef0123456789 guid: 67d73d0e8caef4e60942f4419c6b76bf
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 2345678901234abcdef0123456789abc guid: b682b492eb80d4ed6834b76f72c9f0f3
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 12345678901234abcdef0123456789ab guid: c6f602b0a8ca848859197f9a949a7a5d
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 012345678901234abcdef0123456789a guid: 1aedc29caa5704c07b487d20a27e9334
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -156,6 +156,65 @@ namespace MCPForUnity.Editor.Helpers
return defaultValue; return defaultValue;
} }
/// <summary>
/// Checks if a JToken represents a numeric value (integer or float).
/// Useful for validating JSON values before parsing.
/// </summary>
/// <param name="token">The JSON token to check</param>
/// <returns>True if the token is an integer or float, false otherwise</returns>
public static bool IsNumericToken(JToken token)
{
return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float);
}
/// <summary>
/// Validates that an optional field in a JObject is numeric if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or numeric; false if present but non-numeric</returns>
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;
}
/// <summary>
/// Validates that an optional field in a JObject is an integer if present.
/// Used for dry-run validation of complex type formats.
/// </summary>
/// <param name="obj">The JSON object containing the field</param>
/// <param name="fieldName">The name of the field to validate</param>
/// <param name="error">Output error message if validation fails</param>
/// <returns>True if the field is absent, null, or integer; false if present but non-integer</returns>
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;
}
/// <summary> /// <summary>
/// Normalizes a property name by removing separators and converting to camelCase. /// Normalizes a property name by removing separators and converting to camelCase.
/// Handles common naming variations from LLMs and humans. /// Handles common naming variations from LLMs and humans.

View File

@ -269,14 +269,14 @@ namespace MCPForUnity.Editor.Helpers
} }
/// <summary> /// <summary>
/// Parses a JToken into a Color, returning a default value if parsing fails. /// Parses a JToken into a Color, returning Color.white if parsing fails and no default is specified.
/// Added for ManageVFX refactoring.
/// </summary> /// </summary>
public static Color ParseColorOrDefault(JToken token, Color defaultValue = default) public static Color ParseColorOrDefault(JToken token) => ParseColor(token) ?? Color.white;
{
if (defaultValue == default) defaultValue = Color.black; /// <summary>
return ParseColor(token) ?? defaultValue; /// Parses a JToken into a Color, returning the specified default if parsing fails.
} /// </summary>
public static Color ParseColorOrDefault(JToken token, Color defaultValue) => ParseColor(token) ?? defaultValue;
/// <summary> /// <summary>
/// Parses a JToken into a Vector4, returning a default value if parsing fails. /// Parses a JToken into a Vector4, returning a default value if parsing fails.
@ -391,11 +391,24 @@ namespace MCPForUnity.Editor.Helpers
/// <summary> /// <summary>
/// Parses a JToken into an AnimationCurve. /// Parses a JToken into an AnimationCurve.
/// Supports formats: ///
/// - Constant: 1.0 (number) /// <para><b>Supported formats:</b></para>
/// - Simple: {start: 0.0, end: 1.0} /// <list type="bullet">
/// - Full: {keys: [{time: 0.0, value: 1.0, inTangent: 0.0, outTangent: 0.0}, ...]} /// <item>Constant: <c>1.0</c> (number) - Creates constant curve at that value</item>
/// Added for ManageVFX refactoring. /// <item>Simple: <c>{start: 0.0, end: 1.0}</c> or <c>{startValue: 0.0, endValue: 1.0}</c></item>
/// <item>Full: <c>{keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]}</c></item>
/// </list>
///
/// <para><b>Keyframe field defaults (for Full format):</b></para>
/// <list type="bullet">
/// <item><c>time</c> (float): <b>Default: 0</b></item>
/// <item><c>value</c> (float): <b>Default: 1</b> (note: differs from ManageScriptableObject which uses 0)</item>
/// <item><c>inTangent</c> (float): <b>Default: 0</b></item>
/// <item><c>outTangent</c> (float): <b>Default: 0</b></item>
/// </list>
///
/// <para><b>Note:</b> This method is used by ManageVFX. For ScriptableObject patching,
/// see <see cref="MCPForUnity.Editor.Tools.ManageScriptableObject"/> which has slightly different defaults.</para>
/// </summary> /// </summary>
/// <param name="token">The JSON token to parse</param> /// <param name="token">The JSON token to parse</param>
/// <returns>The parsed AnimationCurve or null if parsing fails</returns> /// <returns>The parsed AnimationCurve or null if parsing fails</returns>
@ -460,6 +473,192 @@ namespace MCPForUnity.Editor.Helpers
return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue); return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue);
} }
/// <summary>
/// Validates AnimationCurve JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Wrapped: <c>{ "keys": [ { "time": 0, "value": 1.0 }, ... ] }</c></item>
/// <item>Direct array: <c>[ { "time": 0, "value": 1.0 }, ... ]</c></item>
/// <item>Null/empty: Valid (will set empty curve)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
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;
}
/// <summary>
/// Validates Quaternion JSON format without parsing it.
/// Used by dry-run validation to provide early feedback on format errors.
///
/// <para><b>Validated formats:</b></para>
/// <list type="bullet">
/// <item>Euler array: <c>[x, y, z]</c> - 3 numeric elements</item>
/// <item>Raw quaternion: <c>[x, y, z, w]</c> - 4 numeric elements</item>
/// <item>Object: <c>{ "x": 0, "y": 0, "z": 0, "w": 1 }</c></item>
/// <item>Explicit euler: <c>{ "euler": [x, y, z] }</c></item>
/// <item>Null/empty: Valid (will set identity)</item>
/// </list>
/// </summary>
/// <param name="valueToken">The JSON value to validate</param>
/// <param name="message">Output message describing validation result or error</param>
/// <returns>True if format is valid, false otherwise</returns>
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;
}
}
/// <summary> /// <summary>
/// Parses a JToken into a Rect. /// Parses a JToken into a Rect.
/// Supports {x, y, width, height} format. /// Supports {x, y, width, height} format.

View File

@ -20,7 +20,7 @@ namespace MCPForUnity.Editor.MenuItems
} }
} }
[MenuItem("Window/MCP For Unity/Setup Window", priority = 2)] [MenuItem("Window/MCP For Unity/Local Setup Window", priority = 2)]
public static void ShowSetupWindow() public static void ShowSetupWindow()
{ {
SetupWindowService.ShowSetupWindow(); SetupWindowService.ShowSetupWindow();

View File

@ -1,8 +1,7 @@
using System; using System;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
namespace MCPForUnity.Editor.Resources.Editor namespace MCPForUnity.Editor.Resources.Editor
{ {
@ -16,20 +15,8 @@ namespace MCPForUnity.Editor.Resources.Editor
{ {
try try
{ {
var activeScene = EditorSceneManager.GetActiveScene(); var snapshot = EditorStateCache.GetSnapshot();
var state = new return new SuccessResponse("Retrieved editor state.", snapshot);
{
isPlaying = EditorApplication.isPlaying,
isPaused = EditorApplication.isPaused,
isCompiling = EditorApplication.isCompiling,
isUpdating = EditorApplication.isUpdating,
timeSinceStartup = EditorApplication.timeSinceStartup,
activeSceneName = activeScene.name ?? "",
selectionCount = UnityEditor.Selection.count,
activeObjectName = UnityEditor.Selection.activeObject?.name
};
return new SuccessResponse("Retrieved editor state.", state);
} }
catch (Exception e) catch (Exception e)
{ {

View File

@ -1,29 +0,0 @@
using System;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Resources.Editor
{
/// <summary>
/// Provides a cached, v2 readiness snapshot. This is designed to remain responsive even when Unity is busy.
/// </summary>
[McpForUnityResource("get_editor_state_v2")]
public static class EditorStateV2
{
public static object HandleCommand(JObject @params)
{
try
{
var snapshot = EditorStateCache.GetSnapshot();
return new SuccessResponse("Retrieved editor state (v2).", snapshot);
}
catch (Exception e)
{
return new ErrorResponse($"Error getting editor state (v2): {e.Message}");
}
}
}
}

View File

@ -282,4 +282,3 @@ namespace MCPForUnity.Editor.Resources.Scene
} }
} }
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEditorInternal; using UnityEditorInternal;
@ -32,6 +33,201 @@ namespace MCPForUnity.Editor.Services
private static JObject _cached; 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() static EditorStateCache()
{ {
try try
@ -135,85 +331,88 @@ namespace MCPForUnity.Editor.Services
activityPhase = "playmode_transition"; activityPhase = "playmode_transition";
} }
// Keep this as a plain JSON object for minimal friction with transports. var snapshot = new EditorStateSnapshot
return JObject.FromObject(new
{ {
schema_version = "unity-mcp/editor_state@2", SchemaVersion = "unity-mcp/editor_state@2",
observed_at_unix_ms = _observedUnixMs, ObservedAtUnixMs = _observedUnixMs,
sequence = _sequence, Sequence = _sequence,
unity = new Unity = new EditorStateUnity
{ {
instance_id = (string)null, InstanceId = null,
unity_version = Application.unityVersion, UnityVersion = Application.unityVersion,
project_id = (string)null, ProjectId = null,
platform = Application.platform.ToString(), Platform = Application.platform.ToString(),
is_batch_mode = Application.isBatchMode IsBatchMode = Application.isBatchMode
}, },
editor = new Editor = new EditorStateEditor
{ {
is_focused = isFocused, IsFocused = isFocused,
play_mode = new PlayMode = new EditorStatePlayMode
{ {
is_playing = EditorApplication.isPlaying, IsPlaying = EditorApplication.isPlaying,
is_paused = EditorApplication.isPaused, IsPaused = EditorApplication.isPaused,
is_changing = EditorApplication.isPlayingOrWillChangePlaymode IsChanging = EditorApplication.isPlayingOrWillChangePlaymode
}, },
active_scene = new ActiveScene = new EditorStateActiveScene
{ {
path = scenePath, Path = scenePath,
guid = sceneGuid, Guid = sceneGuid,
name = scene.name ?? string.Empty Name = scene.name ?? string.Empty
} }
}, },
activity = new Activity = new EditorStateActivity
{ {
phase = activityPhase, Phase = activityPhase,
since_unix_ms = _observedUnixMs, SinceUnixMs = _observedUnixMs,
reasons = new[] { reason } Reasons = new[] { reason }
}, },
compilation = new Compilation = new EditorStateCompilation
{ {
is_compiling = isCompiling, IsCompiling = isCompiling,
is_domain_reload_pending = _domainReloadPending, IsDomainReloadPending = _domainReloadPending,
last_compile_started_unix_ms = _lastCompileStartedUnixMs, LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
last_compile_finished_unix_ms = _lastCompileFinishedUnixMs, LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
last_domain_reload_before_unix_ms = _domainReloadBeforeUnixMs, LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
last_domain_reload_after_unix_ms = _domainReloadAfterUnixMs LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
}, },
assets = new Assets = new EditorStateAssets
{ {
is_updating = EditorApplication.isUpdating, IsUpdating = EditorApplication.isUpdating,
external_changes_dirty = false, ExternalChangesDirty = false,
external_changes_last_seen_unix_ms = (long?)null, ExternalChangesLastSeenUnixMs = null,
refresh = new ExternalChangesDirtySinceUnixMs = null,
ExternalChangesLastClearedUnixMs = null,
Refresh = new EditorStateRefresh
{ {
is_refresh_in_progress = false, IsRefreshInProgress = false,
last_refresh_requested_unix_ms = (long?)null, LastRefreshRequestedUnixMs = null,
last_refresh_finished_unix_ms = (long?)null LastRefreshFinishedUnixMs = null
} }
}, },
tests = new Tests = new EditorStateTests
{ {
is_running = testsRunning, IsRunning = testsRunning,
mode = testsMode, Mode = testsMode,
current_job_id = string.IsNullOrEmpty(currentJobId) ? null : currentJobId, CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
started_unix_ms = TestRunStatus.StartedUnixMs, StartedUnixMs = TestRunStatus.StartedUnixMs,
started_by = "unknown", StartedBy = "unknown",
last_run = TestRunStatus.FinishedUnixMs.HasValue LastRun = TestRunStatus.FinishedUnixMs.HasValue
? new ? new EditorStateLastRun
{ {
finished_unix_ms = TestRunStatus.FinishedUnixMs, FinishedUnixMs = TestRunStatus.FinishedUnixMs,
result = "unknown", Result = "unknown",
counts = (object)null Counts = null
} }
: null : null
}, },
transport = new Transport = new EditorStateTransport
{ {
unity_bridge_connected = (bool?)null, UnityBridgeConnected = null,
last_message_unix_ms = (long?)null LastMessageUnixMs = null
} }
}); };
return JObject.FromObject(snapshot);
} }
public static JObject GetSnapshot() public static JObject GetSnapshot()

View File

@ -416,7 +416,7 @@ namespace MCPForUnity.Editor.Services
PersistToSessionState(force: true); PersistToSessionState(force: true);
} }
public static TestJob GetJob(string jobId) internal static TestJob GetJob(string jobId)
{ {
if (string.IsNullOrWhiteSpace(jobId)) if (string.IsNullOrWhiteSpace(jobId))
{ {
@ -428,7 +428,7 @@ namespace MCPForUnity.Editor.Services
} }
} }
public static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests) internal static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests)
{ {
if (job == null) if (job == null)
{ {

View File

@ -106,10 +106,9 @@ namespace MCPForUnity.Editor.Services
}; };
var settings = new ExecutionSettings(filter); var settings = new ExecutionSettings(filter);
if (mode == TestMode.PlayMode) // Save dirty scenes for all test modes to prevent modal dialogs blocking MCP
{ // (Issue #525: EditMode tests were blocked by save dialog)
SaveDirtyScenesIfNeeded(); SaveDirtyScenesIfNeeded();
}
_testRunnerApi.Execute(settings); _testRunnerApi.Execute(settings);
@ -331,7 +330,7 @@ namespace MCPForUnity.Editor.Services
{ {
if (string.IsNullOrEmpty(scene.path)) if (string.IsNullOrEmpty(scene.path))
{ {
McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running PlayMode tests."); McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running tests.");
continue; continue;
} }
try try

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 345678901234abcdef0123456789abcd guid: d1bf468667bb649989e3ef53dafddea6
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -70,4 +70,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b61d0e8082ed14c1fb500648007bba7a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>
/// Component resolver that delegates to UnityTypeResolver.
/// Kept for backwards compatibility.
/// </summary>
internal static class ComponentResolver
{
/// <summary>
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
/// Delegates to UnityTypeResolver.TryResolve with Component constraint.
/// </summary>
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
{
return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component));
}
/// <summary>
/// Gets all accessible property and field names from a component type.
/// </summary>
public static List<string> GetAllComponentProperties(Type componentType)
{
if (componentType == null) return new List<string>();
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<SerializeField>() != null)
.Select(f => f.Name);
return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList();
}
/// <summary>
/// Suggests the most likely property matches for a user's input using fuzzy matching.
/// Uses Levenshtein distance, substring matching, and common naming pattern heuristics.
/// </summary>
public static List<string> GetFuzzyPropertySuggestions(string userInput, List<string> availableProperties)
{
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
return new List<string>();
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<string>();
}
}
private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new();
/// <summary>
/// Rule-based suggestions that mimic AI behavior for property matching.
/// This provides immediate value while we could add real AI integration later.
/// </summary>
private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties)
{
var suggestions = new List<string>();
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();
}
/// <summary>
/// Calculates Levenshtein distance between two strings for similarity matching.
/// </summary>
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];
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 5514ec4eb8a294a55892a13194e250e8 guid: f5e5a46bdebc040c68897fa4b5e689c7
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -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<Rigidbody>() != null || targetGo.GetComponent<Collider>() != 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<Rigidbody2D>() != null || targetGo.GetComponent<Collider2D>() != 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<string>();
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<SerializeField>() != 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<string> parts = new List<string>();
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;
}
}
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c guid: b580af06e2d3a4788960f3f779edac54
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2
@ -9,5 +9,3 @@ MonoImporter:
userData: userData:
assetBundleName: assetBundleName:
assetBundleVariant: assetBundleVariant:

View File

@ -0,0 +1,309 @@
#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<bool>() ?? 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 (!string.IsNullOrEmpty(prefabPath))
{
if (!prefabPath.Contains("/") && !prefabPath.EndsWith(".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.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
McpLog.Warn($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending.");
prefabPath += ".prefab";
}
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(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
{
McpLog.Warn($"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified.");
}
}
// --- 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));
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0931774a07e4b4626b4261dd8d0974c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,45 @@
#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<GameObject> targets = ManageGameObjectCommon.FindObjectsInternal(targetToken, searchMethod, true);
if (targets.Count == 0)
{
return new ErrorResponse($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
List<object> deletedObjects = new List<object>();
foreach (var targetGo in targets)
{
if (targetGo != null)
{
string goName = targetGo.name;
int goId = targetGo.GetInstanceID();
Undo.DestroyObjectImmediate(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).");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 505a482aaf60b415abd794737a630b10
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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)
}
);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 698728d56425a47af92a45377031a48b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f3cf2313460d44a09b258d2ee04c5ef0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,240 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace MCPForUnity.Editor.Tools.GameObjects
{
internal static class GameObjectModify
{
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"}'.");
}
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)
{
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<bool?>();
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<object>();
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<string>();
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);
return new SuccessResponse(
$"GameObject '{targetGo.name}' modified successfully.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ec5e33513bd094257a26ef6f75ea4574
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<float>() ?? 1f;
Vector3? customOffset = VectorParsing.ParseVector3(@params["offset"]);
bool useWorldSpace = @params["world_space"]?.ToObject<bool>() ?? 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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8b19997a165de45c2af3ada79a6d3f08
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>
/// Handles GameObject manipulation within the current scene (CRUD, find, components).
/// </summary>
[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}");
}
}
}
}

View File

@ -0,0 +1,210 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Tools;
using Newtonsoft.Json.Linq;
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<bool>() ?? false;
if (
targetToken?.Type == JTokenType.Integer
|| (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _))
)
{
findAll = false;
}
List<GameObject> results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams);
return results.Count > 0 ? results[0] : null;
}
internal static List<GameObject> FindObjectsInternal(
JToken targetToken,
string searchMethod,
bool findAll,
JObject findParams = null
)
{
List<GameObject> results = new List<GameObject>();
string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString();
bool searchInChildren = findParams?["searchInChildren"]?.ToObject<bool>() ?? false;
bool searchInactive = findParams?["searchInactive"]?.ToObject<bool>() ?? 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<Transform>(searchInactive)
.Select(t => t.gameObject)
: GetAllSceneObjects(searchInactive);
results.AddRange(searchPoolName.Where(go => go.name == searchTerm));
break;
case "by_path":
Transform foundTransform = rootSearchObject
? rootSearchObject.transform.Find(searchTerm)
: GameObject.Find(searchTerm)?.transform;
if (foundTransform != null)
results.Add(foundTransform.gameObject);
break;
case "by_tag":
var searchPoolTag = rootSearchObject
? rootSearchObject
.GetComponentsInChildren<Transform>(searchInactive)
.Select(t => t.gameObject)
: GetAllSceneObjects(searchInactive);
results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm)));
break;
case "by_layer":
var searchPoolLayer = rootSearchObject
? rootSearchObject
.GetComponentsInChildren<Transform>(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<GameObject> searchPoolComp;
if (rootSearchObject)
{
searchPoolComp = rootSearchObject
.GetComponentsInChildren(componentType, searchInactive)
.Select(c => (c as Component).gameObject);
}
else
{
searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive)
.Cast<Component>()
.Select(c => c.gameObject);
}
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;
}
}
GameObject objByPath = GameObject.Find(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<GameObject> { results[0] };
}
return results.Distinct().ToList();
}
private static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
{
var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects();
var allObjects = new List<GameObject>();
foreach (var root in rootObjects)
{
allObjects.AddRange(
root.GetComponentsInChildren<Transform>(includeInactive)
.Select(t => t.gameObject)
);
}
return allObjects;
}
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;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6bf0edf3cd2af46729294682cee3bee4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -50,5 +50,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -29,5 +29,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -7,7 +7,7 @@ using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Editor.Helpers; // For Response class
using static MCPForUnity.Editor.Tools.ManageGameObject; using MCPForUnity.Editor.Tools;
#if UNITY_6000_0_OR_NEWER #if UNITY_6000_0_OR_NEWER
using PhysicsMaterialType = UnityEngine.PhysicsMaterial; using PhysicsMaterialType = UnityEngine.PhysicsMaterial;

View File

@ -328,4 +328,3 @@ namespace MCPForUnity.Editor.Tools
#endregion #endregion
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
using UnityEditor; using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools namespace MCPForUnity.Editor.Tools
{ {
@ -418,10 +418,12 @@ namespace MCPForUnity.Editor.Tools
string description = ShaderUtil.GetPropertyDescription(shader, i); string description = ShaderUtil.GetPropertyDescription(shader, i);
object currentValue = null; object currentValue = null;
try { try
{
if (mat.HasProperty(name)) if (mat.HasProperty(name))
{ {
switch (type) { switch (type)
{
case ShaderUtil.ShaderPropertyType.Color: case ShaderUtil.ShaderPropertyType.Color:
var c = mat.GetColor(name); var c = mat.GetColor(name);
currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a };
@ -435,11 +437,14 @@ namespace MCPForUnity.Editor.Tools
case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break; case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break;
} }
} }
} catch (Exception ex) { }
catch (Exception ex)
{
currentValue = $"<error: {ex.Message}>"; currentValue = $"<error: {ex.Message}>";
} }
properties.Add(new { properties.Add(new
{
name = name, name = name,
type = type.ToString(), type = type.ToString(),
description = description, description = description,
@ -448,7 +453,8 @@ namespace MCPForUnity.Editor.Tools
} }
#endif #endif
return new SuccessResponse($"Retrieved material info for {mat.name}", new { return new SuccessResponse($"Retrieved material info for {mat.name}", new
{
material = mat.name, material = mat.name,
shader = shader.name, shader = shader.name,
properties = properties properties = properties

View File

@ -282,7 +282,7 @@ namespace MCPForUnity.Editor.Tools
catch { lengthBytes = fi.Exists ? fi.Length : 0; } catch { lengthBytes = fi.Exists ? fi.Length : 0; }
var data = new var data = new
{ {
uri = $"unity://path/{relativePath}", uri = $"mcpforunity://path/{relativePath}",
path = relativePath, path = relativePath,
sha256 = sha, sha256 = sha,
lengthBytes, lengthBytes,
@ -372,7 +372,7 @@ namespace MCPForUnity.Editor.Tools
try { File.Delete(tmp); } catch { } try { File.Delete(tmp); } catch { }
} }
var uri = $"unity://path/{relativePath}"; var uri = $"mcpforunity://path/{relativePath}";
var ok = new SuccessResponse( var ok = new SuccessResponse(
$"Script '{name}.cs' created successfully at '{relativePath}'.", $"Script '{name}.cs' created successfully at '{relativePath}'.",
new { uri, scheduledRefresh = false } new { uri, scheduledRefresh = false }
@ -401,7 +401,7 @@ namespace MCPForUnity.Editor.Tools
// Return both normal and encoded contents for larger files // Return both normal and encoded contents for larger files
bool isLarge = contents.Length > 10000; // If content is large, include encoded version bool isLarge = contents.Length > 10000; // If content is large, include encoded version
var uri = $"unity://path/{relativePath}"; var uri = $"mcpforunity://path/{relativePath}";
var responseData = new var responseData = new
{ {
uri, uri,
@ -481,7 +481,7 @@ namespace MCPForUnity.Editor.Tools
} }
// Prepare success response BEFORE any operation that can trigger a domain reload // Prepare success response BEFORE any operation that can trigger a domain reload
var uri = $"unity://path/{relativePath}"; var uri = $"mcpforunity://path/{relativePath}";
var ok = new SuccessResponse( var ok = new SuccessResponse(
$"Script '{name}.cs' updated successfully at '{relativePath}'.", $"Script '{name}.cs' updated successfully at '{relativePath}'.",
new { uri, path = relativePath, scheduledRefresh = true } new { uri, path = relativePath, scheduledRefresh = true }
@ -704,7 +704,7 @@ namespace MCPForUnity.Editor.Tools
$"No-op: contents unchanged for '{relativePath}'.", $"No-op: contents unchanged for '{relativePath}'.",
new new
{ {
uri = $"unity://path/{relativePath}", uri = $"mcpforunity://path/{relativePath}",
path = relativePath, path = relativePath,
editsApplied = 0, editsApplied = 0,
no_op = true, no_op = true,
@ -805,7 +805,7 @@ namespace MCPForUnity.Editor.Tools
$"Applied {spans.Count} text edit(s) to '{relativePath}'.", $"Applied {spans.Count} text edit(s) to '{relativePath}'.",
new new
{ {
uri = $"unity://path/{relativePath}", uri = $"mcpforunity://path/{relativePath}",
path = relativePath, path = relativePath,
editsApplied = spans.Count, editsApplied = spans.Count,
sha256 = newSha, sha256 = newSha,
@ -1375,7 +1375,7 @@ namespace MCPForUnity.Editor.Tools
new new
{ {
path = relativePath, path = relativePath,
uri = $"unity://path/{relativePath}", uri = $"mcpforunity://path/{relativePath}",
editsApplied = 0, editsApplied = 0,
no_op = true, no_op = true,
sha256 = sameSha, sha256 = sameSha,
@ -1441,7 +1441,7 @@ namespace MCPForUnity.Editor.Tools
new new
{ {
path = relativePath, path = relativePath,
uri = $"unity://path/{relativePath}", uri = $"mcpforunity://path/{relativePath}",
editsApplied = appliedCount, editsApplied = appliedCount,
scheduledRefresh = !immediate, scheduledRefresh = !immediate,
sha256 = newSha sha256 = newSha
@ -2637,8 +2637,8 @@ namespace MCPForUnity.Editor.Tools
{ {
if (string.IsNullOrEmpty(p)) return p; if (string.IsNullOrEmpty(p)) return p;
p = p.Replace('\\', '/').Trim(); p = p.Replace('\\', '/').Trim();
if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) if (p.StartsWith("mcpforunity://path/", StringComparison.OrdinalIgnoreCase))
p = p.Substring("unity://path/".Length); p = p.Substring("mcpforunity://path/".Length);
while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase))
p = p.Substring("Assets/".Length); p = p.Substring("Assets/".Length);
if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))

View File

@ -388,17 +388,51 @@ namespace MCPForUnity.Editor.Tools
if (prop != null) if (prop != null)
{ {
// Property exists - report its type for validation // 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 { results.Add(new {
index = i, index = i,
propertyPath = normalizedPath, propertyPath = normalizedPath,
op, op,
ok = true, ok = true,
message = "Property found.", message = valueValidationMsg ?? "Property found.",
propertyType = prop.propertyType.ToString(), propertyType = prop.propertyType.ToString(),
isArray = prop.isArray 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; return results;
@ -1057,9 +1091,32 @@ namespace MCPForUnity.Editor.Tools
/// <summary> /// <summary>
/// Sets an AnimationCurve property from a JSON structure. /// Sets an AnimationCurve property from a JSON structure.
/// Expected format: { "keys": [ { "time": 0, "value": 0, "inSlope": 0, "outSlope": 2 }, ... ] } ///
/// or a simple array: [ { "time": 0, "value": 0 }, ... ] /// <para><b>Supported formats:</b></para>
/// <list type="bullet">
/// <item>Wrapped: <c>{ "keys": [ { "time": 0, "value": 1.0 }, ... ] }</c></item>
/// <item>Direct array: <c>[ { "time": 0, "value": 1.0 }, ... ]</c></item>
/// <item>Null/empty: Sets an empty AnimationCurve</item>
/// </list>
///
/// <para><b>Keyframe fields:</b></para>
/// <list type="bullet">
/// <item><c>time</c> (float): Keyframe time position. <b>Default: 0</b></item>
/// <item><c>value</c> (float): Keyframe value. <b>Default: 0</b></item>
/// <item><c>inSlope</c> or <c>inTangent</c> (float): Incoming tangent slope. <b>Default: 0</b></item>
/// <item><c>outSlope</c> or <c>outTangent</c> (float): Outgoing tangent slope. <b>Default: 0</b></item>
/// <item><c>weightedMode</c> (int): Weighted mode enum (0=None, 1=In, 2=Out, 3=Both). <b>Default: 0 (None)</b></item>
/// <item><c>inWeight</c> (float): Incoming tangent weight. <b>Default: 0</b></item>
/// <item><c>outWeight</c> (float): Outgoing tangent weight. <b>Default: 0</b></item>
/// </list>
///
/// <para><b>Note:</b> All keyframe fields are optional. Missing fields gracefully default to 0,
/// which produces linear interpolation when both tangents are 0.</para>
/// </summary> /// </summary>
/// <param name="prop">The SerializedProperty of type AnimationCurve to set</param>
/// <param name="valueToken">JSON token containing the curve data</param>
/// <param name="message">Output message describing the result</param>
/// <returns>True if successful, false if the format is invalid</returns>
private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueToken, out string message) private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueToken, out string message)
{ {
message = null; message = null;
@ -1144,12 +1201,28 @@ namespace MCPForUnity.Editor.Tools
/// <summary> /// <summary>
/// Sets a Quaternion property from JSON. /// Sets a Quaternion property from JSON.
/// Accepts: ///
/// - [x, y, z] as Euler angles (degrees) /// <para><b>Supported formats:</b></para>
/// - [x, y, z, w] as raw quaternion components /// <list type="bullet">
/// - { "x": 0, "y": 0, "z": 0, "w": 1 } as raw quaternion /// <item>Euler array: <c>[x, y, z]</c> - Euler angles in degrees</item>
/// - { "euler": [x, y, z] } for explicit euler /// <item>Raw quaternion array: <c>[x, y, z, w]</c> - Direct quaternion components</item>
/// <item>Object format: <c>{ "x": 0, "y": 0, "z": 0, "w": 1 }</c> - Direct components</item>
/// <item>Explicit euler: <c>{ "euler": [x, y, z] }</c> - Euler angles in degrees</item>
/// <item>Null/empty: Sets Quaternion.identity (no rotation)</item>
/// </list>
///
/// <para><b>Format detection:</b></para>
/// <list type="bullet">
/// <item>3-element array → Interpreted as Euler angles (degrees)</item>
/// <item>4-element array → Interpreted as raw quaternion [x, y, z, w]</item>
/// <item>Object with euler → Uses euler array for rotation</item>
/// <item>Object with x, y, z, w → Uses raw quaternion components</item>
/// </list>
/// </summary> /// </summary>
/// <param name="prop">The SerializedProperty of type Quaternion to set</param>
/// <param name="valueToken">JSON token containing the quaternion data</param>
/// <param name="message">Output message describing the result</param>
/// <returns>True if successful, false if the format is invalid</returns>
private static bool TrySetQuaternion(SerializedProperty prop, JToken valueToken, out string message) private static bool TrySetQuaternion(SerializedProperty prop, JToken valueToken, out string message)
{ {
message = null; message = null;

File diff suppressed because it is too large Load Diff

View File

@ -171,5 +171,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -5,18 +5,20 @@ using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Tests; using MCPForUnity.Editor.Resources.Tests;
using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor.TestTools.TestRunner.Api;
namespace MCPForUnity.Editor.Tools namespace MCPForUnity.Editor.Tools
{ {
/// <summary> /// <summary>
/// Executes Unity tests for a specified mode and returns detailed results. /// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
/// Use get_test_job(job_id) to poll status/results.
/// </summary> /// </summary>
[McpForUnityTool("run_tests", AutoRegister = false)] [McpForUnityTool("run_tests", AutoRegister = false)]
public static class RunTests public static class RunTests
{ {
private const int DefaultTimeoutSeconds = 600; // 10 minutes public static Task<object> HandleCommand(JObject @params)
{
public static async Task<object> HandleCommand(JObject @params) try
{ {
string modeStr = @params?["mode"]?.ToString(); string modeStr = @params?["mode"]?.ToString();
if (string.IsNullOrWhiteSpace(modeStr)) if (string.IsNullOrWhiteSpace(modeStr))
@ -26,21 +28,7 @@ namespace MCPForUnity.Editor.Tools
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{ {
return new ErrorResponse(parseError); return Task.FromResult<object>(new ErrorResponse(parseError));
}
int timeoutSeconds = DefaultTimeoutSeconds;
try
{
var timeoutToken = @params?["timeoutSeconds"];
if (timeoutToken != null && int.TryParse(timeoutToken.ToString(), out var parsedTimeout) && parsedTimeout > 0)
{
timeoutSeconds = parsedTimeout;
}
}
catch
{
// Preserve default timeout if parsing fails
} }
bool includeDetails = false; bool includeDetails = false;
@ -61,51 +49,66 @@ namespace MCPForUnity.Editor.Tools
} }
catch catch
{ {
// Preserve defaults if parsing fails // ignore parse failures
} }
var filterOptions = ParseFilterOptions(@params); var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
var testService = MCPServiceLocator.Tests; return Task.FromResult<object>(new SuccessResponse("Test job started.", new
Task<TestRunResult> runTask;
try
{ {
runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions); job_id = jobId,
status = "running",
mode = parsedMode.Value.ToString(),
include_details = includeDetails,
include_failed_tests = includeFailedTests
}));
} }
catch (Exception ex) catch (Exception ex)
{ {
return new ErrorResponse($"Failed to start test run: {ex.Message}"); // Normalize the already-running case to a stable error token.
} if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0)
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds));
var completed = await Task.WhenAny(runTask, timeoutTask).ConfigureAwait(true);
if (completed != runTask)
{ {
return new ErrorResponse($"Test run timed out after {timeoutSeconds} seconds"); return Task.FromResult<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
}
return Task.FromResult<object>(new ErrorResponse($"Failed to start test job: {ex.Message}"));
}
} }
var result = await runTask.ConfigureAwait(true); private static TestFilterOptions GetFilterOptions(JObject @params)
string message = FormatTestResultMessage(parsedMode.Value.ToString(), result);
var data = result.ToSerializable(parsedMode.Value.ToString(), includeDetails, includeFailedTests);
return new SuccessResponse(message, data);
}
private static TestFilterOptions ParseFilterOptions(JObject @params)
{ {
if (@params == null) if (@params == null)
{ {
return null; return null;
} }
var testNames = ParseStringArray(@params, "testNames"); string[] ParseStringArray(string key)
var groupNames = ParseStringArray(@params, "groupNames"); {
var categoryNames = ParseStringArray(@params, "categoryNames"); var token = @params[key];
var assemblyNames = ParseStringArray(@params, "assemblyNames"); 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<string>()
.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");
// Return null if no filters specified
if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null) if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null)
{ {
return null; return null;
@ -119,53 +122,5 @@ namespace MCPForUnity.Editor.Tools
AssemblyNames = assemblyNames AssemblyNames = assemblyNames
}; };
} }
internal static string FormatTestResultMessage(string mode, TestRunResult result)
{
string message =
$"{mode} tests completed: {result.Passed}/{result.Total} passed, {result.Failed} failed, {result.Skipped} skipped";
// Add warning when no tests matched the filter criteria
if (result.Total == 0)
{
message += " (No tests matched the specified filters)";
}
return message;
}
private static string[] ParseStringArray(JObject @params, 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
.Where(t => t.Type == JTokenType.String)
.Select(t => t.ToString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToArray();
return values.Length > 0 ? values : null;
}
return null;
}
} }
} }

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: b177f204e300948f7ae07fb45d4c7ca9 guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@ -1,128 +0,0 @@
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
{
/// <summary>
/// Starts a Unity Test Runner run asynchronously and returns a job id immediately.
/// Use get_test_job(job_id) to poll status/results.
/// </summary>
[McpForUnityTool("run_tests_async", AutoRegister = false)]
public static class RunTestsAsync
{
public static Task<object> HandleCommand(JObject @params)
{
try
{
string modeStr = @params?["mode"]?.ToString();
if (string.IsNullOrWhiteSpace(modeStr))
{
modeStr = "EditMode";
}
if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError))
{
return Task.FromResult<object>(new ErrorResponse(parseError));
}
bool includeDetails = false;
bool includeFailedTests = false;
try
{
var includeDetailsToken = @params?["includeDetails"];
if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails))
{
includeDetails = parsedIncludeDetails;
}
var includeFailedTestsToken = @params?["includeFailedTests"];
if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests))
{
includeFailedTests = parsedIncludeFailedTests;
}
}
catch
{
// ignore parse failures
}
var filterOptions = GetFilterOptions(@params);
string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions);
return Task.FromResult<object>(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<object>(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 }));
}
return Task.FromResult<object>(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<string>()
.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
};
}
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1805768600c6a4228bae31231f2a4a9f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,130 @@
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
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);
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<float>() ?? 1f;
int segments = @params["segments"]?.ToObject<int>() ?? 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);
}
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<float>() ?? 1f;
float startAngle = (@params["startAngle"]?.ToObject<float>() ?? 0f) * Mathf.Deg2Rad;
float endAngle = (@params["endAngle"]?.ToObject<float>() ?? 180f) * Mathf.Deg2Rad;
int segments = @params["segments"]?.ToObject<int>() ?? 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);
}
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<int>() ?? 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);
}
EditorUtility.SetDirty(lr);
return new { success = true, message = $"Created {(isQuadratic ? "quadratic" : "cubic")} Bezier" };
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6d553d3837ecc4d999225bc9b3160a26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<LineRenderer>();
}
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
}
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df77cf0ca14344b0cb2f1b84c5eb15e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,139 @@
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" };
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" };
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" };
int index = @params["index"]?.ToObject<int>() ?? -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" };
Undo.RecordObject(lr, "Set Line Width");
var changes = new List<string>();
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" };
Undo.RecordObject(lr, "Set Line Color");
var changes = new List<string>();
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" };
Undo.RecordObject(lr, "Set Line Properties");
var changes = new List<string>();
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" };
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3911acc5a6a6a494cb88a647e0426d67
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,780 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
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
{
/// <summary>
/// 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)
/// - More to come based on demand and feedback!
/// </summary>
[McpForUnityTool("manage_vfx", AutoRegister = false)]
public static class ManageVFX
{
public static object HandleCommand(JObject @params)
{
string action = @params["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(@params, actionLower.Substring(9));
}
// VFX Graph actions (vfx_*)
if (actionLower.StartsWith("vfx_"))
{
return HandleVFXGraphAction(@params, actionLower.Substring(4));
}
// LineRenderer actions (line_*)
if (actionLower.StartsWith("line_"))
{
return HandleLineRendererAction(@params, actionLower.Substring(5));
}
// TrailRenderer actions (trail_*)
if (actionLower.StartsWith("trail_"))
{
return HandleTrailRendererAction(@params, 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 VFXCreateAsset(@params);
case "assign_asset": return VFXAssignAsset(@params);
case "list_templates": return VFXListTemplates(@params);
case "list_assets": return VFXListAssets(@params);
// Runtime parameter control
case "get_info": return VFXGetInfo(@params);
case "set_float": return VFXSetParameter<float>(@params, (vfx, n, v) => vfx.SetFloat(n, v));
case "set_int": return VFXSetParameter<int>(@params, (vfx, n, v) => vfx.SetInt(n, v));
case "set_bool": return VFXSetParameter<bool>(@params, (vfx, n, v) => vfx.SetBool(n, v));
case "set_vector2": return VFXSetVector(@params, 2);
case "set_vector3": return VFXSetVector(@params, 3);
case "set_vector4": return VFXSetVector(@params, 4);
case "set_color": return VFXSetColor(@params);
case "set_gradient": return VFXSetGradient(@params);
case "set_texture": return VFXSetTexture(@params);
case "set_mesh": return VFXSetMesh(@params);
case "set_curve": return VFXSetCurve(@params);
case "send_event": return VFXSendEvent(@params);
case "play": return VFXControl(@params, "play");
case "stop": return VFXControl(@params, "stop");
case "pause": return VFXControl(@params, "pause");
case "reinit": return VFXControl(@params, "reinit");
case "set_playback_speed": return VFXSetPlaybackSpeed(@params);
case "set_seed": return VFXSetSeed(@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
}
#if UNITY_VFX_GRAPH
private static VisualEffect FindVisualEffect(JObject @params)
{
GameObject go = ManageVfxCommon.FindTargetGameObject(@params);
return go?.GetComponent<VisualEffect>();
}
/// <summary>
/// Creates a new VFX Graph asset file from a template
/// </summary>
private static object VFXCreateAsset(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" };
// 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<UnityEngine.VFX.VisualEffectAsset>(assetPath) != null)
{
bool overwrite = @params["overwrite"]?.ToObject<bool>() ?? false;
if (!overwrite)
return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." };
AssetDatabase.DeleteAsset(assetPath);
}
// Find and copy template
string templatePath = FindVFXTemplate(template);
UnityEngine.VFX.VisualEffectAsset newAsset = null;
if (!string.IsNullOrEmpty(templatePath) && System.IO.File.Exists(templatePath))
{
// templatePath is a full filesystem path, need to copy file directly
// Get the full destination path
string projectRoot = System.IO.Path.GetDirectoryName(Application.dataPath);
string fullDestPath = System.IO.Path.Combine(projectRoot, assetPath);
// Ensure directory exists
string destDir = System.IO.Path.GetDirectoryName(fullDestPath);
if (!System.IO.Directory.Exists(destDir))
System.IO.Directory.CreateDirectory(destDir);
// Copy the file
System.IO.File.Copy(templatePath, fullDestPath, true);
AssetDatabase.Refresh();
newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.VFX.VisualEffectAsset>(assetPath);
}
else
{
// Create empty VFX asset using reflection to access internal API
// Note: Develop in Progress, TODO:// Find authenticated way to create VFX asset
try
{
// Try to use VisualEffectAssetEditorUtility.CreateNewAsset if available
var utilityType = System.Type.GetType("UnityEditor.VFX.VisualEffectAssetEditorUtility, Unity.VisualEffectGraph.Editor");
if (utilityType != null)
{
var createMethod = utilityType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static);
if (createMethod != null)
{
createMethod.Invoke(null, new object[] { assetPath });
AssetDatabase.Refresh();
newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.VFX.VisualEffectAsset>(assetPath);
}
}
// Fallback: Create a ScriptableObject-based asset
if (newAsset == null)
{
// Try direct creation via internal constructor
var resourceType = System.Type.GetType("UnityEditor.VFX.VisualEffectResource, Unity.VisualEffectGraph.Editor");
if (resourceType != null)
{
var createMethod = resourceType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic);
if (createMethod != null)
{
var resource = createMethod.Invoke(null, new object[] { assetPath });
AssetDatabase.Refresh();
newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.VFX.VisualEffectAsset>(assetPath);
}
}
}
}
catch (Exception ex)
{
return new { success = false, message = $"Failed to create VFX asset: {ex.Message}" };
}
}
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
}
};
}
/// <summary>
/// Finds VFX template path by name
/// </summary>
private static string FindVFXTemplate(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<string>();
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)
{
if (!System.IO.Directory.Exists(basePath)) continue;
foreach (string pattern in templatePatterns)
{
string[] files = System.IO.Directory.GetFiles(basePath, pattern, System.IO.SearchOption.AllDirectories);
if (files.Length > 0)
return files[0];
}
// Also search by partial match
try
{
string[] allVfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories);
foreach (string file in allVfxFiles)
{
if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower()))
return file;
}
}
catch { }
}
// Search in project assets
string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName);
if (guids.Length > 0)
{
return AssetDatabase.GUIDToAssetPath(guids[0]);
}
return null;
}
/// <summary>
/// Assigns a VFX asset to a VisualEffect component
/// </summary>
private static object VFXAssignAsset(JObject @params)
{
VisualEffect vfx = 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" };
// Normalize path
if (!assetPath.StartsWith("Assets/") && !assetPath.StartsWith("Packages/"))
assetPath = "Assets/" + assetPath;
if (!assetPath.EndsWith(".vfx"))
assetPath += ".vfx";
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.VFX.VisualEffectAsset>(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<UnityEngine.VFX.VisualEffectAsset>(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
}
};
}
/// <summary>
/// Lists available VFX templates
/// </summary>
private static object VFXListTemplates(JObject @params)
{
var templates = new List<object>();
// 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<string>();
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;
}
templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" });
}
}
catch { }
}
// Also search project assets
string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset");
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (!templates.Any(t => ((dynamic)t).path == path))
{
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
}
};
}
/// <summary>
/// Lists all VFX assets in the project
/// </summary>
private static object VFXListAssets(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))
guids = AssetDatabase.FindAssets(filter, new[] { searchFolder });
else
guids = AssetDatabase.FindAssets(filter);
var assets = new List<object>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.VFX.VisualEffectAsset>(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 object VFXGetInfo(JObject @params)
{
VisualEffect vfx = 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
}
};
}
private static object VFXSetParameter<T>(JObject @params, Action<VisualEffect, string, T> setter)
{
VisualEffect vfx = 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" };
Undo.RecordObject(vfx, $"Set VFX {param}");
T value = valueToken.ToObject<T>();
setter(vfx, param, value);
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set {param} = {value}" };
}
private static object VFXSetVector(JObject @params, int dims)
{
VisualEffect vfx = 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" };
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}" };
}
private static object VFXSetColor(JObject @params)
{
VisualEffect vfx = 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}" };
}
private static object VFXSetGradient(JObject @params)
{
VisualEffect vfx = 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}" };
}
private static object VFXSetTexture(JObject @params)
{
VisualEffect vfx = 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}" };
}
private static object VFXSetMesh(JObject @params)
{
VisualEffect vfx = 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}" };
}
private static object VFXSetCurve(JObject @params)
{
VisualEffect vfx = 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}" };
}
private static object VFXSendEvent(JObject @params)
{
VisualEffect vfx = 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) attr.SetFloat("size", @params["size"].ToObject<float>());
if (@params["lifetime"] != null) attr.SetFloat("lifetime", @params["lifetime"].ToObject<float>());
vfx.SendEvent(eventName, attr);
return new { success = true, message = $"Sent event '{eventName}'" };
}
private static object VFXControl(JObject @params, string action)
{
VisualEffect vfx = 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;
}
return new { success = true, message = $"VFX {action}", isPaused = vfx.pause };
}
private static object VFXSetPlaybackSpeed(JObject @params)
{
VisualEffect vfx = FindVisualEffect(@params);
if (vfx == null) return new { success = false, message = "VisualEffect not found" };
float rate = @params["playRate"]?.ToObject<float>() ?? 1f;
Undo.RecordObject(vfx, "Set VFX Play Rate");
vfx.playRate = rate;
EditorUtility.SetDirty(vfx);
return new { success = true, message = $"Set play rate = {rate}" };
}
private static object VFXSetSeed(JObject @params)
{
VisualEffect vfx = FindVisualEffect(@params);
if (vfx == null) return new { success = false, message = "VisualEffect not found" };
uint seed = @params["seed"]?.ToObject<uint>() ?? 0;
bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject<bool>() ?? 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
#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" };
}
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c5e603b26d2f47529394c1ec6b8ed79
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<ParticleSystem>();
}
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<float>());
}
if (token is JObject obj)
{
string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "constant";
switch (mode)
{
case "constant":
float constant = obj["value"]?.ToObject<float>() ?? defaultValue;
return new ParticleSystem.MinMaxCurve(constant);
case "random_between_constants":
case "two_constants":
float min = obj["min"]?.ToObject<float>() ?? 0f;
float max = obj["max"]?.ToObject<float>() ?? 1f;
return new ParticleSystem.MinMaxCurve(min, max);
case "curve":
AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(obj, defaultValue);
return new ParticleSystem.MinMaxCurve(obj["multiplier"]?.ToObject<float>() ?? 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);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a3a91aa6f6b9c4121a2ccc1a8147bbf9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,103 @@
using System;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
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<bool>() ?? 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" };
bool withChildren = @params["withChildren"]?.ToObject<bool>() ?? 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" };
Undo.RecordObject(ps, "Add Burst");
var emission = ps.emission;
float time = @params["time"]?.ToObject<float>() ?? 0f;
int minCountRaw = @params["minCount"]?.ToObject<int>() ?? @params["count"]?.ToObject<int>() ?? 30;
int maxCountRaw = @params["maxCount"]?.ToObject<int>() ?? @params["count"]?.ToObject<int>() ?? 30;
short minCount = (short)Math.Clamp(minCountRaw, 0, short.MaxValue);
short maxCount = (short)Math.Clamp(maxCountRaw, 0, short.MaxValue);
int cycles = @params["cycles"]?.ToObject<int>() ?? 1;
float interval = @params["interval"]?.ToObject<float>() ?? 0.01f;
var burst = new ParticleSystem.Burst(time, minCount, maxCount, cycles, interval);
burst.probability = @params["probability"]?.ToObject<float>() ?? 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" };
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04e1bfb655f184337943edd5a3fbbcdb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<ParticleSystemRenderer>();
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
}
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74bb7c48a4e1944bcba43b3619653cb9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,217 @@
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" };
Undo.RecordObject(ps, "Set ParticleSystem Main");
var main = ps.main;
var changes = new List<string>();
if (@params["duration"] != null) { main.duration = @params["duration"].ToObject<float>(); changes.Add("duration"); }
if (@params["looping"] != null) { main.loop = @params["looping"].ToObject<bool>(); changes.Add("looping"); }
if (@params["prewarm"] != null) { main.prewarm = @params["prewarm"].ToObject<bool>(); 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<ParticleSystemSimulationSpace>(@params["simulationSpace"].ToString(), true, out var simSpace)) { main.simulationSpace = simSpace; changes.Add("simulationSpace"); }
if (@params["scalingMode"] != null && Enum.TryParse<ParticleSystemScalingMode>(@params["scalingMode"].ToString(), true, out var scaleMode)) { main.scalingMode = scaleMode; changes.Add("scalingMode"); }
if (@params["playOnAwake"] != null) { main.playOnAwake = @params["playOnAwake"].ToObject<bool>(); changes.Add("playOnAwake"); }
if (@params["maxParticles"] != null) { main.maxParticles = @params["maxParticles"].ToObject<int>(); changes.Add("maxParticles"); }
EditorUtility.SetDirty(ps);
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" };
Undo.RecordObject(ps, "Set ParticleSystem Emission");
var emission = ps.emission;
var changes = new List<string>();
if (@params["enabled"] != null) { emission.enabled = @params["enabled"].ToObject<bool>(); 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" };
Undo.RecordObject(ps, "Set ParticleSystem Shape");
var shape = ps.shape;
var changes = new List<string>();
if (@params["enabled"] != null) { shape.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["shapeType"] != null && Enum.TryParse<ParticleSystemShapeType>(@params["shapeType"].ToString(), true, out var shapeType)) { shape.shapeType = shapeType; changes.Add("shapeType"); }
if (@params["radius"] != null) { shape.radius = @params["radius"].ToObject<float>(); changes.Add("radius"); }
if (@params["radiusThickness"] != null) { shape.radiusThickness = @params["radiusThickness"].ToObject<float>(); changes.Add("radiusThickness"); }
if (@params["angle"] != null) { shape.angle = @params["angle"].ToObject<float>(); changes.Add("angle"); }
if (@params["arc"] != null) { shape.arc = @params["arc"].ToObject<float>(); 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" };
Undo.RecordObject(ps, "Set ParticleSystem Color Over Lifetime");
var col = ps.colorOverLifetime;
var changes = new List<string>();
if (@params["enabled"] != null) { col.enabled = @params["enabled"].ToObject<bool>(); 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" };
Undo.RecordObject(ps, "Set ParticleSystem Size Over Lifetime");
var sol = ps.sizeOverLifetime;
var changes = new List<string>();
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<bool>();
changes.Add("enabled");
}
if (@params["separateAxes"] != null) { sol.separateAxes = @params["separateAxes"].ToObject<bool>(); 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" };
Undo.RecordObject(ps, "Set ParticleSystem Velocity Over Lifetime");
var vol = ps.velocityOverLifetime;
var changes = new List<string>();
if (@params["enabled"] != null) { vol.enabled = @params["enabled"].ToObject<bool>(); changes.Add("enabled"); }
if (@params["space"] != null && Enum.TryParse<ParticleSystemSimulationSpace>(@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" };
Undo.RecordObject(ps, "Set ParticleSystem Noise");
var noise = ps.noise;
var changes = new List<string>();
if (@params["enabled"] != null) { noise.enabled = @params["enabled"].ToObject<bool>(); 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<float>(); 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<bool>(); changes.Add("damping"); }
if (@params["octaveCount"] != null) { noise.octaveCount = @params["octaveCount"].ToObject<int>(); changes.Add("octaveCount"); }
if (@params["quality"] != null && Enum.TryParse<ParticleSystemNoiseQuality>(@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<ParticleSystemRenderer>();
if (renderer == null) return new { success = false, message = "ParticleSystemRenderer not found" };
Undo.RecordObject(renderer, "Set ParticleSystem Renderer");
var changes = new List<string>();
if (@params["renderMode"] != null && Enum.TryParse<ParticleSystemRenderMode>(@params["renderMode"].ToString(), true, out var renderMode)) { renderer.renderMode = renderMode; changes.Add("renderMode"); }
if (@params["sortMode"] != null && Enum.TryParse<ParticleSystemSortMode>(@params["sortMode"].ToString(), true, out var sortMode)) { renderer.sortMode = sortMode; changes.Add("sortMode"); }
if (@params["minParticleSize"] != null) { renderer.minParticleSize = @params["minParticleSize"].ToObject<float>(); changes.Add("minParticleSize"); }
if (@params["maxParticleSize"] != null) { renderer.maxParticleSize = @params["maxParticleSize"].ToObject<float>(); changes.Add("maxParticleSize"); }
if (@params["lengthScale"] != null) { renderer.lengthScale = @params["lengthScale"].ToObject<float>(); changes.Add("lengthScale"); }
if (@params["velocityScale"] != null) { renderer.velocityScale = @params["velocityScale"].ToObject<float>(); changes.Add("velocityScale"); }
if (@params["cameraVelocityScale"] != null) { renderer.cameraVelocityScale = @params["cameraVelocityScale"].ToObject<float>(); changes.Add("cameraVelocityScale"); }
if (@params["normalDirection"] != null) { renderer.normalDirection = @params["normalDirection"].ToObject<float>(); changes.Add("normalDirection"); }
if (@params["alignment"] != null && Enum.TryParse<ParticleSystemRenderSpace>(@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<bool>(); changes.Add("allowRoll"); }
if (@params["shadowBias"] != null) { renderer.shadowBias = @params["shadowBias"].ToObject<float>(); changes.Add("shadowBias"); }
RendererHelpers.ApplyCommonRendererProperties(renderer, @params, changes);
if (@params["materialPath"] != null)
{
var findInst = new JObject { ["find"] = @params["materialPath"].ToString() };
Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material;
if (mat != null) { renderer.sharedMaterial = mat; changes.Add("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)}" };
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2a68818a59fac4e2c83ad23433ddc9c1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,33 @@
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
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" };
#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
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: edebad99699494d5585418395a2bf518
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<TrailRenderer>();
}
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
}
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2921f0042777b4ebbaec4c79c60908a1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,90 @@
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" };
float time = @params["time"]?.ToObject<float>() ?? 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" };
Undo.RecordObject(tr, "Set Trail Width");
var changes = new List<string>();
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" };
Undo.RecordObject(tr, "Set Trail Color");
var changes = new List<string>();
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" };
Undo.RecordObject(tr, "Set Trail Properties");
var changes = new List<string>();
if (@params["minVertexDistance"] != null) { tr.minVertexDistance = @params["minVertexDistance"].ToObject<float>(); changes.Add("minVertexDistance"); }
if (@params["autodestruct"] != null) { tr.autodestruct = @params["autodestruct"].ToObject<bool>(); changes.Add("autodestruct"); }
if (@params["emitting"] != null) { tr.emitting = @params["emitting"].ToObject<bool>(); 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)}" };
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 33ba432240c134206a4f71ab24f0fb3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -8,6 +8,7 @@
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) [![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
[![](https://img.shields.io/badge/Website-Visit-purple)](https://www.coplay.dev/?ref=unity-mcp) [![](https://img.shields.io/badge/Website-Visit-purple)](https://www.coplay.dev/?ref=unity-mcp)
[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive)
[![Unity Asset Store](https://img.shields.io/badge/Unity%20Asset%20Store-Get%20Package-FF6A00?style=flat&logo=unity&logoColor=white)](https://assetstore.unity.com/packages/tools/generative-ai/mcp-for-unity-ai-driven-development-329908)
[![python](https://img.shields.io/badge/Python-3.10+-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![python](https://img.shields.io/badge/Python-3.10+-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp) ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp)
@ -18,7 +19,7 @@
MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本地 **MCP模型上下文协议客户端** 直接与您的 Unity 编辑器交互。为您的大语言模型提供管理资源、控制场景、编辑脚本和自动化 Unity 任务的工具。 MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本地 **MCP模型上下文协议客户端** 直接与您的 Unity 编辑器交互。为您的大语言模型提供管理资源、控制场景、编辑脚本和自动化 Unity 任务的工具。
<img width="406" height="704" alt="MCP for Unity screenshot" src="docs/images/readme_ui.png"> <img alt="MCP for Unity building a scene" src="docs/images/building_scene.gif">
### 💬 加入我们的 [Discord](https://discord.gg/y4p8KfzrN4) ### 💬 加入我们的 [Discord](https://discord.gg/y4p8KfzrN4)
@ -39,25 +40,34 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本
您的大语言模型可以使用以下功能: 您的大语言模型可以使用以下功能:
* `execute_custom_tool`: 执行由 Unity 注册的项目范围自定义工具。 * `manage_asset`: 执行资源操作(导入、创建、修改、删除、搜索等)。
* `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,"File/Save Project")。 * `manage_editor`: 控制编辑器状态(播放模式、活动工具、标签、层)。
* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 * `manage_gameobject`: 管理 GameObject创建、修改、删除、查找、复制、移动
* `manage_editor`: 控制和查询编辑器的状态和设置。 * `manage_components`: 管理 GameObject 上的组件(添加、移除、设置属性)。
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 * `manage_material`: 管理材质(创建、设置属性/颜色、分配给渲染器)。
* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。 * `manage_prefabs`: 预制体操作(打开/关闭 Stage、保存、从 GameObject 创建)。
* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。 * `manage_scene`: 场景管理(加载、保存、创建、获取层级、截图)。
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 * `manage_script`: 传统脚本操作(创建、读取、删除)。编辑建议使用 `apply_text_edits``script_apply_edits`
* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits``script_apply_edits` 进行编辑。 * `manage_scriptable_object`: 创建并修改 ScriptableObject 资产。
* `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 * `manage_shader`: Shader CRUD创建、读取、更新、删除
* `read_console`: 获取控制台消息或清除控制台。 * `manage_vfx`: VFX 操作ParticleSystem / LineRenderer / TrailRenderer / VisualEffectGraph 等)。
* `run_tests`: 在 Unity 编辑器中运行测试。 * `batch_execute`: ⚡ **推荐** - 批量执行多条命令10-100x 性能提升)。
* `set_active_instance`: 将后续工具调用路由到特定的 Unity 实例(当运行多个实例时)。 * `find_gameobjects`: 按 name/tag/layer/component/path/id 搜索 GameObject分页
* `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 * `find_in_file`: 使用正则搜索 C# 脚本并返回匹配的行号与片段。
* `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 * `read_console`: 获取或清除 Unity Console 日志。
* `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 * `refresh_unity`: 请求刷新资产数据库,并可选触发编译。
* `create_script`: 在给定的项目路径创建新的 C# 脚本。 * `run_tests`: 异步启动测试,返回 job_id。
* `get_test_job`: 轮询异步测试任务的进度和结果。
* `debug_request_context`: 返回当前请求上下文client_id、session_id、meta
* `execute_custom_tool`: 执行由 Unity 注册的项目级自定义工具。
* `execute_menu_item`: 执行 Unity 编辑器菜单项(例如 "File/Save Project")。
* `set_active_instance`: 将后续工具调用路由到特定 Unity 实例(从 `unity_instances` 获取 `Name@hash`)。
* `apply_text_edits`: 使用行/列范围进行精确文本编辑(支持前置条件哈希)。
* `script_apply_edits`: 结构化 C# 方法/类编辑insert/replace/delete边界更安全。
* `validate_script`: 快速验证basic/standard用于捕获语法/结构问题。
* `create_script`: 在指定项目路径创建新的 C# 脚本。
* `delete_script`: 通过 URI 或 Assets 相对路径删除 C# 脚本。 * `delete_script`: 通过 URI 或 Assets 相对路径删除 C# 脚本。
* `get_sha`: 获取 Unity C# 脚本的 SHA256 和基本元数据,而不返回文件内容。 * `get_sha`: 获取 Unity C# 脚本的 SHA256 与元数据(不返回内容)
</details> </details>
@ -66,18 +76,23 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本
您的大语言模型可以检索以下资源: 您的大语言模型可以检索以下资源:
* `custom_tools`: 列出活动 Unity 项目可用的自定义工具。 * `custom_tools` [`mcpforunity://custom-tools`]: 列出活动 Unity 项目可用的自定义工具。
* `unity_instances`: 列出所有正在运行的 Unity 编辑器实例及其详细信息(名称、路径、端口、状态)。 * `unity_instances` [`mcpforunity://instances`]: 列出所有正在运行的 Unity 编辑器实例及其详细信息。
* `menu_items`: 检索 Unity 编辑器中所有可用的菜单项。 * `menu_items` [`mcpforunity://menu-items`]: Unity 编辑器中所有可用菜单项。
* `tests`: 检索 Unity 编辑器中所有可用的测试。可以选择特定类型的测试(例如,"EditMode"、"PlayMode")。 * `get_tests` [`mcpforunity://tests`]: Unity 编辑器中所有可用测试EditMode + PlayMode
* `editor_active_tool`: 当前活动的编辑器工具(移动、旋转、缩放等)和变换手柄设置。 * `get_tests_for_mode` [`mcpforunity://tests/{mode}`]: 指定模式EditMode 或 PlayMode的测试列表。
* `editor_prefab_stage`: 如果预制件在隔离模式下打开,则为当前预制件编辑上下文。 * `gameobject_api` [`mcpforunity://scene/gameobject-api`]: GameObject 资源用法说明(先用 `find_gameobjects` 获取 instance ID
* `editor_selection`: 有关编辑器中当前选定对象的详细信息。 * `gameobject` [`mcpforunity://scene/gameobject/{instance_id}`]: 读取单个 GameObject 信息(不含完整组件序列化)。
* `editor_state`: 当前编辑器运行时状态,包括播放模式、编译状态、活动场景和选择摘要。 * `gameobject_components` [`mcpforunity://scene/gameobject/{instance_id}/components`]: 读取某 GameObject 的全部组件(支持分页,可选包含属性)。
* `editor_windows`: 所有当前打开的编辑器窗口及其标题、类型、位置和焦点状态。 * `gameobject_component` [`mcpforunity://scene/gameobject/{instance_id}/component/{component_name}`]: 读取某 GameObject 上指定组件的完整属性。
* `project_info`: 静态项目信息包括根路径、Unity 版本和平台。 * `editor_active_tool` [`mcpforunity://editor/active-tool`]: 当前活动工具Move/Rotate/Scale 等)与变换手柄设置。
* `project_layers`: 项目 TagManager 中定义的所有层及其索引0-31 * `editor_prefab_stage` [`mcpforunity://editor/prefab-stage`]: 当前 Prefab Stage 上下文(若未打开则 isOpen=false
* `project_tags`: 项目 TagManager 中定义的所有标签。 * `editor_selection` [`mcpforunity://editor/selection`]: 编辑器当前选中对象的详细信息。
* `editor_state` [`mcpforunity://editor/state`]: 编辑器就绪状态快照(包含建议与 staleness
* `editor_windows` [`mcpforunity://editor/windows`]: 当前打开的编辑器窗口列表(标题、类型、位置、焦点)。
* `project_info` [`mcpforunity://project/info`]: 静态项目信息根路径、Unity 版本、平台)。
* `project_layers` [`mcpforunity://project/layers`]: 项目层0-31及名称。
* `project_tags` [`mcpforunity://project/tags`]: 项目 Tag 列表。
</details> </details>
--- ---
@ -177,9 +192,9 @@ https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.6.0
HTTP 传输默认启用。Unity 窗口可以为您启动 FastMCP 服务器: HTTP 传输默认启用。Unity 窗口可以为您启动 FastMCP 服务器:
1. 打开 `Window > MCP for Unity` 1. 打开 `Window > MCP for Unity`
2. 确保**传输**下拉菜单设置为 `HTTP`(默认),并且 **HTTP URL** 是您想要的(默认为 `http://localhost:8080`)。 2. 确保 **Transport** 下拉菜单设置为 `HTTP Local`(默认),并将 **HTTP URL** 设置为你想要的地址(默认为 `http://localhost:8080`)。
3. 点击**启动本地 HTTP 服务器**。Unity 会生成一个新的操作系统终端,运行 `uv ... server.py --transport http` 3. 点击 **Start Server**。Unity 会生成一个新的系统终端窗口,运行 `uv ... server.py --transport http`
4. 在您工作时保持该终端窗口打开;关闭它会停止服务器。如果您需要干净地关闭它,请使用 Unity 窗口中的**停止会话**按钮。 4. 在你工作时保持该终端窗口打开;关闭它会停止服务器。如果你需要干净地关闭它,请使用 Unity 窗口中的 **Stop Session** 按钮。
> 更喜欢 stdio将传输下拉菜单更改为 `Stdio`Unity 将回退到嵌入式 TCP 桥接器,而不是启动 HTTP 服务器。 > 更喜欢 stdio将传输下拉菜单更改为 `Stdio`Unity 将回退到嵌入式 TCP 桥接器,而不是启动 HTTP 服务器。
@ -188,25 +203,29 @@ HTTP 传输默认启用。Unity 窗口可以为您启动 FastMCP 服务器:
您也可以从终端自己启动服务器——对 CI 或当您想查看原始日志时很有用: 您也可以从终端自己启动服务器——对 CI 或当您想查看原始日志时很有用:
```bash ```bash
uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.1.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080 uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080
``` ```
在客户端连接时保持进程运行。 在客户端连接时保持进程运行。
### 🛠️ 步骤 3配置您的 MCP 客户端 ### 🛠️ 步骤 3配置您的 MCP 客户端
您的 MCP 客户端Claude、Cursor 等)连接到步骤 2自动的 HTTP 服务器或通过手动配置(如下) 你的 MCP 客户端Claude、Cursor 等)连接到步骤 2 启动的 HTTP 服务器(自动)或使用下方的手动配置
**选项 A自动设置推荐用于 Claude/Cursor/VSC Copilot** 对于 **Claude Desktop** 用户,可以尝试下载并上传 `claude_skill_unity.zip`Unity_Skills参见这个链接https://www.claude.com/blog/skills
**选项 A配置按钮推荐用于 Claude/Cursor/VSC Copilot**
1. 在 Unity 中,前往 `Window > MCP for Unity` 1. 在 Unity 中,前往 `Window > MCP for Unity`
2. 点击 `Auto-Setup` 2. 从下拉菜单选择你的 Client/IDE。
3. 寻找绿色状态指示器 🟢 和"Connected ✓"。*(这会写入指向您在步骤 2 中启动的服务器的 HTTP `url`)。* 3. 点击 `Configure` 按钮。(或点击 `Configure All Detected Clients` 自动尝试配置所有检测到的客户端,但会更慢。)
4. 寻找绿色状态指示器 🟢 和 "Connected ✓"。*(这会写入指向你在步骤 2 中启动的服务器的 HTTP `url`)。*
<details><summary><strong>客户端特定故障排除</strong></summary> <details><summary><strong>客户端特定故障排除</strong></summary>
- **VSCode**:使用 `Code/User/mcp.json` 和顶级 `servers.unityMCP`、`"type": "http"` 以及步骤 2 中的 URL。在 Windows 上,当您切换回 stdio 时MCP for Unity 仍然偏好绝对 `uv.exe` 路径。 - **VSCode**:使用 `Code/User/mcp.json` 和顶级 `servers.unityMCP`、`"type": "http"` 以及步骤 2 中的 URL。在 Windows 上,当您切换回 stdio 时MCP for Unity 仍然偏好绝对 `uv.exe` 路径。
- **Cursor / Windsurf** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf):如果缺少 `uv`MCP for Unity 窗口会显示"uv Not Found"和快速 [HELP] 链接以及"Choose `uv` Install Location"按钮。 - **Cursor / Windsurf** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf):如果缺少 `uv`MCP for Unity 窗口会显示"uv Not Found"和快速 [HELP] 链接以及"Choose `uv` Install Location"按钮。
- **Claude Code** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code):如果找不到 `claude`,窗口会显示"Claude Not Found"和 [HELP] 以及"Choose Claude Location"按钮。注销现在会立即更新 UI。</details> - **Claude Code** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code):如果找不到 `claude`,窗口会显示"Claude Not Found"和 [HELP] 以及"Choose Claude Location"按钮。注销现在会立即更新 UI。
</details>
**选项 B手动配置** **选项 B手动配置**
@ -254,7 +273,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso
```json ```json
{ {
"mcpServers": { "mcpServers": {
"UnityMCP": { "unityMCP": {
"url": "http://localhost:8080/mcp" "url": "http://localhost:8080/mcp"
} }
} }
@ -293,7 +312,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso
```json ```json
{ {
"mcpServers": { "mcpServers": {
"UnityMCP": { "unityMCP": {
"command": "uv", "command": "uv",
"args": [ "args": [
"run", "run",
@ -313,7 +332,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso
```json ```json
{ {
"mcpServers": { "mcpServers": {
"UnityMCP": { "unityMCP": {
"command": "C:/Users/YOUR_USERNAME/AppData/Local/Microsoft/WinGet/Links/uv.exe", "command": "C:/Users/YOUR_USERNAME/AppData/Local/Microsoft/WinGet/Links/uv.exe",
"args": [ "args": [
"run", "run",
@ -336,7 +355,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso
## 使用方法 ▶️ ## 使用方法 ▶️
1. **打开您的 Unity 项目** 并验证 HTTP 服务器正在运行Window > MCP for Unity > Start Local HTTP Server一旦服务器启动,指示器应显示"Session Active"。 1. **打开你的 Unity 项目** 并确认 HTTP 服务器正在运行Window > MCP for Unity > Start Server。服务器启动,指示器应显示 "Session Active"。
2. **启动您的 MCP 客户端**Claude、Cursor 等)。它连接到步骤 3 中配置的 HTTP 端点——客户端不会生成额外的终端。 2. **启动您的 MCP 客户端**Claude、Cursor 等)。它连接到步骤 3 中配置的 HTTP 端点——客户端不会生成额外的终端。
@ -344,15 +363,27 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso
示例提示:`创建一个 3D 玩家控制器``创建一个 3D 井字游戏``创建一个酷炫的着色器并应用到立方体上`。 示例提示:`创建一个 3D 玩家控制器``创建一个 3D 井字游戏``创建一个酷炫的着色器并应用到立方体上`。
### 💡 性能提示:使用 `batch_execute`
当你需要执行多个操作时,请使用 `batch_execute` 而不是逐个调用工具。这可以显著降低延迟和 token 成本(单次最多 25 条命令):
```text
❌ 慢:创建 5 个立方体 → 5 次 manage_gameobject 调用
✅ 快:创建 5 个立方体 → 1 次 batch_execute包含 5 条 manage_gameobject 命令)
❌ 慢:先查找对象,再逐个加组件 → N+M 次调用
✅ 快:查找 + 批量加组件 → 1 次 find + 1 次 batch_execute包含 M 条 manage_components 命令)
```
### 使用多个 Unity 实例 ### 使用多个 Unity 实例
MCP for Unity 同时支持多个 Unity 编辑器实例。每个实例在每个 MCP 客户端会话中是隔离的。 MCP for Unity 同时支持多个 Unity 编辑器实例。每个实例在每个 MCP 客户端会话中是隔离的。
**要将工具调用定向到特定实例:** **要将工具调用定向到特定实例:**
1. 列出可用实例:要求您的大语言模型检查 `unity_instances` 资源 1. 列出可用实例:要求的大语言模型检查 `unity_instances` 资源
2. 设置活动实例:使用 `set_active_instance` 与实例名称(例如,`MyProject@abc123` 2. 设置活动实例:使用 `set_active_instance`,并传入 `unity_instances` 返回的精确 `Name@hash`(例如 `MyProject@abc123`
3. 所有后续工具路由到该实例,直到更改 3. 后续所有工具都会路由到该实例,直到你再次更改。如果存在多个实例且未设置活动实例,服务器会报错并提示选择实例。
**示例:** **示例:**
``` ```
@ -412,7 +443,7 @@ MCP for Unity 包含**注重隐私的匿名遥测**来帮助我们改进产品
- 检查状态窗口Window > MCP for Unity。 - 检查状态窗口Window > MCP for Unity。
- 重启 Unity。 - 重启 Unity。
- **MCP 客户端未连接/服务器未启动:** - **MCP 客户端未连接/服务器未启动:**
- 确保本地 HTTP 服务器正在运行Window > MCP for Unity > Start Local HTTP Server。保持生成的终端窗口打开。 - 确保本地 HTTP 服务器正在运行Window > MCP for Unity > Start Server。保持生成的终端窗口打开。
- **验证服务器路径:** 双重检查您的 MCP 客户端 JSON 配置中的 --directory 路径。它必须完全匹配安装位置: - **验证服务器路径:** 双重检查您的 MCP 客户端 JSON 配置中的 --directory 路径。它必须完全匹配安装位置:
- **Windows** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src` - **Windows** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
- **macOS** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src` - **macOS** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`

View File

@ -55,11 +55,12 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
* `manage_vfx`: VFX effect operations, including line/trail renderer, particle system, and VisualEffectGraph (in development). * `manage_vfx`: VFX effect operations, including line/trail renderer, particle system, and VisualEffectGraph (in development).
* `batch_execute`: ⚡ **RECOMMENDED** - Executes multiple commands in one batch for 10-100x better performance. Use this for any repetitive operations. * `batch_execute`: ⚡ **RECOMMENDED** - Executes multiple commands in one batch for 10-100x better performance. Use this for any repetitive operations.
* `find_gameobjects`: Search for GameObjects by name, tag, layer, component, path, or ID (paginated). * `find_gameobjects`: Search for GameObjects by name, tag, layer, component, path, or ID (paginated).
* `find_in_file`: Search a C# script with a regex pattern and return matching line numbers and excerpts.
* `read_console`: Gets messages from or clears the Unity console. * `read_console`: Gets messages from or clears the Unity console.
* `refresh_unity`: Request asset database refresh and optional compilation. * `refresh_unity`: Request asset database refresh and optional compilation.
* `run_tests_async`: Starts tests asynchronously, returns job_id for polling (preferred). * `run_tests`: Starts tests asynchronously, returns job_id for polling.
* `get_test_job`: Polls an async test job for progress and results. * `get_test_job`: Polls an async test job for progress and results.
* `run_tests`: Runs tests synchronously (blocks until complete). * `debug_request_context`: Return the current request context details (client_id, session_id, and meta dump).
* `execute_custom_tool`: Execute project-scoped custom tools registered by Unity. * `execute_custom_tool`: Execute project-scoped custom tools registered by Unity.
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
* `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`. * `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`.
@ -77,23 +78,23 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
Your LLM can retrieve the following resources: Your LLM can retrieve the following resources:
* `custom_tools`: Lists custom tools available for the active Unity project. * `custom_tools` [`mcpforunity://custom-tools`]: Lists custom tools available for the active Unity project.
* `unity_instances`: Lists all running Unity Editor instances with details (name, path, hash, status, session). * `unity_instances` [`mcpforunity://instances`]: Lists all running Unity Editor instances with details (name, path, hash, status, session).
* `menu_items`: All available menu items in the Unity Editor. * `menu_items` [`mcpforunity://menu-items`]: All available menu items in the Unity Editor.
* `tests`: All available tests (EditMode, PlayMode) in the Unity Editor. * `get_tests` [`mcpforunity://tests`]: All available tests (EditMode + PlayMode) in the Unity Editor.
* `gameobject_api`: Documentation for GameObject resources and how to use `find_gameobjects` tool. * `get_tests_for_mode` [`mcpforunity://tests/{mode}`]: All available tests for a specific mode (EditMode or PlayMode).
* `unity://scene/gameobject/{instanceID}`: Read-only access to GameObject data (name, tag, transform, components, children). * `gameobject_api` [`mcpforunity://scene/gameobject-api`]: Documentation for GameObject resources and how to use `find_gameobjects` tool.
* `unity://scene/gameobject/{instanceID}/components`: Read-only access to all components on a GameObject with full property serialization. * `gameobject` [`mcpforunity://scene/gameobject/{instance_id}`]: Read-only access to GameObject data (name, tag, transform, components, children).
* `unity://scene/gameobject/{instanceID}/component/{componentName}`: Read-only access to a specific component's properties. * `gameobject_components` [`mcpforunity://scene/gameobject/{instance_id}/components`]: Read-only access to all components on a GameObject with full property serialization.
* `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings. * `gameobject_component` [`mcpforunity://scene/gameobject/{instance_id}/component/{component_name}`]: Read-only access to a specific component's properties.
* `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode. * `editor_active_tool` [`mcpforunity://editor/active-tool`]: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.
* `editor_selection`: Detailed information about currently selected objects in the editor. * `editor_prefab_stage` [`mcpforunity://editor/prefab-stage`]: Current prefab editing context if a prefab is open in isolation mode.
* `editor_state`: Current editor runtime state (play mode, compilation, active scene, selection). * `editor_selection` [`mcpforunity://editor/selection`]: Detailed information about currently selected objects in the editor.
* `editor_state_v2`: Canonical editor readiness snapshot with advice and staleness info. * `editor_state` [`mcpforunity://editor/state`]: Editor readiness snapshot with advice and staleness info.
* `editor_windows`: All currently open editor windows with titles, types, positions, and focus state. * `editor_windows` [`mcpforunity://editor/windows`]: All currently open editor windows with titles, types, positions, and focus state.
* `project_info`: Static project information (root path, Unity version, platform). * `project_info` [`mcpforunity://project/info`]: Static project information (root path, Unity version, platform).
* `project_layers`: All layers defined in TagManager with their indices (0-31). * `project_layers` [`mcpforunity://project/layers`]: All layers defined in TagManager with their indices (0-31).
* `project_tags`: All tags defined in TagManager. * `project_tags` [`mcpforunity://project/tags`]: All tags defined in TagManager.
</details> </details>
--- ---

View File

@ -30,7 +30,8 @@ try: # pragma: no cover - startup safety guard
) )
for _name in _typing_names: for _name in _typing_names:
if not hasattr(builtins, _name) and hasattr(_typing, _name): if not hasattr(builtins, _name) and hasattr(_typing, _name):
setattr(builtins, _name, getattr(_typing, _name)) # type: ignore[attr-defined] # type: ignore[attr-defined]
setattr(builtins, _name, getattr(_typing, _name))
except Exception: except Exception:
pass pass
@ -234,10 +235,10 @@ mcp = FastMCP(
instructions=""" instructions="""
This server provides tools to interact with the Unity Game Engine Editor. This server provides tools to interact with the Unity Game Engine Editor.
I have a dynamic tool system. Always check the unity://custom-tools resource first to see what special capabilities are available for the current project. I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first to see what special capabilities are available for the current project.
Targeting Unity instances: Targeting Unity instances:
- Use the resource unity://instances to list active Unity sessions (Name@hash). - Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
- When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources. The server will error if multiple are connected and no active instance is set. - When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources. The server will error if multiple are connected and no active instance is set.
Important Workflows: Important Workflows:

View File

@ -310,7 +310,8 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N
# This matches the hash Unity uses when registering tools via WebSocket. # This matches the hash Unity uses when registering tools via WebSocket.
if target.hash: if target.hash:
return target.hash return target.hash
logger.warning(f"Unity instance {target.id} has empty hash; cannot resolve project ID") logger.warning(
f"Unity instance {target.id} has empty hash; cannot resolve project ID")
return None return None
except Exception: except Exception:
logger.debug( logger.debug(

View File

@ -31,7 +31,7 @@ class ActiveToolResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://editor/active-tool", uri="mcpforunity://editor/active-tool",
name="editor_active_tool", name="editor_active_tool",
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings." description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings."
) )

View File

@ -22,7 +22,7 @@ class CustomToolsResourceResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://custom-tools", uri="mcpforunity://custom-tools",
name="custom_tools", name="custom_tools",
description="Lists custom tools available for the active Unity project.", description="Lists custom tools available for the active Unity project.",
) )
@ -31,7 +31,7 @@ async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPRes
if not unity_instance: if not unity_instance:
return MCPResponse( return MCPResponse(
success=False, success=False,
message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.", message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
) )
project_id = resolve_project_id_for_unity_instance(unity_instance) project_id = resolve_project_id_for_unity_instance(unity_instance)

View File

@ -1,51 +1,304 @@
from pydantic import BaseModel import os
import time
from typing import Any
from fastmcp import Context from fastmcp import Context
from pydantic import BaseModel
from models import MCPResponse from models import MCPResponse
from services.registry import mcp_for_unity_resource from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance from services.state.external_changes_scanner import external_changes_scanner
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry from transport.legacy.unity_connection import async_send_command_with_retry
class EditorStateUnity(BaseModel):
instance_id: str | None = None
unity_version: str | None = None
project_id: str | None = None
platform: str | None = None
is_batch_mode: bool | None = None
class EditorStatePlayMode(BaseModel):
is_playing: bool | None = None
is_paused: bool | None = None
is_changing: bool | None = None
class EditorStateActiveScene(BaseModel):
path: str | None = None
guid: str | None = None
name: str | None = None
class EditorStateEditor(BaseModel):
is_focused: bool | None = None
play_mode: EditorStatePlayMode | None = None
active_scene: EditorStateActiveScene | None = None
class EditorStateActivity(BaseModel):
phase: str | None = None
since_unix_ms: int | None = None
reasons: list[str] | None = None
class EditorStateCompilation(BaseModel):
is_compiling: bool | None = None
is_domain_reload_pending: bool | None = None
last_compile_started_unix_ms: int | None = None
last_compile_finished_unix_ms: int | None = None
last_domain_reload_before_unix_ms: int | None = None
last_domain_reload_after_unix_ms: int | None = None
class EditorStateRefresh(BaseModel):
is_refresh_in_progress: bool | None = None
last_refresh_requested_unix_ms: int | None = None
last_refresh_finished_unix_ms: int | None = None
class EditorStateAssets(BaseModel):
is_updating: bool | None = None
external_changes_dirty: bool | None = None
external_changes_last_seen_unix_ms: int | None = None
external_changes_dirty_since_unix_ms: int | None = None
external_changes_last_cleared_unix_ms: int | None = None
refresh: EditorStateRefresh | None = None
class EditorStateLastRun(BaseModel):
finished_unix_ms: int | None = None
result: str | None = None
counts: Any | None = None
class EditorStateTests(BaseModel):
is_running: bool | None = None
mode: str | None = None
current_job_id: str | None = None
started_unix_ms: int | None = None
started_by: str | None = None
last_run: EditorStateLastRun | None = None
class EditorStateTransport(BaseModel):
unity_bridge_connected: bool | None = None
last_message_unix_ms: int | None = None
class EditorStateAdvice(BaseModel):
ready_for_tools: bool | None = None
blocking_reasons: list[str] | None = None
recommended_retry_after_ms: int | None = None
recommended_next_action: str | None = None
class EditorStateStaleness(BaseModel):
age_ms: int | None = None
is_stale: bool | None = None
class EditorStateData(BaseModel): class EditorStateData(BaseModel):
"""Editor state data fields.""" schema_version: str
isPlaying: bool = False observed_at_unix_ms: int
isPaused: bool = False sequence: int
isCompiling: bool = False unity: EditorStateUnity | None = None
isUpdating: bool = False editor: EditorStateEditor | None = None
timeSinceStartup: float = 0.0 activity: EditorStateActivity | None = None
activeSceneName: str = "" compilation: EditorStateCompilation | None = None
selectionCount: int = 0 assets: EditorStateAssets | None = None
activeObjectName: str | None = None tests: EditorStateTests | None = None
transport: EditorStateTransport | None = None
advice: EditorStateAdvice | None = None
staleness: EditorStateStaleness | None = None
class EditorStateResponse(MCPResponse): def _now_unix_ms() -> int:
"""Dynamic editor state information that changes frequently.""" return int(time.time() * 1000)
data: EditorStateData = EditorStateData()
def _in_pytest() -> bool:
# Avoid instance-discovery side effects during the Python integration test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
async def infer_single_instance_id(ctx: Context) -> str | None:
"""
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
This makes editor_state outputs self-describing even when no explicit active instance is set.
"""
await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
try:
transport = unity_transport._current_transport()
except Exception:
transport = None
if transport == "http":
# HTTP/WebSocket transport: derive from PluginHub sessions.
try:
from transport.plugin_hub import PluginHub
sessions_data = await PluginHub.get_sessions()
sessions = sessions_data.sessions if hasattr(
sessions_data, "sessions") else {}
if isinstance(sessions, dict) and len(sessions) == 1:
session = next(iter(sessions.values()))
project = getattr(session, "project", None)
project_hash = getattr(session, "hash", None)
if project and project_hash:
return f"{project}@{project_hash}"
except Exception:
return None
return None
# Stdio/TCP transport: derive from connection pool discovery.
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if isinstance(instances, list) and len(instances) == 1:
inst = instances[0]
inst_id = getattr(inst, "id", None)
return str(inst_id) if inst_id else None
except Exception:
return None
return None
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
now_ms = _now_unix_ms()
observed = state_v2.get("observed_at_unix_ms")
try:
observed_ms = int(observed)
except Exception:
observed_ms = now_ms
age_ms = max(0, now_ms - observed_ms)
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
is_stale = age_ms > 2000
compilation = state_v2.get("compilation") or {}
tests = state_v2.get("tests") or {}
assets = state_v2.get("assets") or {}
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
blocking: list[str] = []
if compilation.get("is_compiling") is True:
blocking.append("compiling")
if compilation.get("is_domain_reload_pending") is True:
blocking.append("domain_reload")
if tests.get("is_running") is True:
blocking.append("running_tests")
if refresh.get("is_refresh_in_progress") is True:
blocking.append("asset_refresh")
if is_stale:
blocking.append("stale_status")
ready_for_tools = len(blocking) == 0
state_v2["advice"] = {
"ready_for_tools": ready_for_tools,
"blocking_reasons": blocking,
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
"recommended_next_action": "none" if ready_for_tools else "retry_later",
}
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
return state_v2
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://editor/state", uri="mcpforunity://editor/state",
name="editor_state", name="editor_state",
description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information." description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.",
) )
async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse: async def get_editor_state(ctx: Context) -> MCPResponse:
"""Get current editor runtime state."""
unity_instance = get_unity_instance_from_context(ctx) unity_instance = get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry, async_send_command_with_retry,
unity_instance, unity_instance,
"get_editor_state", "get_editor_state",
{} {},
) )
# When Unity is reloading/unresponsive (often when unfocused), transports may return
# a retryable MCPResponse payload with success=false and no data. Do not attempt to # If Unity returns a structured retry hint or error, surface it directly.
# coerce that into EditorStateResponse (it would fail validation); return it as-is. if isinstance(response, dict) and not response.get("success", True):
if isinstance(response, dict):
if not response.get("success", True):
return MCPResponse(**response) return MCPResponse(**response)
if response.get("data") is None:
return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response) state_v2 = response.get("data") if isinstance(
return EditorStateResponse(**response) response, dict) and isinstance(response.get("data"), dict) else {}
return response state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
state_v2.setdefault("sequence", 0)
# Ensure the returned snapshot is clearly associated with the targeted instance.
unity_section = state_v2.get("unity")
if not isinstance(unity_section, dict):
unity_section = {}
state_v2["unity"] = unity_section
current_instance_id = unity_section.get("instance_id")
if current_instance_id in (None, ""):
if unity_instance:
unity_section["instance_id"] = unity_instance
else:
inferred = await infer_single_instance_id(ctx)
if inferred:
unity_section["instance_id"] = inferred
# External change detection (server-side): compute per instance based on project root path.
try:
instance_id = unity_section.get("instance_id")
if isinstance(instance_id, str) and instance_id.strip():
from services.resources.project_info import get_project_info
proj_resp = await get_project_info(ctx)
proj = proj_resp.model_dump() if hasattr(
proj_resp, "model_dump") else proj_resp
proj_data = proj.get("data") if isinstance(proj, dict) else None
project_root = proj_data.get("projectRoot") if isinstance(
proj_data, dict) else None
if isinstance(project_root, str) and project_root.strip():
external_changes_scanner.set_project_root(
instance_id, project_root)
ext = external_changes_scanner.update_and_get(instance_id)
assets = state_v2.get("assets")
if not isinstance(assets, dict):
assets = {}
state_v2["assets"] = assets
assets["external_changes_dirty"] = bool(
ext.get("external_changes_dirty", False))
assets["external_changes_last_seen_unix_ms"] = ext.get(
"external_changes_last_seen_unix_ms")
assets["external_changes_dirty_since_unix_ms"] = ext.get(
"dirty_since_unix_ms")
assets["external_changes_last_cleared_unix_ms"] = ext.get(
"last_cleared_unix_ms")
except Exception:
pass
state_v2 = _enrich_advice_and_staleness(state_v2)
try:
if hasattr(EditorStateData, "model_validate"):
validated = EditorStateData.model_validate(state_v2)
else:
validated = EditorStateData.parse_obj(
state_v2) # type: ignore[attr-defined]
data = validated.model_dump() if hasattr(
validated, "model_dump") else validated.dict()
except Exception as e:
return MCPResponse(
success=False,
error="invalid_editor_state",
message=f"Editor state payload failed validation: {e}",
data={"raw": state_v2},
)
return MCPResponse(success=True, message="Retrieved editor state.", data=data)

View File

@ -1,270 +0,0 @@
import time
import os
from typing import Any
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
from services.state.external_changes_scanner import external_changes_scanner
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Avoid instance-discovery side effects during the Python integration test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
async def _infer_single_instance_id(ctx: Context) -> str | None:
"""
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
This makes editor_state outputs self-describing even when no explicit active instance is set.
"""
if _in_pytest():
return None
try:
transport = unity_transport._current_transport()
except Exception:
transport = None
if transport == "http":
# HTTP/WebSocket transport: derive from PluginHub sessions.
try:
from transport.plugin_hub import PluginHub
sessions_data = await PluginHub.get_sessions()
sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {}
if isinstance(sessions, dict) and len(sessions) == 1:
session = next(iter(sessions.values()))
project = getattr(session, "project", None)
project_hash = getattr(session, "hash", None)
if project and project_hash:
return f"{project}@{project_hash}"
except Exception:
return None
return None
# Stdio/TCP transport: derive from connection pool discovery.
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if isinstance(instances, list) and len(instances) == 1:
inst = instances[0]
inst_id = getattr(inst, "id", None)
return str(inst_id) if inst_id else None
except Exception:
return None
return None
def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]:
"""
Best-effort mapping from legacy get_editor_state payload into the v2 contract.
Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...}
"""
now_ms = _now_unix_ms()
# legacy may arrive already wrapped as MCPResponse-like {success,data:{...}}
state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {}
return {
"schema_version": "unity-mcp/editor_state@2",
"observed_at_unix_ms": now_ms,
"sequence": 0,
"unity": {
"instance_id": None,
"unity_version": None,
"project_id": None,
"platform": None,
"is_batch_mode": None,
},
"editor": {
"is_focused": None,
"play_mode": {
"is_playing": bool(state.get("isPlaying", False)),
"is_paused": bool(state.get("isPaused", False)),
"is_changing": None,
},
"active_scene": {
"path": None,
"guid": None,
"name": state.get("activeSceneName", "") or "",
},
"selection": {
"count": int(state.get("selectionCount", 0) or 0),
"active_object_name": state.get("activeObjectName", None),
},
},
"activity": {
"phase": "unknown",
"since_unix_ms": now_ms,
"reasons": ["legacy_fallback"],
},
"compilation": {
"is_compiling": bool(state.get("isCompiling", False)),
"is_domain_reload_pending": None,
"last_compile_started_unix_ms": None,
"last_compile_finished_unix_ms": None,
},
"assets": {
"is_updating": bool(state.get("isUpdating", False)),
"external_changes_dirty": False,
"external_changes_last_seen_unix_ms": None,
"refresh": {
"is_refresh_in_progress": False,
"last_refresh_requested_unix_ms": None,
"last_refresh_finished_unix_ms": None,
},
},
"tests": {
"is_running": False,
"mode": None,
"started_unix_ms": None,
"started_by": "unknown",
"last_run": None,
},
"transport": {
"unity_bridge_connected": None,
"last_message_unix_ms": None,
},
}
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
now_ms = _now_unix_ms()
observed = state_v2.get("observed_at_unix_ms")
try:
observed_ms = int(observed)
except Exception:
observed_ms = now_ms
age_ms = max(0, now_ms - observed_ms)
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
is_stale = age_ms > 2000
compilation = state_v2.get("compilation") or {}
tests = state_v2.get("tests") or {}
assets = state_v2.get("assets") or {}
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
blocking: list[str] = []
if compilation.get("is_compiling") is True:
blocking.append("compiling")
if compilation.get("is_domain_reload_pending") is True:
blocking.append("domain_reload")
if tests.get("is_running") is True:
blocking.append("running_tests")
if refresh.get("is_refresh_in_progress") is True:
blocking.append("asset_refresh")
if is_stale:
blocking.append("stale_status")
ready_for_tools = len(blocking) == 0
state_v2["advice"] = {
"ready_for_tools": ready_for_tools,
"blocking_reasons": blocking,
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
"recommended_next_action": "none" if ready_for_tools else "retry_later",
}
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
return state_v2
@mcp_for_unity_resource(
uri="unity://editor_state",
name="editor_state_v2",
description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.",
)
async def get_editor_state_v2(ctx: Context) -> MCPResponse:
unity_instance = get_unity_instance_from_context(ctx)
# Try v2 snapshot first (Unity-side cache will make this fast once implemented).
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state_v2",
{},
)
# If Unity returns a structured retry hint or error, surface it directly.
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
# If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map.
if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")):
legacy = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state",
{},
)
if isinstance(legacy, dict) and not legacy.get("success", True):
return MCPResponse(**legacy)
state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {})
else:
state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {}
# Ensure required v2 marker exists even if Unity returns partial.
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
state_v2.setdefault("sequence", 0)
# Ensure the returned snapshot is clearly associated with the targeted instance.
# (This matters when multiple Unity instances are connected and the client is polling readiness.)
unity_section = state_v2.get("unity")
if not isinstance(unity_section, dict):
unity_section = {}
state_v2["unity"] = unity_section
current_instance_id = unity_section.get("instance_id")
if current_instance_id in (None, ""):
if unity_instance:
unity_section["instance_id"] = unity_instance
else:
inferred = await _infer_single_instance_id(ctx)
if inferred:
unity_section["instance_id"] = inferred
# External change detection (server-side): compute per instance based on project root path.
# This helps detect stale assets when external tools edit the filesystem.
try:
instance_id = unity_section.get("instance_id")
if isinstance(instance_id, str) and instance_id.strip():
from services.resources.project_info import get_project_info
# Cache the project root for this instance (best-effort).
proj_resp = await get_project_info(ctx)
proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp
proj_data = proj.get("data") if isinstance(proj, dict) else None
project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None
if isinstance(project_root, str) and project_root.strip():
external_changes_scanner.set_project_root(instance_id, project_root)
ext = external_changes_scanner.update_and_get(instance_id)
assets = state_v2.get("assets")
if not isinstance(assets, dict):
assets = {}
state_v2["assets"] = assets
# IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative
# for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner.
assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False))
assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms")
# Extra bookkeeping fields (server-only) are safe to add under assets.
assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms")
assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms")
except Exception:
# Best-effort; do not fail readiness resource if filesystem scan can't run.
pass
state_v2 = _enrich_advice_and_staleness(state_v2)
return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2)

View File

@ -2,9 +2,9 @@
MCP Resources for reading GameObject data from Unity scenes. MCP Resources for reading GameObject data from Unity scenes.
These resources provide read-only access to: These resources provide read-only access to:
- Single GameObject data (unity://scene/gameobject/{id}) - Single GameObject data (mcpforunity://scene/gameobject/{id})
- All components on a GameObject (unity://scene/gameobject/{id}/components) - All components on a GameObject (mcpforunity://scene/gameobject/{id}/components)
- Single component on a GameObject (unity://scene/gameobject/{id}/component/{name}) - Single component on a GameObject (mcpforunity://scene/gameobject/{id}/component/{name})
""" """
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@ -40,7 +40,7 @@ def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | N
# ============================================================================= # =============================================================================
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://scene/gameobject-api", uri="mcpforunity://scene/gameobject-api",
name="gameobject_api", name="gameobject_api",
description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below." description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below."
) )
@ -63,23 +63,23 @@ async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse:
"Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands" "Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands"
], ],
"resources": { "resources": {
"unity://scene/gameobject/{instance_id}": { "mcpforunity://scene/gameobject/{instance_id}": {
"description": "Get basic GameObject data (name, tag, layer, transform, component type list)", "description": "Get basic GameObject data (name, tag, layer, transform, component type list)",
"example": "unity://scene/gameobject/-81840", "example": "mcpforunity://scene/gameobject/-81840",
"returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"] "returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"]
}, },
"unity://scene/gameobject/{instance_id}/components": { "mcpforunity://scene/gameobject/{instance_id}/components": {
"description": "Get all components with full property serialization (paginated)", "description": "Get all components with full property serialization (paginated)",
"example": "unity://scene/gameobject/-81840/components", "example": "mcpforunity://scene/gameobject/-81840/components",
"parameters": { "parameters": {
"page_size": "Number of components per page (default: 25)", "page_size": "Number of components per page (default: 25)",
"cursor": "Pagination offset (default: 0)", "cursor": "Pagination offset (default: 0)",
"include_properties": "Include full property data (default: true)" "include_properties": "Include full property data (default: true)"
} }
}, },
"unity://scene/gameobject/{instance_id}/component/{component_name}": { "mcpforunity://scene/gameobject/{instance_id}/component/{component_name}": {
"description": "Get a single component by type name with full properties", "description": "Get a single component by type name with full properties",
"example": "unity://scene/gameobject/-81840/component/Camera", "example": "mcpforunity://scene/gameobject/-81840/component/Camera",
"note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')" "note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')"
} }
}, },
@ -127,7 +127,7 @@ class GameObjectResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}", uri="mcpforunity://scene/gameobject/{instance_id}",
name="gameobject", name="gameobject",
description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties)." description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties)."
) )
@ -168,7 +168,7 @@ class ComponentsResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}/components", uri="mcpforunity://scene/gameobject/{instance_id}/components",
name="gameobject_components", name="gameobject_components",
description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters." description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters."
) )
@ -214,7 +214,7 @@ class SingleComponentResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}/component/{component_name}", uri="mcpforunity://scene/gameobject/{instance_id}/component/{component_name}",
name="gameobject_component", name="gameobject_component",
description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties." description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties."
) )
@ -241,4 +241,3 @@ async def get_gameobject_component(
) )
return _normalize_response(response) return _normalize_response(response)

View File

@ -13,7 +13,7 @@ class LayersResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://project/layers", uri="mcpforunity://project/layers",
name="project_layers", name="project_layers",
description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools." description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools."
) )

View File

@ -23,7 +23,7 @@ class PrefabStageResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://editor/prefab-stage", uri="mcpforunity://editor/prefab-stage",
name="editor_prefab_stage", name="editor_prefab_stage",
description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited." description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited."
) )

View File

@ -23,7 +23,7 @@ class ProjectInfoResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://project/info", uri="mcpforunity://project/info",
name="project_info", name="project_info",
description="Static project information including root path, Unity version, and platform. This data rarely changes." description="Static project information including root path, Unity version, and platform. This data rarely changes."
) )

View File

@ -39,7 +39,7 @@ class SelectionResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://editor/selection", uri="mcpforunity://editor/selection",
name="editor_selection", name="editor_selection",
description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties." description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties."
) )

View File

@ -14,7 +14,7 @@ class TagsResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://project/tags", uri="mcpforunity://project/tags",
name="project_tags", name="project_tags",
description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools." description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools."
) )

View File

@ -11,7 +11,7 @@ from transport.unity_transport import _current_transport
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://instances", uri="mcpforunity://instances",
name="unity_instances", name="unity_instances",
description="Lists all running Unity Editor instances with their details." description="Lists all running Unity Editor instances with their details."
) )

View File

@ -31,7 +31,7 @@ class WindowsResponse(MCPResponse):
@mcp_for_unity_resource( @mcp_for_unity_resource(
uri="unity://editor/windows", uri="mcpforunity://editor/windows",
name="editor_windows", name="editor_windows",
description="All currently open editor windows with their titles, types, positions, and focus state." description="All currently open editor windows with their titles, types, positions, and focus state."
) )

View File

@ -116,7 +116,8 @@ class ExternalChangesScanner:
st.manifest_last_mtime_ns = None st.manifest_last_mtime_ns = None
return [] return []
mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) mtime_ns = getattr(stat, "st_mtime_ns", int(
stat.st_mtime * 1_000_000_000))
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns: if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
return [Path(p) for p in st.extra_roots if p] return [Path(p) for p in st.extra_roots if p]
@ -143,7 +144,7 @@ class ExternalChangesScanner:
v = ver.strip() v = ver.strip()
if not v.startswith("file:"): if not v.startswith("file:"):
continue continue
suffix = v[len("file:") :].strip() suffix = v[len("file:"):].strip()
# Handle file:///abs/path or file:/abs/path # Handle file:///abs/path or file:/abs/path
if suffix.startswith("///"): if suffix.startswith("///"):
candidate = Path("/" + suffix.lstrip("/")) candidate = Path("/" + suffix.lstrip("/"))
@ -242,5 +243,3 @@ class ExternalChangesScanner:
# Global singleton (simple, process-local) # Global singleton (simple, process-local)
external_changes_scanner = ExternalChangesScanner() external_changes_scanner = ExternalChangesScanner()

Some files were not shown because too many files have changed in this diff Show More