🎮 GameObject Toolset Redesign and Streamlining (#518)

* feat: Redesign GameObject API for better LLM ergonomics

## New Tools
- find_gameobjects: Search GameObjects, returns paginated instance IDs only
- manage_components: Component lifecycle (add, remove, set_property)

## New Resources
- unity://scene/gameobject/{id}: Single GameObject data (no component serialization)
- unity://scene/gameobject/{id}/components: All components (paginated)
- unity://scene/gameobject/{id}/component/{name}: Single component by type

## Updated
- manage_scene get_hierarchy: Now includes componentTypes array
- manage_gameobject: Slimmed to lifecycle only (create, modify, delete)
  - Legacy actions (find, get_components, etc.) log deprecation warnings

## Extracted Utilities
- ParamCoercion: Centralized int/bool/float/string coercion
- VectorParsing: Vector3/Vector2/Quaternion/Color parsing
- GameObjectLookup: Centralized GameObject search logic

## Test Coverage
- 76 new Unity EditMode tests for ManageGameObject actions
- 21 new pytest tests for Python tools/resources
- New NL/T CI suite for GameObject API (GO-0 to GO-5)

Addresses LLM confusion with parameter overload by splitting into
focused tools and read-only resources.

* feat: Add static gameobject_api helper resource for UI discoverability

Adds unity://scene/gameobject-api resource that:
- Shows in Cursor's resource list UI (no parameters needed)
- Documents the parameterized gameobject resources
- Explains the workflow: find_gameobjects → read resource
- Lists examples and related tools

* feat: Add GO tests to main NL/T CI workflow

- Adds GO pass (GO-0 to GO-5) after T pass in claude-nl-suite.yml
- Includes retry logic for incomplete GO tests
- Updates all regex patterns to recognize GO-* test IDs
- Updates DESIRED lists to include all 21 tests (NL-0..4, T-A..J, GO-0..5)
- Updates default_titles for GO tests in markdown summary
- Keeps separate claude-gameobject-suite.yml for standalone runs

* feat: Add GameObject API stress tests and NL/T suite updates

Stress Tests (12 new tests):
- BulkCreate small/medium batches
- FindGameObjects pagination with by_component search
- AddComponents to single object
- GetComponents with full serialization
- SetComponentProperties (complex Rigidbody)
- Deep hierarchy creation and path lookup
- GetHierarchy with large scenes
- Resource read performance tests
- RapidFire create-modify-delete cycles

NL/T Suite Updates:
- Added GO-0..GO-10 tests in nl-gameobject-suite.md
- Fixed tool naming: mcp__unity__ → mcp__UnityMCP__

Other:
- Fixed LongUnityScriptClaudeTest.cs compilation errors
- Added reports/, .claude/local/, scripts/local-test/ to .gitignore

All 254 EditMode tests pass (250 run, 4 explicit skips)

* fix: Address code review feedback

- ParamCoercion: Use CultureInfo.InvariantCulture for float parsing
- ManageComponents: Move Transform removal check before GetComponent
- ManageGameObjectFindTests: Use try-finally for LogAssert.ignoreFailingMessages
- VectorParsing: Document that quaternions are not auto-normalized
- gameobject.py: Prefix unused ctx parameter with underscore

* fix: Address additional code review feedback

- ManageComponents: Reuse GameObjectLookup.FindComponentType instead of duplicate
- ManageComponents: Log warnings when SetPropertiesOnComponent fails
- GameObjectLookup: Make FindComponentType public for reuse
- gameobject.py: Extract _normalize_response helper to reduce duplication
- gameobject.py: Add TODO comment for unused typed response classes

* fix: Address more code review feedback

NL/T Prompt Fixes:
- nl-gameobject-suite.md: Remove non-existent list_resources/read_resource from AllowedTools
- nl-gameobject-suite.md: Fix parameter names (component_type, properties)
- nl-unity-suite-nl.md: Remove unused manage_editor from AllowedTools

Test Fixes:
- GameObjectAPIStressTests: Add null check to ToJObject helper
- GameObjectAPIStressTests: Clarify AudioSource usage comment
- ManageGameObjectFindTests: Use built-in 'UI' layer instead of 'Water'
- LongUnityScriptClaudeTest: Clean up NL/T test artifacts (Counte42 typo, HasTarget)

* docs: Add documentation for API limitations and behaviors

- GameObjectLookup.SearchByPath: Document and warn that includeInactive
  has no effect (Unity API limitation)
- ManageComponents.TrySetProperty: Document case-insensitive lookup behavior

* More test fixes and tighten parameters on python tools

* fix: Align test expectation with implementation error message case

* docs: update README tools and resources lists

- Add missing tools: manage_components, batch_execute, find_gameobjects, refresh_unity
- Add missing resources: gameobject_api, editor_state_v2
- Make descriptions more concise across all tools and resources
- Ensure documentation matches current MCP server functionality

* fix: Address code review feedback

- ParamCoercion: Use InvariantCulture for int/double parsing consistency
- ManageComponents: Remove redundant Undo.RecordObject (AddComponent handles undo)
- ManageScene: Replace deprecated FindObjectsOfType with FindObjectsByType
- GameObjectLookup: Add explanatory comment to empty catch block
- gameobject.py: Extract _validate_instance_id helper to reduce duplication
- Tests: Fix assertion for instanceID (Unity IDs can be negative)

* chore: Remove accidentally committed test artifacts

- Remove Materials folder (40 .mat files from interactive testing)
- Remove Shaders folder (5 noise shaders from testing)
- Remove test scripts (Bounce*, CylinderBounce* from testing)
- Remove Temp.meta and commit.sh

* test: Improve delete tests to verify actual deletion

- Delete_ByTag_DeletesMatchingObjects: Verify objects are actually destroyed
- Delete_ByLayer_DeletesMatchingObjects: Assert deletion using Unity null check
- Delete_MultipleObjectsSameName_DeletesCorrectly: Document first-match behavior
- Delete_Success_ReturnsDeletedCount: Verify count value if present

All tests now verify deletion occurred rather than just checking for a result.

* refactor: remove deprecated manage_gameobject actions

- Remove deprecated switch cases: find, get_components, get_component, add_component, remove_component, set_component_property
- Remove deprecated wrapper methods (423 lines deleted from ManageGameObject.cs)
- Delete ManageGameObjectFindTests.cs (tests deprecated 'find' action)
- Remove deprecated test methods from ManageGameObjectTests.cs
- Add GameObject resource URIs to README documentation
- Add batch_execute performance tips to README, tool description, and gameobject_api resource
- Enhance batch_execute description to emphasize 10-100x performance gains

Total: ~1200 lines removed. New API (find_gameobjects, manage_components, resources) is the recommended path forward.

* fix: Remove starlette stubs from conftest.py

Starlette is now a proper dependency via the mcp package, so we don't need
to stub it anymore. The real package handles all HTTP transport needs.
main
dsarno 2026-01-06 10:13:45 -08:00 committed by GitHub
parent 9d5a817540
commit dbdaa546b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 6015 additions and 871 deletions

View File

@ -0,0 +1,150 @@
# Unity GameObject API Test Suite — Tool/Resource Separation
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
**Print this once, verbatim, early in the run:**
AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobject,mcp__UnityMCP__find_gameobjects,mcp__UnityMCP__manage_components,mcp__UnityMCP__manage_scene,mcp__UnityMCP__read_console
---
## Mission
1) Test the new Tool/Resource separation for GameObject management
2) Execute GO tests GO-0..GO-10 in order
3) Verify deprecation warnings appear for legacy actions
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`
**CRITICAL XML FORMAT REQUIREMENTS:**
- Each file must contain EXACTLY one `<testcase>` root element
- NO prologue, epilogue, code fences, or extra characters
- Use this exact shape:
<testcase name="GO-0 — Hierarchy with ComponentTypes" classname="UnityMCP.GO-T">
<system-out><![CDATA[
(evidence of what was accomplished)
]]></system-out>
</testcase>
- If test fails, include: `<failure message="reason"/>`
- TESTID must be one of: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10
---
## Test Specs
### GO-0. Hierarchy with ComponentTypes
**Goal**: Verify get_hierarchy now includes componentTypes list
**Actions**:
- Call `mcp__UnityMCP__manage_scene(action="get_hierarchy", page_size=10)`
- Verify response includes `componentTypes` array for each item in `data.items`
- Check that Main Camera (or similar) has component types like `["Transform", "Camera", "AudioListener"]`
- **Pass criteria**: componentTypes present and non-empty for at least one item
### GO-1. Find GameObjects Tool
**Goal**: Test the new find_gameobjects tool
**Actions**:
- Call `mcp__UnityMCP__find_gameobjects(search_term="Camera", search_method="by_component")`
- Verify response contains `instanceIDs` array in `data`
- Verify response contains pagination info (`pageSize`, `cursor`, `totalCount`)
- **Pass criteria**: Returns at least one instance ID
### GO-2. GameObject Resource Read
**Goal**: Test reading a single GameObject via resource
**Actions**:
- Use the instance ID from GO-1
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID
- Verify response includes: instanceID, name, tag, layer, transform, path
- **Pass criteria**: All expected fields present
### GO-3. Components Resource Read
**Goal**: Test reading components via resource
**Actions**:
- Use the instance ID from GO-1
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID
- Verify response includes paginated component list in `data.items`
- Verify at least one component has typeName and instanceID
- **Pass criteria**: Components list returned with proper pagination
### GO-4. Manage Components Tool - Add and Set Property
**Goal**: Test the new manage_components tool (add component, set property)
**Actions**:
- Create a test GameObject: `mcp__UnityMCP__manage_gameobject(action="create", name="GO_Test_Object")`
- Add a component: `mcp__UnityMCP__manage_components(action="add", target="GO_Test_Object", component_type="Rigidbody")`
- Set a property: `mcp__UnityMCP__manage_components(action="set_property", target="GO_Test_Object", component_type="Rigidbody", properties={"mass": 5.0})`
- Verify the component was added and property was set
- **Pass criteria**: Component added, property set successfully
- **Note**: Keep GO_Test_Object for GO-5 through GO-8
### GO-5. Find GameObjects by Name
**Goal**: Test find_gameobjects with by_name search method
**Actions**:
- Call `mcp__UnityMCP__find_gameobjects(search_term="GO_Test_Object", search_method="by_name")`
- Verify response contains the GameObject created in GO-4
- Verify pagination info is present
- **Pass criteria**: Returns at least one instance ID matching GO_Test_Object
### GO-6. Find GameObjects by Tag
**Goal**: Test find_gameobjects with by_tag search method
**Actions**:
- Set a tag on GO_Test_Object: `mcp__UnityMCP__manage_gameobject(action="modify", target="GO_Test_Object", tag="TestTag")`
- Call `mcp__UnityMCP__find_gameobjects(search_term="TestTag", search_method="by_tag")`
- Verify response contains the tagged GameObject
- **Pass criteria**: Returns at least one instance ID
### GO-7. Single Component Resource Read
**Goal**: Test reading a single component via resource
**Actions**:
- Get instance ID of GO_Test_Object from GO-5
- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID}
- Verify response includes component data with typeName="Rigidbody"
- Verify mass property is 5.0 (set in GO-4)
- **Pass criteria**: Component data returned with correct properties
### GO-8. Remove Component
**Goal**: Test manage_components remove action
**Actions**:
- Remove the Rigidbody from GO_Test_Object: `mcp__UnityMCP__manage_components(action="remove", target="GO_Test_Object", component_type="Rigidbody")`
- Verify the component was removed by attempting to read it again
- **Pass criteria**: Component successfully removed
### GO-9. Find with Pagination
**Goal**: Test find_gameobjects pagination
**Actions**:
- Call `mcp__UnityMCP__find_gameobjects(search_term="", search_method="by_name", page_size=2)`
- Verify response includes cursor for next page
- If cursor is present, call again with the cursor to get next page
- Clean up: `mcp__UnityMCP__manage_gameobject(action="delete", target="GO_Test_Object")`
- **Pass criteria**: Pagination works (cursor present when more results available)
### GO-10. Deprecation Warnings
**Goal**: Verify legacy actions log deprecation warnings
**Actions**:
- Call legacy action: `mcp__UnityMCP__manage_gameobject(action="find", search_term="Camera", search_method="by_component")`
- Read console using `mcp__UnityMCP__read_console` for deprecation warning
- Verify warning mentions "find_gameobjects" as replacement
- **Pass criteria**: Deprecation warning logged
---
## Tool Reference
### New Tools
- `find_gameobjects(search_term, search_method, page_size?, cursor?, search_inactive?)` - Returns instance IDs only
- `manage_components(action, target, component_type?, properties?)` - Add/remove/set_property/get_all/get_single
### New Resources
- `unity://scene/gameobject/{instanceID}` - Single GameObject data
- `unity://scene/gameobject/{instanceID}/components` - All components (paginated)
- `unity://scene/gameobject/{instanceID}/component/{componentName}` - Single component
### Updated Resources
- `manage_scene(action="get_hierarchy")` - Now includes `componentTypes` array in each item
---
## Transcript Minimization Rules
- Do not restate tool JSON; summarize in ≤ 2 short lines
- Per-test `system-out` ≤ 400 chars
- Console evidence: include ≤ 3 lines in the fragment
---

View File

@ -3,7 +3,7 @@
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
**Print this once, verbatim, early in the run:**
AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha
AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_edits,mcp__UnityMCP__validate_script,mcp__UnityMCP__find_in_file,mcp__UnityMCP__read_console,mcp__UnityMCP__get_sha
---
@ -11,7 +11,7 @@ AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__un
1) Pick target file (prefer):
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
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__unity__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`.
**CRITICAL XML FORMAT REQUIREMENTS:**
@ -50,7 +50,7 @@ CI provides:
## Transcript Minimization Rules
- Do not restate tool JSON; summarize in ≤ 2 short lines.
- Never paste full file contents. For matches, include only the matched line and ±1 line.
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
- Prefer `mcp__UnityMCP__find_in_file` for targeting to minimize transcript size.
- Pertest `system-out` ≤ 400 chars: brief status only (no SHA).
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
- Avoid quoting multiline diffs; reference markers instead.
@ -59,17 +59,17 @@ CI provides:
---
## Tool Mapping
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
- **Anchors/regex/structured**: `mcp__UnityMCP__script_apply_edits`
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (nonoverlapping ranges)
- **Precise ranges / atomic batch**: `mcp__UnityMCP__apply_text_edits` (nonoverlapping ranges)
STRICT OP GUARDRAILS
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
- For multispot textual tweaks in one operation, compute nonoverlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
- For multispot textual tweaks in one operation, compute nonoverlapping ranges with `mcp__UnityMCP__find_in_file` and use `mcp__UnityMCP__apply_text_edits`.
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
- **Validation**: `mcp__unity__validate_script(level:"standard")`
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
- **Hash-only**: `mcp__UnityMCP__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
- **Validation**: `mcp__UnityMCP__validate_script(level:"standard")`
- **Dynamic targeting**: Use `mcp__UnityMCP__find_in_file` to locate current positions of methods/markers
---
@ -83,7 +83,7 @@ STRICT OP GUARDRAILS
5. **Composability**: Tests demonstrate how operations work together in real workflows
**State Tracking:**
- Track file SHA after each test (`mcp__unity__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments.
- Track file SHA after each test (`mcp__UnityMCP__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments.
- Use content signatures (method names, comment markers) to verify expected state
- Validate structural integrity after each major change

View File

@ -2,7 +2,7 @@
You are running inside CI for the `unity-mcp` repo. Use only the tools allowed by the workflow. Work autonomously; do not prompt the user. Do NOT spawn subagents.
**Print this once, verbatim, early in the run:**
AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__unity__read_resource,mcp__unity__apply_text_edits,mcp__unity__script_apply_edits,mcp__unity__validate_script,mcp__unity__find_in_file,mcp__unity__read_console,mcp__unity__get_sha
AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,mcp__UnityMCP__read_resource,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_edits,mcp__UnityMCP__validate_script,mcp__UnityMCP__find_in_file,mcp__UnityMCP__read_console,mcp__UnityMCP__get_sha
---
@ -10,7 +10,7 @@ AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__un
1) Pick target file (prefer):
- `unity://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.
3) Validate each edit with `mcp__unity__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`.
**CRITICAL XML FORMAT REQUIREMENTS:**
@ -49,7 +49,7 @@ CI provides:
## Transcript Minimization Rules
- Do not restate tool JSON; summarize in ≤ 2 short lines.
- Never paste full file contents. For matches, include only the matched line and ±1 line.
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
- Prefer `mcp__UnityMCP__find_in_file` for targeting; avoid `mcp__UnityMCP__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
- Pertest `system-out` ≤ 400 chars: brief status only (no SHA).
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
- Avoid quoting multiline diffs; reference markers instead.
@ -59,17 +59,17 @@ CI provides:
---
## Tool Mapping
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
- **Anchors/regex/structured**: `mcp__UnityMCP__script_apply_edits`
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (nonoverlapping ranges)
- **Precise ranges / atomic batch**: `mcp__UnityMCP__apply_text_edits` (nonoverlapping ranges)
STRICT OP GUARDRAILS
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
- For multispot textual tweaks in one operation, compute nonoverlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
- For multispot textual tweaks in one operation, compute nonoverlapping ranges with `mcp__UnityMCP__find_in_file` and use `mcp__UnityMCP__apply_text_edits`.
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
- **Validation**: `mcp__unity__validate_script(level:"standard")`
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
- **Hash-only**: `mcp__UnityMCP__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
- **Validation**: `mcp__UnityMCP__validate_script(level:"standard")`
- **Dynamic targeting**: Use `mcp__UnityMCP__find_in_file` to locate current positions of methods/markers
---
@ -83,7 +83,7 @@ STRICT OP GUARDRAILS
5. **Composability**: Tests demonstrate how operations work together in real workflows
**State Tracking:**
- Track file SHA after each test (`mcp__unity__get_sha`) and use it as a precondition
- Track file SHA after each test (`mcp__UnityMCP__get_sha`) and use it as a precondition
for `apply_text_edits` in TF/TG/TI to exercise `stale_file` semantics. Do not include SHA values in report fragments.
- Use content signatures (method names, comment markers) to verify expected state
- Validate structural integrity after each major change
@ -100,14 +100,14 @@ STRICT OP GUARDRAILS
- **Expected final state**: Return to State C (helper removed, other changes intact)
### Late-Test Editing Rule
- When modifying a method body, use `mcp__unity__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__unity__validate_script` and rollback on error. Use `//` comments in inserted code.
- When modifying a method body, use `mcp__UnityMCP__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__UnityMCP__validate_script` and rollback on error. Use `//` comments in inserted code.
### T-B. Method Body Interior Edit (Additive State D)
**Goal**: Edit method interior without affecting structure, on modified file
**Actions**:
- Use `find_in_file` to locate current `HasTarget()` method (modified in NL-1)
- Edit method body interior: change return statement to `return true; /* test modification */`
- Validate with `mcp__unity__validate_script(level:"standard")` for consistency
- Validate with `mcp__UnityMCP__validate_script(level:"standard")` for consistency
- Verify edit succeeded and file remains balanced
- **Expected final state**: State C + modified HasTarget() body
@ -124,7 +124,7 @@ STRICT OP GUARDRAILS
**Actions**:
- Use smart anchor matching to find current class-ending brace (after NL-3 tail comments)
- Insert permanent helper before class brace: `private void TestHelper() { /* placeholder */ }`
- Validate with `mcp__unity__validate_script(level:"standard")`
- Validate with `mcp__UnityMCP__validate_script(level:"standard")`
- **IMMEDIATELY** write clean XML fragment to `reports/T-D_results.xml` (no extra text). The `<testcase name>` must start with `T-D`. Include brief evidence in `system-out`.
- **Expected final state**: State E + TestHelper() method before class end
@ -178,12 +178,12 @@ STRICT OP GUARDRAILS
### T-J. Idempotency on Modified File (Additive State I)
**Goal**: Verify operations behave predictably when repeated
**Actions**:
- **Insert (structured)**: `mcp__unity__script_apply_edits` with:
- **Insert (structured)**: `mcp__UnityMCP__script_apply_edits` with:
`{"op":"anchor_insert","anchor":"// Tail test C","position":"after","text":"\n // idempotency test marker"}`
- **Insert again** (same op) → expect `no_op: true`.
- **Remove (structured)**: `{"op":"regex_replace","pattern":"(?m)^\\s*// idempotency test marker\\r?\\n?","text":""}`
- **Remove again** (same `regex_replace`) → expect `no_op: true`.
- `mcp__unity__validate_script(level:"standard")`
- `mcp__UnityMCP__validate_script(level:"standard")`
- Perform a final console scan for errors/exceptions (errors only, up to 3); include "no errors" if none
- **IMMEDIATELY** write clean XML fragment to `reports/T-J_results.xml` with evidence of both `no_op: true` outcomes and the console result. The `<testcase name>` must start with `T-J`.
- **Expected final state**: State H + verified idempotent behavior

View File

@ -0,0 +1,637 @@
name: Claude GameObject API Tests (Unity live)
on: [workflow_dispatch]
permissions:
contents: read
checks: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f2-linux-il2cpp-3
jobs:
go-suite:
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
JUNIT_OUT: reports/junit-go-suite.xml
MD_OUT: reports/junit-go-suite.md
steps:
# ---------- Secrets check ----------
- name: Detect secrets (outputs)
id: detect
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -e
if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
else
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ---------- Python env for MCP server (uv) ----------
- uses: astral-sh/setup-uv@v4
with:
python-version: "3.11"
- name: Install MCP server
run: |
set -eux
uv venv
echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV"
echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH"
if [ -f Server/pyproject.toml ]; then
uv pip install -e Server
elif [ -f Server/requirements.txt ]; then
uv pip install -r Server/requirements.txt
else
echo "No MCP Python deps found (skipping)"
fi
# --- Licensing ---
- name: Decide license sources
id: lic
shell: bash
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -eu
use_ulf=false; use_ebl=false
[[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
[[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
- name: Stage Unity .ulf license (from secret)
if: steps.lic.outputs.use_ulf == 'true'
id: ulf
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
shell: bash
run: |
set -eu
mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
else
printf "%s" "$UNITY_LICENSE" > "$f"
fi
chmod 600 "$f" || true
if head -c 100 "$f" | grep -qi '<\?xml'; then
mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses"
mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml"
echo "ok=false" >> "$GITHUB_OUTPUT"
elif grep -qi '<Signature>' "$f"; then
cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
fi
- name: Activate Unity (EBL via container - host-mount)
if: steps.lic.outputs.use_ebl == 'true'
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
docker run --rm --network host \
-e HOME=/root \
-e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
"$UNITY_IMAGE" bash -lc '
set -euxo pipefail
if [[ -n "${UNITY_SERIAL:-}" ]]; then
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
else
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true
fi
ls -la /root/.config/unity3d/Unity/licenses || true
'
if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then
if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then
echo "EBL entitlement not found; proceeding with ULF-only (ok=true)."
else
echo "No entitlement produced and no valid ULF; cannot continue." >&2
exit 1
fi
fi
# ---------- Warm up project ----------
- name: Warm up project (import Library once)
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
manual_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
docker run --rm --network host \
-e HOME=/root \
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
"${manual_args[@]}" \
-quit
# ---------- Clean old MCP status ----------
- name: Clean old MCP status
run: |
set -eux
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
rm -f "$GITHUB_WORKSPACE/.unity-mcp"/unity-mcp-status-*.json || true
# ---------- Start headless Unity ----------
- name: Start Unity (persistent bridge)
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
shell: bash
env:
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
ULF_OK: ${{ steps.ulf.outputs.ok }}
run: |
set -euxo pipefail
manual_args=()
if [[ "${ULF_OK:-false}" == "true" ]]; then
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
fi
mkdir -p "$GITHUB_WORKSPACE/.unity-mcp"
docker rm -f unity-mcp >/dev/null 2>&1 || true
docker run -d --name unity-mcp --network host \
-e HOME=/root \
-e UNITY_MCP_ALLOW_BATCH=1 \
-e UNITY_MCP_STATUS_DIR="${{ github.workspace }}/.unity-mcp" \
-e UNITY_MCP_BIND_HOST=127.0.0.1 \
-v "${{ github.workspace }}:${{ github.workspace }}" -w "${{ github.workspace }}" \
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
-v "$RUNNER_TEMP/unity-cache:/root/.cache/unity3d" \
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile /root/.config/unity3d/Editor.log \
-stackTraceLogType Full \
-projectPath "${{ github.workspace }}/TestProjects/UnityMCPTests" \
"${manual_args[@]}" \
-executeMethod MCPForUnity.Editor.McpCiBoot.StartStdioForCi
# ---------- Wait for Unity bridge ----------
- name: Wait for Unity bridge (robust)
if: steps.detect.outputs.anthropic_ok == 'true' && (steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true')
shell: bash
run: |
set -euo pipefail
deadline=$((SECONDS+600))
fatal_after=$((SECONDS+120))
ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)'
license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)'
while [ $SECONDS -lt $deadline ]; do
logs="$(docker logs unity-mcp 2>&1 || true)"
port="$(jq -r '.unity_port // empty' "$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)"
if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then
echo "Bridge ready on port $port"
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
exit 0
fi
if echo "$logs" | grep -qiE "$ok_pat"; then
echo "Bridge ready (log markers)"
docker exec unity-mcp chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || chmod -R a+rwx "$GITHUB_WORKSPACE/.unity-mcp" || true
exit 0
fi
if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then
echo "::error::Fatal licensing signal detected after warm-up"
echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
exit 1
fi
st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)"
if [[ "$st" != "running" ]]; then
echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
exit 1
fi
sleep 2
done
echo "::error::Bridge not ready before deadline"
docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
exit 1
- name: Pin Claude tool permissions
run: |
set -eux
mkdir -p .claude
cat > .claude/settings.json <<'JSON'
{
"permissions": {
"allow": [
"mcp__unity",
"Edit(reports/**)",
"MultiEdit(reports/**)"
],
"deny": [
"Bash",
"WebFetch",
"WebSearch",
"Task",
"TodoWrite",
"NotebookEdit",
"NotebookRead"
]
}
}
JSON
- name: Prepare reports
run: |
set -eux
rm -f reports/*.xml reports/*.md || true
mkdir -p reports
- name: Create report skeletons
run: |
set -eu
cat > "$JUNIT_OUT" <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<testsuites><testsuite name="UnityMCP.GO-T" tests="1" failures="1" errors="0" skipped="0" time="0">
<testcase name="GO-Suite.Bootstrap" classname="UnityMCP.GO-T">
<failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure>
</testcase>
</testsuite></testsuites>
XML
printf '# Unity GameObject API Test Results\n\n' > "$MD_OUT"
- name: Verify Unity bridge status
run: |
set -euxo pipefail
shopt -s nullglob
status_files=("$GITHUB_WORKSPACE"/.unity-mcp/unity-mcp-status-*.json)
if ((${#status_files[@]})); then
first_status="${status_files[0]}"
fname="$(basename "$first_status")"
hash_part="${fname%.json}"; hash_part="${hash_part#unity-mcp-status-}"
proj="$(jq -r '.project_name // empty' "$first_status" || true)"
if [[ -n "${proj:-}" && -n "${hash_part:-}" ]]; then
echo "UNITY_MCP_DEFAULT_INSTANCE=${proj}@${hash_part}" >> "$GITHUB_ENV"
echo "Default instance set to ${proj}@${hash_part}"
fi
fi
- name: Write MCP config
run: |
set -eux
mkdir -p .claude
python3 - <<'PY'
import json
import os
import textwrap
from pathlib import Path
workspace = os.environ["GITHUB_WORKSPACE"]
default_inst = os.environ.get("UNITY_MCP_DEFAULT_INSTANCE", "").strip()
cfg = {
"mcpServers": {
"unity": {
"args": [
"run",
"--active",
"--directory",
"Server",
"mcp-for-unity",
"--transport",
"stdio",
],
"transport": {"type": "stdio"},
"env": {
"PYTHONUNBUFFERED": "1",
"MCP_LOG_LEVEL": "debug",
"UNITY_PROJECT_ROOT": f"{workspace}/TestProjects/UnityMCPTests",
"UNITY_MCP_STATUS_DIR": f"{workspace}/.unity-mcp",
"UNITY_MCP_HOST": "127.0.0.1",
},
}
}
}
unity = cfg["mcpServers"]["unity"]
if default_inst:
unity["env"]["UNITY_MCP_DEFAULT_INSTANCE"] = default_inst
if "--default-instance" not in unity["args"]:
unity["args"] += ["--default-instance", default_inst]
runner_script = Path(".claude/run-unity-mcp.sh")
workspace_path = Path(workspace)
uv_candidate = workspace_path / ".venv" / "bin" / "uv"
uv_cmd = uv_candidate.as_posix() if uv_candidate.exists() else "uv"
script = textwrap.dedent(f"""\
#!/usr/bin/env bash
set -euo pipefail
LOG="{workspace}/.unity-mcp/mcp-server-startup-debug.log"
mkdir -p "$(dirname "$LOG")"
echo "" >> "$LOG"
echo "[ $(date -Iseconds) ] Starting unity MCP server" >> "$LOG"
exec {uv_cmd} "$@" 2>> "$LOG"
""")
runner_script.write_text(script)
runner_script.chmod(0o755)
unity["command"] = runner_script.resolve().as_posix()
path = Path(".claude/mcp.json")
path.write_text(json.dumps(cfg, indent=2) + "\n")
print(f"Wrote {path} and {runner_script}")
PY
# ---------- Run Claude GO pass ----------
- name: Run Claude GO pass
uses: anthropics/claude-code-base-action@beta
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
continue-on-error: true
env:
UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }}
with:
use_node_cache: false
prompt_file: .claude/prompts/nl-gameobject-suite.md
mcp_config: .claude/mcp.json
settings: .claude/settings.json
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
model: claude-haiku-4-5-20251001
fallback_model: claude-sonnet-4-5-20250929
append_system_prompt: |
You are running the GameObject API tests.
- Emit exactly GO-0, GO-1, GO-2, GO-3, GO-4, GO-5.
- Write each to reports/${ID}_results.xml.
- Stop after GO-5_results.xml is written.
timeout_minutes: "25"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# ---------- Backfill missing tests ----------
- name: Backfill missing GO tests
if: always()
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
import xml.etree.ElementTree as ET
import re
DESIRED = ["GO-0","GO-1","GO-2","GO-3","GO-4","GO-5"]
seen = set()
def id_from_filename(p: Path):
n = p.name
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
if m:
return f"GO-{int(m.group(1))}"
return None
for p in Path("reports").glob("*_results.xml"):
fid = id_from_filename(p)
if fid in DESIRED:
seen.add(fid)
Path("reports").mkdir(parents=True, exist_ok=True)
for d in DESIRED:
if d in seen:
continue
frag = Path(f"reports/{d}_results.xml")
tc = ET.Element("testcase", {"classname":"UnityMCP.GO-T", "name": d})
fail = ET.SubElement(tc, "failure", {"message":"not produced"})
fail.text = "The agent did not emit a fragment for this test."
ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False)
print(f"backfill: {d}")
PY
# ---------- Merge fragments into JUnit ----------
- name: Assemble JUnit
if: always()
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
import xml.etree.ElementTree as ET
import re, os
def localname(tag: str) -> str:
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
if not src.exists():
raise SystemExit(0)
tree = ET.parse(src)
root = tree.getroot()
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
if suite is None:
raise SystemExit(0)
def id_from_filename(p: Path):
n = p.name
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
if m:
return f"GO-{int(m.group(1))}"
return None
fragments = sorted(Path('reports').glob('GO-*_results.xml'))
added = 0
for frag in fragments:
try:
froot = ET.parse(frag).getroot()
if localname(froot.tag) == 'testcase':
suite.append(froot)
added += 1
except Exception:
pass
if added:
for tc in list(suite.findall('.//testcase')):
if (tc.get('name') or '') == 'GO-Suite.Bootstrap':
suite.remove(tc)
testcases = suite.findall('.//testcase')
failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None))
suite.set('tests', str(len(testcases)))
suite.set('failures', str(failures_cnt))
suite.set('errors', '0')
suite.set('skipped', '0')
tree.write(src, encoding='utf-8', xml_declaration=True)
print(f"Appended {added} testcase(s).")
PY
# ---------- Build markdown summary ----------
- name: Build markdown summary
if: always()
shell: bash
run: |
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path
import os, html, re
def localname(tag: str) -> str:
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-go-suite.xml'))
md_out = Path(os.environ.get('MD_OUT', 'reports/junit-go-suite.md'))
md_out.parent.mkdir(parents=True, exist_ok=True)
if not src.exists():
md_out.write_text("# Unity GameObject API Test Results\n\n(No JUnit found)\n", encoding='utf-8')
raise SystemExit(0)
tree = ET.parse(src)
root = tree.getroot()
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
cases = [] if suite is None else list(suite.findall('.//testcase'))
desired = ['GO-0','GO-1','GO-2','GO-3','GO-4','GO-5']
default_titles = {
'GO-0': 'Hierarchy with ComponentTypes',
'GO-1': 'Find GameObjects Tool',
'GO-2': 'GameObject Resource Read',
'GO-3': 'Components Resource Read',
'GO-4': 'Manage Components Tool',
'GO-5': 'Deprecation Warnings',
}
def id_from_case(tc):
n = (tc.get('name') or '')
m = re.match(r'\s*(GO-\d+)\b', n)
if m:
return m.group(1)
return None
id_status = {}
for tc in cases:
tid = id_from_case(tc)
if not tid or tid not in desired or tid in id_status:
continue
ok = (tc.find('failure') is None and tc.find('error') is None)
id_status[tid] = ok
total = len(cases)
failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None))
passed = total - failures
lines = [
'# Unity GameObject API Test Results',
'',
f'Totals: {passed} passed, {failures} failed, {total} total',
'',
'## Test Checklist'
]
for p in desired:
st = id_status.get(p, None)
label = f"{p} — {default_titles.get(p, '')}"
lines.append(f"- [x] {label}" if st is True else (f"- [ ] {label} (fail)" if st is False else f"- [ ] {label} (not run)"))
lines.append('')
lines.append('## Test Details')
for tc in cases:
tid = id_from_case(tc)
if not tid:
continue
title = tc.get('name') or tid
ok = (tc.find('failure') is None and tc.find('error') is None)
badge = "PASS" if ok else "FAIL"
lines.append(f"### {title} — {badge}")
so = tc.find('system-out')
text = '' if so is None or so.text is None else html.unescape(so.text.strip())
if text:
lines += ['```', text[:2000], '```']
else:
lines.append('(no system-out)')
node = tc.find('failure') or tc.find('error')
if node is not None:
msg = (node.get('message') or '').strip()
if msg:
lines.append(f"- Message: {msg}")
lines.append('')
md_out.write_text('\n'.join(lines), encoding='utf-8')
PY
- name: GO details -> Job Summary
if: always()
run: |
echo "## Unity GameObject API Tests — Summary" >> $GITHUB_STEP_SUMMARY
python3 - <<'PY' >> $GITHUB_STEP_SUMMARY
from pathlib import Path
p = Path('reports/junit-go-suite.md')
if p.exists():
text = p.read_bytes().decode('utf-8', 'replace')
print(text[:65000])
else:
print("_No markdown report found._")
PY
- name: Publish JUnit report
if: always()
uses: mikepenz/action-junit-report@v5
with:
report_paths: "${{ env.JUNIT_OUT }}"
include_passed: true
detailed_summary: true
annotate_notice: true
require_tests: false
fail_on_parse_error: true
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: claude-go-suite-artifacts
path: |
${{ env.JUNIT_OUT }}
${{ env.MD_OUT }}
reports/*_results.xml
retention-days: 7
# ---------- Cleanup ----------
- name: Stop Unity
if: always()
run: |
docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
docker rm -f unity-mcp || true
- name: Return Pro license (if used)
if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true'
uses: game-ci/unity-return-license@v2
continue-on-error: true
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}

View File

@ -751,6 +751,75 @@ jobs:
exit 1
fi
# ---------- Run GO pass (GameObject API tests) ----------
- name: Run Claude GO pass
uses: anthropics/claude-code-base-action@beta
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
continue-on-error: true
env:
UNITY_MCP_DEFAULT_INSTANCE: ${{ env.UNITY_MCP_DEFAULT_INSTANCE }}
with:
use_node_cache: false
prompt_file: .claude/prompts/nl-gameobject-suite.md
mcp_config: .claude/mcp.json
settings: .claude/settings.json
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
model: claude-haiku-4-5-20251001
fallback_model: claude-sonnet-4-5-20250929
append_system_prompt: |
You are running the GO pass (GameObject API tests) only.
Output requirements:
- Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10.
- Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml).
- Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch.
- Do not emit any NL-* or T-* fragments.
Stop condition:
- After GO-10_results.xml is written, stop.
timeout_minutes: "20"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Check GO coverage incomplete (pre-retry)
id: go_cov
if: always()
shell: bash
run: |
set -euo pipefail
missing=()
for id in GO-0 GO-1 GO-2 GO-3 GO-4 GO-5 GO-6 GO-7 GO-8 GO-9 GO-10; do
if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then
missing+=("$id")
fi
done
echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT"
if (( ${#missing[@]} )); then
echo "list=${missing[*]}" >> "$GITHUB_OUTPUT"
fi
- name: Retry GO pass (Sonnet) if incomplete
if: steps.go_cov.outputs.missing != '0'
uses: anthropics/claude-code-base-action@beta
with:
use_node_cache: false
prompt_file: .claude/prompts/nl-gameobject-suite.md
mcp_config: .claude/mcp.json
settings: .claude/settings.json
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
model: claude-sonnet-4-5-20250929
fallback_model: claude-haiku-4-5-20251001
append_system_prompt: |
You are running the GO pass only.
Output requirements:
- Emit exactly 11 test fragments: GO-0, GO-1, GO-2, GO-3, GO-4, GO-5, GO-6, GO-7, GO-8, GO-9, GO-10.
- Write each fragment to reports/${ID}_results.xml (e.g., GO-0_results.xml).
- Prefer a single MultiEdit(reports/**) call that writes all eleven files in one batch.
- Do not emit any NL-* or T-* fragments.
Stop condition:
- After GO-10_results.xml is written, stop.
timeout_minutes: "20"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# (kept) Finalize staged report fragments (promote to reports/)
# (removed duplicate) Finalize staged report fragments
@ -796,6 +865,17 @@ jobs:
("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"),
("T-I", r"\b(T-?I|Failure\s*Surface)\b"),
("T-J", r"\b(T-?J|Idempotenc(y|e))\b"),
("GO-0", r"\b(GO-?0|Hierarchy.*ComponentTypes)\b"),
("GO-1", r"\b(GO-?1|Find\s*GameObjects\s*Tool)\b"),
("GO-2", r"\b(GO-?2|GameObject\s*Resource)\b"),
("GO-3", r"\b(GO-?3|Components\s*Resource)\b"),
("GO-4", r"\b(GO-?4|Manage\s*Components)\b"),
("GO-5", r"\b(GO-?5|Find.*by.*Name)\b"),
("GO-6", r"\b(GO-?6|Find.*by.*Tag)\b"),
("GO-7", r"\b(GO-?7|Single\s*Component)\b"),
("GO-8", r"\b(GO-?8|Remove\s*Component)\b"),
("GO-9", r"\b(GO-?9|Pagination)\b"),
("GO-10", r"\b(GO-?10|Deprecation)\b"),
]
def canon_name(name: str) -> str:
@ -822,6 +902,9 @@ jobs:
m = re.match(r'T-?([A-J])_results\.xml$', n, re.I)
if m:
return f"T-{m.group(1).upper()}"
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
if m:
return f"GO-{int(m.group(1))}"
return None
frags = list(sorted(Path("reports").glob("*_results.xml")))
@ -837,7 +920,7 @@ jobs:
# Prefer filename-derived ID; if name doesn't start with it, override
if file_id:
# Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns)
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip()
title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', old).strip()
new = f"{file_id} — {title}" if title else file_id
else:
new = canon_name(old)
@ -860,7 +943,7 @@ jobs:
import re
import shutil
DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"]
DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J","GO-0","GO-1","GO-2","GO-3","GO-4","GO-5","GO-6","GO-7","GO-8","GO-9","GO-10"]
seen = set()
bad = set()
def id_from_filename(p: Path):
@ -871,6 +954,9 @@ jobs:
m = re.match(r'T-?([A-J])_results\.xml$', n, re.I)
if m:
return f"T-{m.group(1).upper()}"
m = re.match(r'GO-?(\d+)_results\.xml$', n, re.I)
if m:
return f"GO-{int(m.group(1))}"
return None
for p in Path("reports").glob("*_results.xml"):
@ -963,12 +1049,15 @@ jobs:
m = re.match(r'T([A-J])_results\.xml$', n, re.I)
if m:
return f"T-{m.group(1).upper()}"
m = re.match(r'GO(\d+)_results\.xml$', n, re.I)
if m:
return f"GO-{int(m.group(1))}"
return None
def id_from_system_out(tc):
so = tc.find('system-out')
if so is not None and so.text:
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
if m:
return m.group(1)
return None
@ -1005,13 +1094,13 @@ jobs:
current_name = tc.get('name') or ''
tid = test_id or id_from_system_out(tc)
# Enforce filename-derived ID as prefix; repair names if needed
if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name):
if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', current_name):
title = current_name.strip()
new_name = f'{tid} — {title}' if title else tid
tc.set('name', new_name)
elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name):
# Replace any wrong leading ID with the correct one
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip()
title = re.sub(r'^\s*(NL-\d+|T-[A-Z]|GO-\d+)\s*[—–:\-]\s*', '', current_name).strip()
new_name = f'{tid} — {title}' if title else tid
tc.set('name', new_name)
renamed += 1
@ -1061,12 +1150,12 @@ jobs:
def id_from_case(tc):
n = (tc.get('name') or '')
m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n)
m = re.match(r'\s*(NL-\d+|T-[A-Z]|GO-\d+)\b', n)
if m:
return m.group(1)
so = tc.find('system-out')
if so is not None and so.text:
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
if m:
return m.group(1)
return None
@ -1080,7 +1169,7 @@ jobs:
id_status[tid] = ok
name_map[tid] = (tc.get('name') or tid)
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10']
default_titles = {
'NL-0': 'Baseline State Capture',
'NL-1': 'Core Method Operations',
@ -1097,6 +1186,17 @@ jobs:
'T-H': 'Validation on Modified',
'T-I': 'Failure Surface',
'T-J': 'Idempotency',
'GO-0': 'Hierarchy with ComponentTypes',
'GO-1': 'Find GameObjects Tool',
'GO-2': 'GameObject Resource Read',
'GO-3': 'Components Resource Read',
'GO-4': 'Manage Components Tool',
'GO-5': 'Find GameObjects by Name',
'GO-6': 'Find GameObjects by Tag',
'GO-7': 'Single Component Resource Read',
'GO-8': 'Remove Component',
'GO-9': 'Find with Pagination',
'GO-10': 'Deprecation Warnings',
}
def display_name(test_id: str) -> str:
@ -1189,7 +1289,7 @@ jobs:
from pathlib import Path
import xml.etree.ElementTree as ET
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J','GO-0','GO-1','GO-2','GO-3','GO-4','GO-5','GO-6','GO-7','GO-8','GO-9','GO-10']
junit_path = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml'))
if not junit_path.exists():
@ -1206,12 +1306,12 @@ jobs:
def id_from_case(tc):
name = (tc.get('name') or '').strip()
m = re.match(r'(NL-\d+|T-[A-Z])\b', name)
m = re.match(r'(NL-\d+|T-[A-Z]|GO-\d+)\b', name)
if m:
return m.group(1)
so = tc.find('system-out')
if so is not None and so.text:
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
m = re.search(r'\b(NL-\d+|T-[A-Z]|GO-\d+)\b', so.text)
if m:
return m.group(1)
return None

9
.gitignore vendored
View File

@ -42,3 +42,12 @@ TestProjects/UnityMCPTests/Assets/Temp/
*.backup.meta
.wt-origin-main/
# CI test reports (generated during test runs)
reports/
# Local Claude configs (not for repo)
.claude/local/
# Local testing harness
scripts/local-test/

View File

@ -0,0 +1,343 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for finding and looking up GameObjects in the scene.
/// Provides search functionality by name, tag, layer, component, path, and instance ID.
/// </summary>
public static class GameObjectLookup
{
/// <summary>
/// Supported search methods for finding GameObjects.
/// </summary>
public enum SearchMethod
{
ByName,
ByTag,
ByLayer,
ByComponent,
ByPath,
ById
}
/// <summary>
/// Parses a search method string into the enum value.
/// </summary>
public static SearchMethod ParseSearchMethod(string method)
{
if (string.IsNullOrEmpty(method))
return SearchMethod.ByName;
return method.ToLowerInvariant() switch
{
"by_name" => SearchMethod.ByName,
"by_tag" => SearchMethod.ByTag,
"by_layer" => SearchMethod.ByLayer,
"by_component" => SearchMethod.ByComponent,
"by_path" => SearchMethod.ByPath,
"by_id" => SearchMethod.ById,
_ => SearchMethod.ByName
};
}
/// <summary>
/// Finds a single GameObject based on the target and search method.
/// </summary>
/// <param name="target">The target identifier (name, ID, path, etc.)</param>
/// <param name="searchMethod">The search method to use</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <returns>The found GameObject or null</returns>
public static GameObject FindByTarget(JToken target, string searchMethod, bool includeInactive = false)
{
if (target == null)
return null;
var results = SearchGameObjects(searchMethod, target.ToString(), includeInactive, 1);
return results.Count > 0 ? FindById(results[0]) : null;
}
/// <summary>
/// Finds a GameObject by its instance ID.
/// </summary>
public static GameObject FindById(int instanceId)
{
return EditorUtility.InstanceIDToObject(instanceId) as GameObject;
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="searchMethod">The search method string (by_name, by_tag, etc.)</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(string searchMethod, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var method = ParseSearchMethod(searchMethod);
return SearchGameObjects(method, searchTerm, includeInactive, maxResults);
}
/// <summary>
/// Searches for GameObjects and returns their instance IDs.
/// </summary>
/// <param name="method">The search method</param>
/// <param name="searchTerm">The term to search for</param>
/// <param name="includeInactive">Whether to include inactive objects</param>
/// <param name="maxResults">Maximum number of results to return (0 = unlimited)</param>
/// <returns>List of instance IDs</returns>
public static List<int> SearchGameObjects(SearchMethod method, string searchTerm, bool includeInactive = false, int maxResults = 0)
{
var results = new List<int>();
switch (method)
{
case SearchMethod.ById:
if (int.TryParse(searchTerm, out int instanceId))
{
var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
if (obj != null && (includeInactive || obj.activeInHierarchy))
{
results.Add(instanceId);
}
}
break;
case SearchMethod.ByName:
results.AddRange(SearchByName(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByPath:
results.AddRange(SearchByPath(searchTerm, includeInactive));
break;
case SearchMethod.ByTag:
results.AddRange(SearchByTag(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByLayer:
results.AddRange(SearchByLayer(searchTerm, includeInactive, maxResults));
break;
case SearchMethod.ByComponent:
results.AddRange(SearchByComponent(searchTerm, includeInactive, maxResults));
break;
}
return results;
}
private static IEnumerable<int> SearchByName(string name, bool includeInactive, int maxResults)
{
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.name == name);
if (maxResults > 0)
matching = matching.Take(maxResults);
return matching.Select(go => go.GetInstanceID());
}
private static IEnumerable<int> SearchByPath(string path, bool includeInactive)
{
// NOTE: Unity's GameObject.Find(path) only finds ACTIVE GameObjects.
// The includeInactive parameter has no effect here due to Unity API limitations.
// Consider using by_name search with includeInactive if you need to find inactive objects.
if (includeInactive)
{
Debug.LogWarning("[GameObjectLookup] SearchByPath with includeInactive=true: " +
"GameObject.Find() cannot find inactive objects. Use by_name search instead.");
}
var found = GameObject.Find(path);
if (found != null)
{
yield return found.GetInstanceID();
}
}
private static IEnumerable<int> SearchByTag(string tag, bool includeInactive, int maxResults)
{
GameObject[] taggedObjects;
try
{
if (includeInactive)
{
// FindGameObjectsWithTag doesn't find inactive, so we need to iterate all
var allObjects = GetAllSceneObjects(true);
taggedObjects = allObjects.Where(go => go.CompareTag(tag)).ToArray();
}
else
{
taggedObjects = GameObject.FindGameObjectsWithTag(tag);
}
}
catch (UnityException)
{
// Tag doesn't exist
yield break;
}
var results = taggedObjects.AsEnumerable();
if (maxResults > 0)
results = results.Take(maxResults);
foreach (var go in results)
{
yield return go.GetInstanceID();
}
}
private static IEnumerable<int> SearchByLayer(string layerName, bool includeInactive, int maxResults)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer == -1)
{
// Try parsing as layer number
if (!int.TryParse(layerName, out layer) || layer < 0 || layer > 31)
{
yield break;
}
}
var allObjects = GetAllSceneObjects(includeInactive);
var matching = allObjects.Where(go => go.layer == layer);
if (maxResults > 0)
matching = matching.Take(maxResults);
foreach (var go in matching)
{
yield return go.GetInstanceID();
}
}
private static IEnumerable<int> SearchByComponent(string componentTypeName, bool includeInactive, int maxResults)
{
Type componentType = FindComponentType(componentTypeName);
if (componentType == null)
{
Debug.LogWarning($"[GameObjectLookup] Component type '{componentTypeName}' not found.");
yield break;
}
var allObjects = GetAllSceneObjects(includeInactive);
var count = 0;
foreach (var go in allObjects)
{
if (go.GetComponent(componentType) != null)
{
yield return go.GetInstanceID();
count++;
if (maxResults > 0 && count >= maxResults)
yield break;
}
}
}
/// <summary>
/// Gets all GameObjects in the current scene.
/// </summary>
public static IEnumerable<GameObject> GetAllSceneObjects(bool includeInactive)
{
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
yield break;
var rootObjects = scene.GetRootGameObjects();
foreach (var root in rootObjects)
{
foreach (var go in GetObjectAndDescendants(root, includeInactive))
{
yield return go;
}
}
}
private static IEnumerable<GameObject> GetObjectAndDescendants(GameObject obj, bool includeInactive)
{
if (!includeInactive && !obj.activeInHierarchy)
yield break;
yield return obj;
foreach (Transform child in obj.transform)
{
foreach (var descendant in GetObjectAndDescendants(child.gameObject, includeInactive))
{
yield return descendant;
}
}
}
/// <summary>
/// Finds a component type by name, searching loaded assemblies.
/// </summary>
public static Type FindComponentType(string typeName)
{
// Try direct type lookup first
var type = Type.GetType(typeName);
if (type != null && typeof(Component).IsAssignableFrom(type))
return type;
// Search in UnityEngine
type = typeof(Component).Assembly.GetType($"UnityEngine.{typeName}");
if (type != null && typeof(Component).IsAssignableFrom(type))
return type;
// Search all loaded assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
// Try exact match
type = assembly.GetType(typeName);
if (type != null && typeof(Component).IsAssignableFrom(type))
return type;
// Try with UnityEngine prefix
type = assembly.GetType($"UnityEngine.{typeName}");
if (type != null && typeof(Component).IsAssignableFrom(type))
return type;
}
catch (Exception)
{
// Skip assemblies that can't be searched (e.g., dynamic, reflection-only)
// This is expected for some assemblies in Unity's domain
}
}
return null;
}
/// <summary>
/// Gets the hierarchical path of a GameObject.
/// </summary>
public static string GetGameObjectPath(GameObject obj)
{
if (obj == null)
return string.Empty;
var path = obj.name;
var parent = obj.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
}
}

View File

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

View File

@ -0,0 +1,160 @@
using System;
using System.Globalization;
using Newtonsoft.Json.Linq;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for coercing JSON parameter values to strongly-typed values.
/// Handles various input formats (strings, numbers, booleans) gracefully.
/// </summary>
public static class ParamCoercion
{
/// <summary>
/// Coerces a JToken to an integer value, handling strings and floats.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced integer value or default</returns>
public static int CoerceInt(JToken token, int defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Integer)
return token.Value<int>();
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
return i;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
return (int)d;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a boolean value, handling strings like "true", "1", etc.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced boolean value or default</returns>
public static bool CoerceBool(JToken token, bool defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Boolean)
return token.Value<bool>();
var s = token.ToString().Trim().ToLowerInvariant();
if (s.Length == 0)
return defaultValue;
if (bool.TryParse(s, out var b))
return b;
if (s == "1" || s == "yes" || s == "on")
return true;
if (s == "0" || s == "no" || s == "off")
return false;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a float value, handling strings and integers.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced float value or default</returns>
public static float CoerceFloat(JToken token, float defaultValue)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer)
return token.Value<float>();
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var f))
return f;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
/// <summary>
/// Coerces a JToken to a string value, with null handling.
/// </summary>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if null or empty</param>
/// <returns>The string value or default</returns>
public static string CoerceString(JToken token, string defaultValue = null)
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
var s = token.ToString();
return string.IsNullOrEmpty(s) ? defaultValue : s;
}
/// <summary>
/// Coerces a JToken to an enum value, handling strings.
/// </summary>
/// <typeparam name="T">The enum type</typeparam>
/// <param name="token">The JSON token to coerce</param>
/// <param name="defaultValue">Default value if coercion fails</param>
/// <returns>The coerced enum value or default</returns>
public static T CoerceEnum<T>(JToken token, T defaultValue) where T : struct, Enum
{
if (token == null || token.Type == JTokenType.Null)
return defaultValue;
try
{
var s = token.ToString().Trim();
if (s.Length == 0)
return defaultValue;
if (Enum.TryParse<T>(s, ignoreCase: true, out var result))
return result;
}
catch
{
// Swallow and return default
}
return defaultValue;
}
}
}

View File

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

View File

@ -0,0 +1,296 @@
using System;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Utility class for parsing JSON tokens into Unity vector and math types.
/// Supports both array format [x, y, z] and object format {x: 1, y: 2, z: 3}.
/// </summary>
public static class VectorParsing
{
/// <summary>
/// Parses a JToken (array or object) into a Vector3.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector3 or null if parsing fails</returns>
public static Vector3? ParseVector3(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [x, y, z]
if (token is JArray array && array.Count >= 3)
{
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
// Object format: {x: 1, y: 2, z: 3}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z"))
{
return new Vector3(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>()
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Vector3 from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Vector3, returning a default value if parsing fails.
/// </summary>
public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = default)
{
return ParseVector3(token) ?? defaultValue;
}
/// <summary>
/// Parses a JToken (array or object) into a Vector2.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Vector2 or null if parsing fails</returns>
public static Vector2? ParseVector2(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [x, y]
if (token is JArray array && array.Count >= 2)
{
return new Vector2(
array[0].ToObject<float>(),
array[1].ToObject<float>()
);
}
// Object format: {x: 1, y: 2}
if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y"))
{
return new Vector2(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>()
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Vector2 from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken (array or object) into a Quaternion.
/// Supports both euler angles [x, y, z] and quaternion components [x, y, z, w].
/// Note: Raw quaternion components are NOT normalized. Callers should normalize if needed
/// for operations like interpolation where non-unit quaternions cause issues.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <param name="asEulerAngles">If true, treats 3-element arrays as euler angles</param>
/// <returns>The parsed Quaternion or null if parsing fails</returns>
public static Quaternion? ParseQuaternion(JToken token, bool asEulerAngles = true)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JArray array)
{
// Quaternion components: [x, y, z, w]
if (array.Count >= 4)
{
return new Quaternion(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
// Euler angles: [x, y, z]
if (array.Count >= 3 && asEulerAngles)
{
return Quaternion.Euler(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
}
// Object format: {x: 0, y: 0, z: 0, w: 1}
if (token is JObject obj)
{
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w"))
{
return new Quaternion(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>(),
obj["w"].ToObject<float>()
);
}
// Euler format in object: {x: 45, y: 90, z: 0} (as euler angles)
if (obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && asEulerAngles)
{
return Quaternion.Euler(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["z"].ToObject<float>()
);
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Quaternion from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken (array or object) into a Color.
/// Supports both [r, g, b, a] and {r: 1, g: 1, b: 1, a: 1} formats.
/// </summary>
/// <param name="token">The JSON token to parse</param>
/// <returns>The parsed Color or null if parsing fails</returns>
public static Color? ParseColor(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
// Array format: [r, g, b, a] or [r, g, b]
if (token is JArray array)
{
if (array.Count >= 4)
{
return new Color(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
if (array.Count >= 3)
{
return new Color(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
1f // Default alpha
);
}
}
// Object format: {r: 1, g: 1, b: 1, a: 1}
if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b"))
{
float a = obj.ContainsKey("a") ? obj["a"].ToObject<float>() : 1f;
return new Color(
obj["r"].ToObject<float>(),
obj["g"].ToObject<float>(),
obj["b"].ToObject<float>(),
a
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Color from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Rect.
/// Supports {x, y, width, height} format.
/// </summary>
public static Rect? ParseRect(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JObject obj &&
obj.ContainsKey("x") && obj.ContainsKey("y") &&
obj.ContainsKey("width") && obj.ContainsKey("height"))
{
return new Rect(
obj["x"].ToObject<float>(),
obj["y"].ToObject<float>(),
obj["width"].ToObject<float>(),
obj["height"].ToObject<float>()
);
}
// Array format: [x, y, width, height]
if (token is JArray array && array.Count >= 4)
{
return new Rect(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>(),
array[3].ToObject<float>()
);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Rect from '{token}': {ex.Message}");
}
return null;
}
/// <summary>
/// Parses a JToken into a Bounds.
/// Supports {center: {x,y,z}, size: {x,y,z}} format.
/// </summary>
public static Bounds? ParseBounds(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return null;
try
{
if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size"))
{
var center = ParseVector3(obj["center"]) ?? Vector3.zero;
var size = ParseVector3(obj["size"]) ?? Vector3.zero;
return new Bounds(center, size);
}
}
catch (Exception ex)
{
Debug.LogWarning($"[VectorParsing] Failed to parse Bounds from '{token}': {ex.Message}");
}
return null;
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Resources.Scene
{
/// <summary>
/// Resource handler for reading GameObject data.
/// Provides read-only access to GameObject information without component serialization.
///
/// URI: unity://scene/gameobject/{instanceID}
/// </summary>
[McpForUnityResource("get_gameobject")]
public static class GameObjectResource
{
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
// Get instance ID from params
int? instanceID = null;
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
if (idToken != null)
{
instanceID = ParamCoercion.CoerceInt(idToken, -1);
if (instanceID == -1)
{
instanceID = null;
}
}
if (!instanceID.HasValue)
{
return new ErrorResponse("'instanceID' parameter is required.");
}
try
{
var go = EditorUtility.InstanceIDToObject(instanceID.Value) as GameObject;
if (go == null)
{
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
}
return new
{
success = true,
data = SerializeGameObject(go)
};
}
catch (Exception e)
{
Debug.LogError($"[GameObjectResource] Error getting GameObject: {e}");
return new ErrorResponse($"Error getting GameObject: {e.Message}");
}
}
/// <summary>
/// Serializes a GameObject without component details.
/// For component data, use GetComponents or GetComponent resources.
/// </summary>
public static object SerializeGameObject(GameObject go)
{
if (go == null)
return null;
var transform = go.transform;
// Get component type names (not full serialization)
var componentTypes = go.GetComponents<Component>()
.Where(c => c != null)
.Select(c => c.GetType().Name)
.ToList();
// Get children instance IDs (not full serialization)
var childrenIds = new List<int>();
foreach (Transform child in transform)
{
childrenIds.Add(child.gameObject.GetInstanceID());
}
return new
{
instanceID = go.GetInstanceID(),
name = go.name,
tag = go.tag,
layer = go.layer,
layerName = LayerMask.LayerToName(go.layer),
active = go.activeSelf,
activeInHierarchy = go.activeInHierarchy,
isStatic = go.isStatic,
transform = new
{
position = SerializeVector3(transform.position),
localPosition = SerializeVector3(transform.localPosition),
rotation = SerializeVector3(transform.eulerAngles),
localRotation = SerializeVector3(transform.localEulerAngles),
scale = SerializeVector3(transform.localScale),
lossyScale = SerializeVector3(transform.lossyScale)
},
parent = transform.parent != null ? transform.parent.gameObject.GetInstanceID() : (int?)null,
children = childrenIds,
componentTypes = componentTypes,
path = GameObjectLookup.GetGameObjectPath(go)
};
}
private static object SerializeVector3(Vector3 v)
{
return new { x = v.x, y = v.y, z = v.z };
}
}
/// <summary>
/// Resource handler for reading all components on a GameObject.
///
/// URI: unity://scene/gameobject/{instanceID}/components
/// </summary>
[McpForUnityResource("get_gameobject_components")]
public static class GameObjectComponentsResource
{
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
int instanceID = ParamCoercion.CoerceInt(idToken, -1);
if (instanceID == -1)
{
return new ErrorResponse("'instanceID' parameter is required.");
}
// Pagination parameters
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 25);
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
bool includeProperties = ParamCoercion.CoerceBool(@params["includeProperties"] ?? @params["include_properties"], true);
pageSize = Mathf.Clamp(pageSize, 1, 100);
try
{
var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
if (go == null)
{
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
}
var allComponents = go.GetComponents<Component>().Where(c => c != null).ToList();
int total = allComponents.Count;
var pagedComponents = allComponents.Skip(cursor).Take(pageSize).ToList();
var componentData = new List<object>();
foreach (var component in pagedComponents)
{
if (includeProperties)
{
componentData.Add(GameObjectSerializer.GetComponentData(component));
}
else
{
componentData.Add(new
{
typeName = component.GetType().FullName,
instanceID = component.GetInstanceID()
});
}
}
int? nextCursor = cursor + pagedComponents.Count < total ? cursor + pagedComponents.Count : (int?)null;
return new
{
success = true,
data = new
{
gameObjectID = instanceID,
gameObjectName = go.name,
components = componentData,
cursor = cursor,
pageSize = pageSize,
nextCursor = nextCursor,
totalCount = total,
hasMore = nextCursor.HasValue,
includeProperties = includeProperties
}
};
}
catch (Exception e)
{
Debug.LogError($"[GameObjectComponentsResource] Error getting components: {e}");
return new ErrorResponse($"Error getting components: {e.Message}");
}
}
}
/// <summary>
/// Resource handler for reading a single component on a GameObject.
///
/// URI: unity://scene/gameobject/{instanceID}/component/{componentName}
/// </summary>
[McpForUnityResource("get_gameobject_component")]
public static class GameObjectComponentResource
{
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
var idToken = @params["instanceID"] ?? @params["instance_id"] ?? @params["id"];
int instanceID = ParamCoercion.CoerceInt(idToken, -1);
if (instanceID == -1)
{
return new ErrorResponse("'instanceID' parameter is required.");
}
string componentName = ParamCoercion.CoerceString(@params["componentName"] ?? @params["component_name"] ?? @params["component"], null);
if (string.IsNullOrEmpty(componentName))
{
return new ErrorResponse("'componentName' parameter is required.");
}
try
{
var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
if (go == null)
{
return new ErrorResponse($"GameObject with instance ID {instanceID} not found.");
}
// Find the component by type name
Component targetComponent = null;
foreach (var component in go.GetComponents<Component>())
{
if (component == null) continue;
var typeName = component.GetType().Name;
var fullTypeName = component.GetType().FullName;
if (string.Equals(typeName, componentName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(fullTypeName, componentName, StringComparison.OrdinalIgnoreCase))
{
targetComponent = component;
break;
}
}
if (targetComponent == null)
{
return new ErrorResponse($"Component '{componentName}' not found on GameObject '{go.name}'.");
}
return new
{
success = true,
data = new
{
gameObjectID = instanceID,
gameObjectName = go.name,
component = GameObjectSerializer.GetComponentData(targetComponent)
}
};
}
catch (Exception e)
{
Debug.LogError($"[GameObjectComponentResource] Error getting component: {e}");
return new ErrorResponse($"Error getting component: {e.Message}");
}
}
}
}

View File

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

View File

@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Tool for searching GameObjects in the scene.
/// Returns only instance IDs with pagination support.
///
/// This is a focused search tool that returns lightweight results (IDs only).
/// For detailed GameObject data, use the unity://scene/gameobject/{id} resource.
/// </summary>
[McpForUnityTool("find_gameobjects")]
public static class FindGameObjects
{
/// <summary>
/// Handles the find_gameobjects command.
/// </summary>
/// <param name="params">Command parameters</param>
/// <returns>Paginated list of instance IDs</returns>
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
// Parse search parameters
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], "by_name");
string searchTerm = ParamCoercion.CoerceString(@params["searchTerm"] ?? @params["search_term"] ?? @params["target"], null);
if (string.IsNullOrEmpty(searchTerm))
{
return new ErrorResponse("'searchTerm' or 'target' parameter is required.");
}
// Pagination parameters
int pageSize = ParamCoercion.CoerceInt(@params["pageSize"] ?? @params["page_size"], 50);
int cursor = ParamCoercion.CoerceInt(@params["cursor"], 0);
// Search options
bool includeInactive = ParamCoercion.CoerceBool(@params["includeInactive"] ?? @params["searchInactive"] ?? @params["include_inactive"], false);
// Validate pageSize bounds
pageSize = Mathf.Clamp(pageSize, 1, 500);
try
{
// Get all matching instance IDs
var allIds = GameObjectLookup.SearchGameObjects(searchMethod, searchTerm, includeInactive, 0);
int totalCount = allIds.Count;
// Apply pagination
var pagedIds = allIds.Skip(cursor).Take(pageSize).ToList();
// Calculate next cursor
int? nextCursor = cursor + pagedIds.Count < totalCount ? cursor + pagedIds.Count : (int?)null;
return new
{
success = true,
data = new
{
instanceIDs = pagedIds,
pageSize = pageSize,
cursor = cursor,
nextCursor = nextCursor,
totalCount = totalCount,
hasMore = nextCursor.HasValue
}
};
}
catch (System.Exception ex)
{
Debug.LogError($"[FindGameObjects] Error searching GameObjects: {ex.Message}");
return new ErrorResponse($"Error searching GameObjects: {ex.Message}");
}
}
}
}

View File

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

View File

@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Tool for managing components on GameObjects.
/// Actions: add, remove, set_property
///
/// This is a focused tool for component lifecycle operations.
/// For reading component data, use the unity://scene/gameobject/{id}/components resource.
/// </summary>
[McpForUnityTool("manage_components")]
public static class ManageComponents
{
/// <summary>
/// Handles the manage_components command.
/// </summary>
/// <param name="params">Command parameters</param>
/// <returns>Result of the component operation</returns>
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return new ErrorResponse("Parameters cannot be null.");
}
string action = ParamCoercion.CoerceString(@params["action"], null)?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("'action' parameter is required (add, remove, set_property).");
}
// Target resolution
JToken targetToken = @params["target"];
string searchMethod = ParamCoercion.CoerceString(@params["searchMethod"] ?? @params["search_method"], null);
if (targetToken == null)
{
return new ErrorResponse("'target' parameter is required.");
}
try
{
return action switch
{
"add" => AddComponent(@params, targetToken, searchMethod),
"remove" => RemoveComponent(@params, targetToken, searchMethod),
"set_property" => SetProperty(@params, targetToken, searchMethod),
_ => new ErrorResponse($"Unknown action: '{action}'. Supported actions: add, remove, set_property")
};
}
catch (Exception e)
{
Debug.LogError($"[ManageComponents] Action '{action}' failed: {e}");
return new ErrorResponse($"Internal error processing action '{action}': {e.Message}");
}
}
#region Action Implementations
private static object AddComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'add' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found. Use a fully-qualified name if needed.");
}
// Optional properties to set on the new component
JObject properties = @params["properties"] as JObject ?? @params["componentProperties"] as JObject;
try
{
// Undo.AddComponent creates its own undo record, no need for RecordObject
Component newComponent = Undo.AddComponent(targetGo, type);
if (newComponent == null)
{
return new ErrorResponse($"Failed to add component '{componentType}' to '{targetGo.name}'.");
}
// Set properties if provided
if (properties != null && properties.HasValues)
{
SetPropertiesOnComponent(newComponent, properties);
}
EditorUtility.SetDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentType}' added to '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
componentType = type.FullName,
componentInstanceID = newComponent.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error adding component '{componentType}': {e.Message}");
}
}
private static object RemoveComponent(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'remove' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found.");
}
// Prevent removal of Transform (check early before GetComponent)
if (type == typeof(Transform))
{
return new ErrorResponse("Cannot remove the Transform component.");
}
Component component = targetGo.GetComponent(type);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
try
{
Undo.DestroyObjectImmediate(component);
EditorUtility.SetDirty(targetGo);
return new
{
success = true,
message = $"Component '{componentType}' removed from '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error removing component '{componentType}': {e.Message}");
}
}
private static object SetProperty(JObject @params, JToken targetToken, string searchMethod)
{
GameObject targetGo = FindTarget(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.");
}
string componentType = ParamCoercion.CoerceString(@params["componentType"] ?? @params["component_type"], null);
if (string.IsNullOrEmpty(componentType))
{
return new ErrorResponse("'componentType' parameter is required for 'set_property' action.");
}
// Resolve component type
Type type = FindComponentType(componentType);
if (type == null)
{
return new ErrorResponse($"Component type '{componentType}' not found.");
}
Component component = targetGo.GetComponent(type);
if (component == null)
{
return new ErrorResponse($"Component '{componentType}' not found on '{targetGo.name}'.");
}
// Get property and value
string propertyName = ParamCoercion.CoerceString(@params["property"], null);
JToken valueToken = @params["value"];
// Support both single property or properties object
JObject properties = @params["properties"] as JObject;
if (string.IsNullOrEmpty(propertyName) && (properties == null || !properties.HasValues))
{
return new ErrorResponse("Either 'property'+'value' or 'properties' object is required for 'set_property' action.");
}
var errors = new List<string>();
try
{
Undo.RecordObject(component, $"Set property on {componentType}");
if (!string.IsNullOrEmpty(propertyName) && valueToken != null)
{
// Single property mode
var error = TrySetProperty(component, propertyName, valueToken);
if (error != null)
{
errors.Add(error);
}
}
if (properties != null && properties.HasValues)
{
// Multiple properties mode
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
{
errors.Add(error);
}
}
}
EditorUtility.SetDirty(component);
if (errors.Count > 0)
{
return new
{
success = false,
message = $"Some properties failed to set on '{componentType}'.",
data = new
{
instanceID = targetGo.GetInstanceID(),
errors = errors
}
};
}
return new
{
success = true,
message = $"Properties set on component '{componentType}' on '{targetGo.name}'.",
data = new
{
instanceID = targetGo.GetInstanceID()
}
};
}
catch (Exception e)
{
return new ErrorResponse($"Error setting properties on component '{componentType}': {e.Message}");
}
}
#endregion
#region Helpers
private static GameObject FindTarget(JToken targetToken, string searchMethod)
{
if (targetToken == null)
return null;
// Try instance ID first
if (targetToken.Type == JTokenType.Integer)
{
int instanceId = targetToken.Value<int>();
return GameObjectLookup.FindById(instanceId);
}
string targetStr = targetToken.ToString();
// Try parsing as instance ID
if (int.TryParse(targetStr, out int parsedId))
{
var byId = GameObjectLookup.FindById(parsedId);
if (byId != null)
return byId;
}
// Use GameObjectLookup for search
return GameObjectLookup.FindByTarget(targetToken, searchMethod ?? "by_name", true);
}
/// <summary>
/// Finds a component type by name. Delegates to GameObjectLookup.FindComponentType.
/// </summary>
private static Type FindComponentType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return null;
return GameObjectLookup.FindComponentType(typeName);
}
private static void SetPropertiesOnComponent(Component component, JObject properties)
{
if (component == null || properties == null)
return;
var errors = new List<string>();
foreach (var prop in properties.Properties())
{
var error = TrySetProperty(component, prop.Name, prop.Value);
if (error != null)
errors.Add(error);
}
if (errors.Count > 0)
{
Debug.LogWarning($"[ManageComponents] Some properties failed to set on {component.GetType().Name}: {string.Join(", ", errors)}");
}
}
/// <summary>
/// Attempts to set a property or field on a component.
/// Note: Property/field lookup is case-insensitive for better usability with external callers.
/// </summary>
private static string TrySetProperty(Component component, string propertyName, JToken value)
{
if (component == null || string.IsNullOrEmpty(propertyName))
return $"Invalid component or property name";
var type = component.GetType();
// Try property first
var propInfo = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (propInfo != null && propInfo.CanWrite)
{
try
{
var convertedValue = ConvertValue(value, propInfo.PropertyType);
propInfo.SetValue(component, convertedValue);
return null; // Success
}
catch (Exception e)
{
Debug.LogWarning($"[ManageComponents] Failed to set property '{propertyName}': {e.Message}");
return $"Failed to set property '{propertyName}': {e.Message}";
}
}
// Try field
var fieldInfo = type.GetField(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (fieldInfo != null)
{
try
{
var convertedValue = ConvertValue(value, fieldInfo.FieldType);
fieldInfo.SetValue(component, convertedValue);
return null; // Success
}
catch (Exception e)
{
Debug.LogWarning($"[ManageComponents] Failed to set field '{propertyName}': {e.Message}");
return $"Failed to set field '{propertyName}': {e.Message}";
}
}
Debug.LogWarning($"[ManageComponents] Property or field '{propertyName}' not found on {type.Name}");
return $"Property '{propertyName}' not found on {type.Name}";
}
private static object ConvertValue(JToken token, Type targetType)
{
if (token == null || token.Type == JTokenType.Null)
return null;
// Handle Unity types
if (targetType == typeof(Vector3))
{
return VectorParsing.ParseVector3OrDefault(token);
}
if (targetType == typeof(Vector2))
{
return VectorParsing.ParseVector2(token) ?? Vector2.zero;
}
if (targetType == typeof(Quaternion))
{
return VectorParsing.ParseQuaternion(token) ?? Quaternion.identity;
}
if (targetType == typeof(Color))
{
return VectorParsing.ParseColor(token) ?? Color.white;
}
// Use Newtonsoft for other types
return token.ToObject(targetType);
}
#endregion
}
}

View File

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

View File

@ -73,10 +73,6 @@ namespace MCPForUnity.Editor.Tools
string layer = @params["layer"]?.ToString();
JToken parentToken = @params["parent"];
// --- Add parameter for controlling non-public field inclusion ---
bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject<bool>() ?? true; // Default to true
// --- End add parameter ---
// Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string
var componentPropsToken = @params["componentProperties"];
if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String)
@ -166,76 +162,13 @@ namespace MCPForUnity.Editor.Tools
{
switch (action)
{
// --- Primary lifecycle actions (kept in manage_gameobject) ---
case "create":
return CreateGameObject(@params);
case "modify":
return ModifyGameObject(@params, targetToken, searchMethod);
case "delete":
return DeleteGameObject(targetToken, searchMethod);
case "find":
return FindGameObjects(@params, targetToken, searchMethod);
case "get_components":
string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string
if (getCompTarget == null)
return new ErrorResponse(
"'target' parameter required for get_components."
);
// Paging + safety: return metadata by default; deep fields are opt-in.
int CoerceInt(JToken t, int @default)
{
if (t == null || t.Type == JTokenType.Null) return @default;
try
{
if (t.Type == JTokenType.Integer) return t.Value<int>();
var s = t.ToString().Trim();
if (s.Length == 0) return @default;
if (int.TryParse(s, out var i)) return i;
if (double.TryParse(s, out var d)) return (int)d;
}
catch { }
return @default;
}
bool CoerceBool(JToken t, bool @default)
{
if (t == null || t.Type == JTokenType.Null) return @default;
try
{
if (t.Type == JTokenType.Boolean) return t.Value<bool>();
var s = t.ToString().Trim();
if (s.Length == 0) return @default;
if (bool.TryParse(s, out var b)) return b;
if (s == "1") return true;
if (s == "0") return false;
}
catch { }
return @default;
}
int pageSize = CoerceInt(@params["pageSize"] ?? @params["page_size"], 25);
int cursor = CoerceInt(@params["cursor"], 0);
int maxComponents = CoerceInt(@params["maxComponents"] ?? @params["max_components"], 50);
bool includeProperties = CoerceBool(@params["includeProperties"] ?? @params["include_properties"], false);
// Pass the includeNonPublicSerialized flag through, but only used if includeProperties is true.
return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized, pageSize, cursor, maxComponents, includeProperties);
case "get_component":
string getSingleCompTarget = targetToken?.ToString();
if (getSingleCompTarget == null)
return new ErrorResponse(
"'target' parameter required for get_component."
);
string componentName = @params["componentName"]?.ToString();
if (string.IsNullOrEmpty(componentName))
return new ErrorResponse(
"'componentName' parameter required for get_component."
);
return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized);
case "add_component":
return AddComponentToTarget(@params, targetToken, searchMethod);
case "remove_component":
return RemoveComponentFromTarget(@params, targetToken, searchMethod);
case "set_component_property":
return SetComponentPropertyOnTarget(@params, targetToken, searchMethod);
case "duplicate":
return DuplicateGameObject(@params, targetToken, searchMethod);
case "move_relative":
@ -1202,381 +1135,6 @@ namespace MCPForUnity.Editor.Tools
}
}
private static object FindGameObjects(
JObject @params,
JToken targetToken,
string searchMethod
)
{
bool findAll = @params["findAll"]?.ToObject<bool>() ?? false;
List<GameObject> foundObjects = FindObjectsInternal(
targetToken,
searchMethod,
findAll,
@params
);
if (foundObjects.Count == 0)
{
return new SuccessResponse("No matching GameObjects found.", new List<object>());
}
// Use the new serializer helper
//var results = foundObjects.Select(go => GetGameObjectData(go)).ToList();
var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList();
return new SuccessResponse($"Found {results.Count} GameObject(s).", results);
}
private static object GetComponentsFromTarget(
string target,
string searchMethod,
bool includeNonPublicSerialized = true,
int pageSize = 25,
int cursor = 0,
int maxComponents = 50,
bool includeProperties = false
)
{
GameObject targetGo = FindObjectInternal(target, searchMethod);
if (targetGo == null)
{
return new ErrorResponse(
$"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'."
);
}
try
{
int resolvedPageSize = Mathf.Clamp(pageSize, 1, 200);
int resolvedCursor = Mathf.Max(0, cursor);
int resolvedMaxComponents = Mathf.Clamp(maxComponents, 1, 500);
int effectiveTake = Mathf.Min(resolvedPageSize, resolvedMaxComponents);
// Build a stable list once; pagination is applied to this list.
var all = targetGo.GetComponents<Component>();
var components = new List<Component>(all?.Length ?? 0);
if (all != null)
{
for (int i = 0; i < all.Length; i++)
{
if (all[i] != null) components.Add(all[i]);
}
}
int total = components.Count;
if (resolvedCursor > total) resolvedCursor = total;
int end = Mathf.Min(total, resolvedCursor + effectiveTake);
var items = new List<object>(Mathf.Max(0, end - resolvedCursor));
// If caller explicitly asked for properties, we still enforce a conservative payload budget.
const int maxPayloadChars = 250_000; // ~250KB assuming 1 char ~= 1 byte ASCII-ish
int payloadChars = 0;
for (int i = resolvedCursor; i < end; i++)
{
var c = components[i];
if (c == null) continue;
if (!includeProperties)
{
items.Add(BuildComponentMetadata(c));
continue;
}
try
{
var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized);
if (data == null) continue;
// Rough cap to keep responses from exploding even when includeProperties is true.
var token = JToken.FromObject(data);
int addChars = token.ToString(Newtonsoft.Json.Formatting.None).Length;
if (payloadChars + addChars > maxPayloadChars && items.Count > 0)
{
// Stop early; next_cursor will allow fetching more (or caller can use get_component).
end = i;
break;
}
payloadChars += addChars;
items.Add(token);
}
catch (Exception ex)
{
// Avoid throwing; mark the component as failed.
items.Add(
new JObject(
new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"),
new JProperty("instanceID", c.GetInstanceID()),
new JProperty("error", ex.Message)
)
);
}
}
bool truncated = end < total;
string nextCursor = truncated ? end.ToString() : null;
var payload = new
{
cursor = resolvedCursor,
pageSize = effectiveTake,
next_cursor = nextCursor,
truncated = truncated,
total = total,
includeProperties = includeProperties,
items = items,
};
return new SuccessResponse(
$"Retrieved components page from '{targetGo.name}'.",
payload
);
}
catch (Exception e)
{
return new ErrorResponse(
$"Error getting components from '{targetGo.name}': {e.Message}"
);
}
}
private static object BuildComponentMetadata(Component c)
{
if (c == null) return null;
var d = new Dictionary<string, object>
{
{ "typeName", c.GetType().FullName },
{ "instanceID", c.GetInstanceID() },
};
if (c is Behaviour b)
{
d["enabled"] = b.enabled;
}
return d;
}
private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true)
{
GameObject targetGo = FindObjectInternal(target, searchMethod);
if (targetGo == null)
{
return new ErrorResponse(
$"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'."
);
}
try
{
// Try to find the component by name
Component targetComponent = targetGo.GetComponent(componentName);
// If not found directly, try to find by type name (handle namespaces)
if (targetComponent == null)
{
Component[] allComponents = targetGo.GetComponents<Component>();
foreach (Component comp in allComponents)
{
if (comp != null)
{
string typeName = comp.GetType().Name;
string fullTypeName = comp.GetType().FullName;
if (typeName == componentName || fullTypeName == componentName)
{
targetComponent = comp;
break;
}
}
}
}
if (targetComponent == null)
{
return new ErrorResponse(
$"Component '{componentName}' not found on GameObject '{targetGo.name}'."
);
}
var componentData = Helpers.GameObjectSerializer.GetComponentData(targetComponent, includeNonPublicSerialized);
if (componentData == null)
{
return new ErrorResponse(
$"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'."
);
}
return new SuccessResponse(
$"Retrieved component '{componentName}' from '{targetGo.name}'.",
componentData
);
}
catch (Exception e)
{
return new ErrorResponse(
$"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}"
);
}
}
private static object AddComponentToTarget(
JObject @params,
JToken targetToken,
string searchMethod
)
{
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse(
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
);
}
string typeName = null;
JObject properties = null;
// Allow adding component specified directly or via componentsToAdd array (take first)
if (@params["componentName"] != null)
{
typeName = @params["componentName"]?.ToString();
properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name
}
else if (
@params["componentsToAdd"] is JArray componentsToAddArray
&& componentsToAddArray.Count > 0
)
{
var compToken = componentsToAddArray.First;
if (compToken.Type == JTokenType.String)
{
typeName = compToken.ToString();
// Check for properties in top-level componentProperties parameter
properties = @params["componentProperties"]?[typeName] as JObject;
}
else if (compToken is JObject compObj)
{
typeName = compObj["typeName"]?.ToString();
properties = compObj["properties"] as JObject;
}
}
if (string.IsNullOrEmpty(typeName))
{
return new ErrorResponse(
"Component type name ('componentName' or first element in 'componentsToAdd') is required."
);
}
var addResult = AddComponentInternal(targetGo, typeName, properties);
if (addResult != null)
return addResult; // Return error
EditorUtility.SetDirty(targetGo);
// Use the new serializer helper
return new SuccessResponse(
$"Component '{typeName}' added to '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
); // Return updated GO data
}
private static object RemoveComponentFromTarget(
JObject @params,
JToken targetToken,
string searchMethod
)
{
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse(
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
);
}
string typeName = null;
// Allow removing component specified directly or via componentsToRemove array (take first)
if (@params["componentName"] != null)
{
typeName = @params["componentName"]?.ToString();
}
else if (
@params["componentsToRemove"] is JArray componentsToRemoveArray
&& componentsToRemoveArray.Count > 0
)
{
typeName = componentsToRemoveArray.First?.ToString();
}
if (string.IsNullOrEmpty(typeName))
{
return new ErrorResponse(
"Component type name ('componentName' or first element in 'componentsToRemove') is required."
);
}
var removeResult = RemoveComponentInternal(targetGo, typeName);
if (removeResult != null)
return removeResult; // Return error
EditorUtility.SetDirty(targetGo);
// Use the new serializer helper
return new SuccessResponse(
$"Component '{typeName}' removed from '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
private static object SetComponentPropertyOnTarget(
JObject @params,
JToken targetToken,
string searchMethod
)
{
GameObject targetGo = FindObjectInternal(targetToken, searchMethod);
if (targetGo == null)
{
return new ErrorResponse(
$"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."
);
}
string compName = @params["componentName"]?.ToString();
JObject propertiesToSet = null;
if (!string.IsNullOrEmpty(compName))
{
// Properties might be directly under componentProperties or nested under the component name
if (@params["componentProperties"] is JObject compProps)
{
propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure
}
}
else
{
return new ErrorResponse("'componentName' parameter is required.");
}
if (propertiesToSet == null || !propertiesToSet.HasValues)
{
return new ErrorResponse(
"'componentProperties' dictionary for the specified component is required and cannot be empty."
);
}
var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet);
if (setResult != null)
return setResult; // Return error
EditorUtility.SetDirty(targetGo);
// Use the new serializer helper
return new SuccessResponse(
$"Properties set for component '{compName}' on '{targetGo.name}'.",
Helpers.GameObjectSerializer.GetGameObjectData(targetGo)
);
}
// --- Internal Helpers ---
/// <summary>

View File

@ -396,7 +396,7 @@ namespace MCPForUnity.Editor.Tools
Camera cam = Camera.main;
if (cam == null)
{
var cams = UnityEngine.Object.FindObjectsOfType<Camera>();
var cams = UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
cam = cams.FirstOrDefault();
}
@ -629,6 +629,24 @@ namespace MCPForUnity.Editor.Tools
try { childCount = go.transform != null ? go.transform.childCount : 0; } catch { }
bool childrenTruncated = childCount > 0; // We do not inline children in summary mode.
// Get component type names (lightweight - no full serialization)
var componentTypes = new List<string>();
try
{
var components = go.GetComponents<Component>();
if (components != null)
{
foreach (var c in components)
{
if (c != null)
{
componentTypes.Add(c.GetType().Name);
}
}
}
}
catch { }
var d = new Dictionary<string, object>
{
{ "name", go.name },
@ -643,6 +661,7 @@ namespace MCPForUnity.Editor.Tools
{ "childrenTruncated", childrenTruncated },
{ "childrenCursor", childCount > 0 ? "0" : null },
{ "childrenPageSizeDefault", maxChildrenPerNode },
{ "componentTypes", componentTypes }, // NEW: Lightweight component type list
};
if (includeTransform && go.transform != null)

View File

@ -41,28 +41,32 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
Your LLM can use functions like:
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
* `manage_editor`: Controls and queries the editor's state and settings.
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
* `manage_material`: Manages materials: create, set properties, colors, assign to renderers, and query material info.
* `manage_prefabs`: Performs prefab operations (create, modify, delete, etc.).
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
* `manage_script`: Compatibility router for legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits` for edits.
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets using Unity SerializedObject property paths.
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `read_console`: Gets messages from or clears the console.
* `run_tests_async`: Starts tests asynchronously and returns a job_id for polling (preferred).
* `manage_asset`: Performs asset operations (import, create, modify, delete, search, etc.).
* `manage_editor`: Controls editor state (play mode, active tool, tags, layers).
* `manage_gameobject`: Manages GameObjects (create, modify, delete, find, duplicate, move).
* `manage_components`: Manages components on GameObjects (add, remove, set properties).
* `manage_material`: Manages materials (create, set properties, colors, assign to renderers).
* `manage_prefabs`: Performs prefab operations (open/close stage, save, create from GameObject).
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, screenshot).
* `manage_script`: Legacy script operations (create, read, delete). Prefer `apply_text_edits` or `script_apply_edits`.
* `manage_scriptable_object`: Creates and modifies ScriptableObject assets.
* `manage_shader`: Shader CRUD operations (create, read, modify, delete).
* `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).
* `read_console`: Gets messages from or clears the Unity console.
* `refresh_unity`: Request asset database refresh and optional compilation.
* `run_tests_async`: Starts tests asynchronously, returns job_id for polling (preferred).
* `get_test_job`: Polls an async test job for progress and results.
* `run_tests`: Runs tests synchronously (blocks until complete; prefer `run_tests_async` for long suites).
* `execute_custom_tool`: Execute a project-scoped custom tool registered by Unity.
* `run_tests`: Runs tests synchronously (blocks until complete).
* `execute_custom_tool`: Execute project-scoped custom tools registered by Unity.
* `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project").
* `set_active_instance`: Routes subsequent tool calls to a specific Unity instance (when multiple are running). Requires the exact `Name@hash` from `unity_instances`.
* `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches.
* `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`.
* `apply_text_edits`: Precise text edits with line/column ranges and precondition hashes.
* `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries.
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes.
* `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues.
* `create_script`: Create a new C# script at the given project path.
* `delete_script`: Delete a C# script by URI or Assets-relative path.
* `get_sha`: Get SHA256 and basic metadata for a Unity C# script without returning file contents.
* `get_sha`: Get SHA256 and metadata for a Unity C# script without returning contents.
</details>
@ -72,17 +76,22 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav
Your LLM can retrieve the following resources:
* `custom_tools`: Lists custom tools available for the active Unity project.
* `unity_instances`: Lists all running Unity Editor instances with their details (name, path, port, status).
* `menu_items`: Retrieves all available menu items in the Unity Editor.
* `tests`: Retrieves all available tests in the Unity Editor. Can select tests of a specific type (e.g., "EditMode", "PlayMode").
* `unity_instances`: Lists all running Unity Editor instances with details (name, path, hash, status, session).
* `menu_items`: All available menu items in the Unity Editor.
* `tests`: All available tests (EditMode, PlayMode) in the Unity Editor.
* `gameobject_api`: Documentation for GameObject resources and how to use `find_gameobjects` tool.
* `unity://scene/gameobject/{instanceID}`: Read-only access to GameObject data (name, tag, transform, components, children).
* `unity://scene/gameobject/{instanceID}/components`: Read-only access to all components on a GameObject with full property serialization.
* `unity://scene/gameobject/{instanceID}/component/{componentName}`: Read-only access to a specific component's properties.
* `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.
* `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode.
* `editor_selection`: Detailed information about currently selected objects in the editor.
* `editor_state`: Current editor runtime state including play mode, compilation status, active scene, and selection summary.
* `editor_windows`: All currently open editor windows with their titles, types, positions, and focus state.
* `project_info`: Static project information including root path, Unity version, and platform.
* `project_layers`: All layers defined in the project's TagManager with their indices (0-31).
* `project_tags`: All tags defined in the project's TagManager.
* `editor_state`: Current editor runtime state (play mode, compilation, active scene, selection).
* `editor_state_v2`: Canonical editor readiness snapshot with advice and staleness info.
* `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_layers`: All layers defined in TagManager with their indices (0-31).
* `project_tags`: All tags defined in TagManager.
</details>
---
@ -341,6 +350,20 @@ Replace `YOUR_USERNAME` and `AppSupport` path segments as needed for your platfo
Example Prompt: `Create a 3D player controller`, `Create a tic-tac-toe game in 3D`, `Create a cool shader and apply to a cube`.
### 💡 Performance Tip: Use `batch_execute`
When performing multiple operations, use the `batch_execute` tool instead of calling tools one-by-one. This dramatically reduces latency and token costs:
```
❌ Slow: Create 5 cubes → 5 separate manage_gameobject calls
✅ Fast: Create 5 cubes → 1 batch_execute call with 5 commands
❌ Slow: Find objects, then add components to each → N+M separate calls
✅ Fast: Find objects, then add components → 1 find + 1 batch with M component adds
```
**Example prompt:** "Create 10 colored cubes in a grid using batch_execute"
### Working with Multiple Unity Instances
MCP for Unity supports multiple Unity Editor instances simultaneously. Each instance is isolated per MCP client session.

View File

@ -0,0 +1,244 @@
"""
MCP Resources for reading GameObject data from Unity scenes.
These resources provide read-only access to:
- Single GameObject data (unity://scene/gameobject/{id})
- All components on a GameObject (unity://scene/gameobject/{id}/components)
- Single component on a GameObject (unity://scene/gameobject/{id}/component/{name})
"""
from typing import Any
from pydantic import BaseModel
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
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
def _normalize_response(response: dict | Any) -> MCPResponse:
"""Normalize Unity transport response to MCPResponse."""
if isinstance(response, dict):
return MCPResponse(**response)
return response
def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | None]:
"""
Validate and convert instance_id string to int.
Returns (id_int, None) on success or (None, error_response) on failure.
"""
try:
return int(instance_id), None
except ValueError:
return None, MCPResponse(success=False, error=f"Invalid instance ID: {instance_id}")
# =============================================================================
# Static Helper Resource (shows in UI)
# =============================================================================
@mcp_for_unity_resource(
uri="unity://scene/gameobject-api",
name="gameobject_api",
description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below."
)
async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse:
"""
Returns documentation for the GameObject resource API.
This is a helper resource that explains how to use the parameterized
GameObject resources which require an instance ID.
"""
docs = {
"overview": "GameObject resources provide read-only access to Unity scene objects.",
"workflow": [
"1. Use find_gameobjects tool to search for GameObjects and get instance IDs",
"2. Use the instance ID to access detailed data via resources below"
],
"best_practices": [
"⚡ Use batch_execute for multiple operations: Combine create/modify/component calls into one batch_execute call for 10-100x better performance",
"Example: Creating 5 cubes → 1 batch_execute with 5 manage_gameobject commands instead of 5 separate calls",
"Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands"
],
"resources": {
"unity://scene/gameobject/{instance_id}": {
"description": "Get basic GameObject data (name, tag, layer, transform, component type list)",
"example": "unity://scene/gameobject/-81840",
"returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"]
},
"unity://scene/gameobject/{instance_id}/components": {
"description": "Get all components with full property serialization (paginated)",
"example": "unity://scene/gameobject/-81840/components",
"parameters": {
"page_size": "Number of components per page (default: 25)",
"cursor": "Pagination offset (default: 0)",
"include_properties": "Include full property data (default: true)"
}
},
"unity://scene/gameobject/{instance_id}/component/{component_name}": {
"description": "Get a single component by type name with full properties",
"example": "unity://scene/gameobject/-81840/component/Camera",
"note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')"
}
},
"related_tools": {
"find_gameobjects": "Search for GameObjects by name, tag, layer, component, or path",
"manage_components": "Add, remove, or modify components on GameObjects",
"manage_gameobject": "Create, modify, or delete GameObjects"
}
}
return MCPResponse(success=True, data=docs)
class TransformData(BaseModel):
"""Transform component data."""
position: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
localPosition: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
rotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
localRotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
scale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
lossyScale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
class GameObjectData(BaseModel):
"""Data for a single GameObject (without full component serialization)."""
instanceID: int
name: str
tag: str = "Untagged"
layer: int = 0
layerName: str = "Default"
active: bool = True
activeInHierarchy: bool = True
isStatic: bool = False
transform: TransformData = TransformData()
parent: int | None = None
children: list[int] = []
componentTypes: list[str] = []
path: str = ""
# TODO: Use these typed response classes for better type safety once
# we update the endpoints to validate response structure more strictly.
class GameObjectResponse(MCPResponse):
"""Response containing GameObject data."""
data: GameObjectData | None = None
@mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}",
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)."
)
async def get_gameobject(ctx: Context, instance_id: str) -> MCPResponse:
"""Get GameObject data by instance ID."""
unity_instance = get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject",
{"instanceID": id_int}
)
return _normalize_response(response)
class ComponentsData(BaseModel):
"""Data for components on a GameObject."""
gameObjectID: int
gameObjectName: str
components: list[Any] = []
cursor: int = 0
pageSize: int = 25
nextCursor: int | None = None
totalCount: int = 0
hasMore: bool = False
includeProperties: bool = True
class ComponentsResponse(MCPResponse):
"""Response containing components data."""
data: ComponentsData | None = None
@mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}/components",
name="gameobject_components",
description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters."
)
async def get_gameobject_components(
ctx: Context,
instance_id: str,
page_size: int = 25,
cursor: int = 0,
include_properties: bool = True
) -> MCPResponse:
"""Get all components on a GameObject."""
unity_instance = get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject_components",
{
"instanceID": id_int,
"pageSize": page_size,
"cursor": cursor,
"includeProperties": include_properties
}
)
return _normalize_response(response)
class SingleComponentData(BaseModel):
"""Data for a single component."""
gameObjectID: int
gameObjectName: str
component: Any = None
class SingleComponentResponse(MCPResponse):
"""Response containing single component data."""
data: SingleComponentData | None = None
@mcp_for_unity_resource(
uri="unity://scene/gameobject/{instance_id}/component/{component_name}",
name="gameobject_component",
description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties."
)
async def get_gameobject_component(
ctx: Context,
instance_id: str,
component_name: str
) -> MCPResponse:
"""Get a specific component on a GameObject."""
unity_instance = get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject_component",
{
"instanceID": id_int,
"componentName": component_name
}
)
return _normalize_response(response)

View File

@ -17,11 +17,10 @@ MAX_COMMANDS_PER_BATCH = 25
@mcp_for_unity_tool(
name="batch_execute",
description=(
"Runs a list of MCP tool calls as one batch. Use it to send a full sequence of commands, "
"inspect the results, then submit the next batch for the following step. "
"Note: Safety characteristics depend on the tools contained in the batch—batches with only "
"read-only tools (e.g., find, get_info) are safe, while batches containing create/modify/delete "
"operations may be destructive."
"Executes multiple MCP commands in a single batch for dramatically better performance. "
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
"or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
"sequential tool calls. Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
),
annotations=ToolAnnotations(
title="Batch Execute",

View File

@ -0,0 +1,85 @@
"""
Tool for searching GameObjects in Unity scenes.
Returns only instance IDs with pagination support for efficient searches.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool, coerce_int
from services.tools.preflight import preflight
@mcp_for_unity_tool(
description="Search for GameObjects in the scene. Returns instance IDs only (paginated) for efficient lookups. Use unity://scene/gameobject/{id} resource to get full GameObject data."
)
async def find_gameobjects(
ctx: Context,
search_term: Annotated[str, "The value to search for (name, tag, layer name, component type, or path)"],
search_method: Annotated[
Literal["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"],
"How to search for GameObjects"
] = "by_name",
include_inactive: Annotated[bool | str, "Include inactive GameObjects in search"] | None = None,
page_size: Annotated[int | str, "Number of results per page (default: 50, max: 500)"] | None = None,
cursor: Annotated[int | str, "Pagination cursor (offset for next page)"] | None = None,
) -> dict[str, Any]:
"""
Search for GameObjects and return their instance IDs.
This is a focused search tool optimized for finding GameObjects efficiently.
It returns only instance IDs to minimize payload size.
For detailed GameObject information, use the returned IDs with:
- unity://scene/gameobject/{id} - Get full GameObject data
- unity://scene/gameobject/{id}/components - Get all components
- unity://scene/gameobject/{id}/component/{name} - Get specific component
"""
unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
if not search_term:
return {
"success": False,
"message": "Missing required parameter 'search_term'. Specify what to search for."
}
# Coerce parameters
include_inactive = coerce_bool(include_inactive, default=False)
page_size = coerce_int(page_size, default=50)
cursor = coerce_int(cursor, default=0)
try:
params = {
"searchMethod": search_method,
"searchTerm": search_term,
"includeInactive": include_inactive,
"pageSize": page_size,
"cursor": cursor,
}
params = {k: v for k, v in params.items() if v is not None}
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"find_gameobjects",
params,
)
if isinstance(response, dict) and response.get("success"):
return {
"success": True,
"message": response.get("message", "Search completed."),
"data": response.get("data")
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Error searching GameObjects: {e!s}"}

View File

@ -17,6 +17,41 @@ from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.preflight import preflight
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
"""
Robustly normalize properties parameter to a dict.
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
"""
if value is None:
return {}, None
# Already a dict - return as-is
if isinstance(value, dict):
return value, None
# Try parsing as string
if isinstance(value, str):
# Check for obviously invalid values from serialization bugs
if value in ("[object Object]", "undefined", "null", ""):
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
# Try JSON parsing first
parsed = parse_json_payload(value)
if isinstance(parsed, dict):
return parsed, None
# Fallback to ast.literal_eval for Python dict literals
try:
parsed = ast.literal_eval(value)
if isinstance(parsed, dict):
return parsed, None
return None, f"properties must evaluate to a dict, got {type(parsed).__name__}"
except (ValueError, SyntaxError) as e:
return None, f"Failed to parse properties: {e}"
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
@mcp_for_unity_tool(
description=(
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
@ -34,8 +69,8 @@ async def manage_asset(
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
asset_type: Annotated[str,
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
properties: Annotated[dict[str, Any] | str,
"Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
properties: Annotated[dict[str, Any],
"Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
destination: Annotated[str,
"Target path for 'duplicate'/'move'."] | None = None,
generate_preview: Annotated[bool,
@ -60,46 +95,10 @@ async def manage_asset(
if gate is not None:
return gate.model_dump()
def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
try:
parsed = json.loads(raw)
if not isinstance(parsed, dict):
return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
return parsed, "JSON"
except json.JSONDecodeError as json_err:
try:
parsed = ast.literal_eval(raw)
if not isinstance(parsed, dict):
return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
return parsed, "Python literal"
except (ValueError, SyntaxError) as literal_err:
return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
if raw is None:
return {}, None
if isinstance(raw, dict):
await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
return raw, None
if isinstance(raw, str):
await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
# Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
parsed = parse_json_payload(raw)
if isinstance(parsed, dict):
await ctx.info("manage_asset: coerced properties using centralized parser")
return parsed, None
# Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
parsed, source = _parse_properties_string(raw)
if parsed is None:
return None, source
await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
return parsed, None
return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
properties, parse_error = await _normalize_properties(properties)
# --- Normalize properties using robust module-level helper ---
properties, parse_error = _normalize_properties(properties)
if parse_error:
await ctx.error(parse_error)
await ctx.error(f"manage_asset: {parse_error}")
return {"success": False, "message": parse_error}
page_size = coerce_int(page_size)

View File

@ -0,0 +1,157 @@
"""
Tool for managing components on GameObjects in Unity.
Supports add, remove, and set_property operations.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import parse_json_payload
from services.tools.preflight import preflight
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
"""
Robustly normalize properties parameter to a dict.
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
"""
if value is None:
return None, None
# Already a dict - return as-is
if isinstance(value, dict):
return value, None
# Try parsing as string
if isinstance(value, str):
# Check for obviously invalid values from serialization bugs
if value in ("[object Object]", "undefined", "null", ""):
return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"propertyName\": value}}"
parsed = parse_json_payload(value)
if isinstance(parsed, dict):
return parsed, None
return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
@mcp_for_unity_tool(
description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the unity://scene/gameobject/{id}/components resource."
)
async def manage_components(
ctx: Context,
action: Annotated[
Literal["add", "remove", "set_property"],
"Action to perform: add (add component), remove (remove component), set_property (set component property)"
],
target: Annotated[
str | int,
"Target GameObject - instance ID (preferred) or name/path"
],
component_type: Annotated[
str,
"Component type name (e.g., 'Rigidbody', 'BoxCollider', 'MyScript')"
],
search_method: Annotated[
Literal["by_id", "by_name", "by_path"],
"How to find the target GameObject"
] | None = None,
# For set_property action - single property
property: Annotated[str, "Property name to set (for set_property action)"] | None = None,
value: Annotated[Any, "Value to set (for set_property action)"] | None = None,
# For add/set_property - multiple properties
properties: Annotated[
dict[str, Any],
"Dictionary of property names to values. Example: {\"mass\": 5.0, \"useGravity\": false}"
] | None = None,
) -> dict[str, Any]:
"""
Manage components on GameObjects.
Actions:
- add: Add a new component to a GameObject
- remove: Remove a component from a GameObject
- set_property: Set one or more properties on a component
Examples:
- Add Rigidbody: action="add", target="Player", component_type="Rigidbody"
- Remove BoxCollider: action="remove", target=-12345, component_type="BoxCollider"
- Set single property: action="set_property", target="Enemy", component_type="Rigidbody", property="mass", value=5.0
- Set multiple properties: action="set_property", target="Enemy", component_type="Rigidbody", properties={"mass": 5.0, "useGravity": false}
"""
unity_instance = get_unity_instance_from_context(ctx)
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
if not action:
return {
"success": False,
"message": "Missing required parameter 'action'. Valid actions: add, remove, set_property"
}
if not target:
return {
"success": False,
"message": "Missing required parameter 'target'. Specify GameObject instance ID or name."
}
if not component_type:
return {
"success": False,
"message": "Missing required parameter 'component_type'. Specify the component type name."
}
# --- Normalize properties with detailed error handling ---
properties, props_error = _normalize_properties(properties)
if props_error:
return {"success": False, "message": props_error}
# --- Validate value parameter for serialization issues ---
if value is not None and isinstance(value, str) and value in ("[object Object]", "undefined"):
return {"success": False, "message": f"value received invalid input: '{value}'. Expected an actual value."}
try:
params = {
"action": action,
"target": target,
"componentType": component_type,
}
if search_method:
params["searchMethod"] = search_method
if action == "set_property":
if property and value is not None:
params["property"] = property
params["value"] = value
if properties:
params["properties"] = properties
if action == "add" and properties:
params["properties"] = properties
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_components",
params,
)
if isinstance(response, dict) and response.get("success"):
return {
"success": True,
"message": response.get("message", f"Component {action} successful."),
"data": response.get("data")
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Error managing component: {e!s}"}

View File

@ -1,6 +1,6 @@
import json
import math
from typing import Annotated, Any, Literal, Union
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
@ -13,6 +13,75 @@ from services.tools.utils import coerce_bool, parse_json_payload, coerce_int
from services.tools.preflight import preflight
def _normalize_vector(value: Any, default: Any = None) -> list[float] | None:
"""
Robustly normalize a vector parameter to [x, y, z] format.
Handles: list, tuple, JSON string, comma-separated string.
Returns None if parsing fails.
"""
if value is None:
return default
# If already a list/tuple with 3 elements, convert to floats
if isinstance(value, (list, tuple)) and len(value) == 3:
try:
vec = [float(value[0]), float(value[1]), float(value[2])]
return vec if all(math.isfinite(n) for n in vec) else default
except (ValueError, TypeError):
return default
# Try parsing as JSON string
if isinstance(value, str):
parsed = parse_json_payload(value)
if isinstance(parsed, list) and len(parsed) == 3:
try:
vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
return vec if all(math.isfinite(n) for n in vec) else default
except (ValueError, TypeError):
pass
# Handle legacy comma-separated strings "1,2,3" or "[1,2,3]"
s = value.strip()
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
if len(parts) == 3:
try:
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
return vec if all(math.isfinite(n) for n in vec) else default
except (ValueError, TypeError):
pass
return default
def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any]] | None, str | None]:
"""
Robustly normalize component_properties to a dict.
Returns (parsed_dict, error_message). If error_message is set, parsed_dict is None.
"""
if value is None:
return None, None
# Already a dict - validate structure
if isinstance(value, dict):
return value, None
# Try parsing as JSON string
if isinstance(value, str):
# Check for obviously invalid values
if value in ("[object Object]", "undefined", "null", ""):
return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}"
parsed = parse_json_payload(value)
if isinstance(parsed, dict):
return parsed, None
return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}"
@mcp_for_unity_tool(
description="Performs CRUD operations on GameObjects and components. Read-only actions: find, get_components, get_component. Modifying actions: create, modify, delete, add_component, remove_component, set_component_property, duplicate, move_relative.",
annotations=ToolAnnotations(
@ -33,12 +102,12 @@ async def manage_gameobject(
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
parent: Annotated[str,
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
position: Annotated[Union[list[float], str],
"Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
rotation: Annotated[Union[list[float], str],
"Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
scale: Annotated[Union[list[float], str],
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
position: Annotated[list[float],
"Position as [x, y, z] array"] | None = None,
rotation: Annotated[list[float],
"Rotation as [x, y, z] euler angles array"] | None = None,
scale: Annotated[list[float],
"Scale as [x, y, z] array"] | None = None,
components_to_add: Annotated[list[str],
"List of component names to add"] | None = None,
primitive_type: Annotated[str,
@ -54,7 +123,7 @@ async def manage_gameobject(
layer: Annotated[str, "Layer name"] | None = None,
components_to_remove: Annotated[list[str],
"List of component names to remove"] | None = None,
component_properties: Annotated[Union[dict[str, dict[str, Any]], str],
component_properties: Annotated[dict[str, dict[str, Any]],
"""Dictionary of component names to their properties to set. For example:
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
@ -83,8 +152,8 @@ async def manage_gameobject(
# --- Parameters for 'duplicate' ---
new_name: Annotated[str,
"New name for the duplicated object (default: SourceName_Copy)"] | None = None,
offset: Annotated[Union[list[float], str],
"Offset from original/reference position - [x,y,z] or string '[x,y,z]'"] | None = None,
offset: Annotated[list[float],
"Offset from original/reference position as [x, y, z] array"] | None = None,
# --- Parameters for 'move_relative' ---
reference_object: Annotated[str,
"Reference object for relative movement (required for move_relative)"] | None = None,
@ -109,41 +178,13 @@ async def manage_gameobject(
"message": "Missing required parameter 'action'. Valid actions: create, modify, delete, find, add_component, remove_component, set_component_property, get_components, get_component, duplicate, move_relative"
}
# Coercers to tolerate stringified booleans and vectors
def _coerce_vec(value, default=None):
if value is None:
return default
# --- Normalize vector parameters using robust helper ---
position = _normalize_vector(position)
rotation = _normalize_vector(rotation)
scale = _normalize_vector(scale)
offset = _normalize_vector(offset)
# First try to parse if it's a string
val = parse_json_payload(value)
def _to_vec3(parts):
try:
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
except (ValueError, TypeError):
return default
return vec if all(math.isfinite(n) for n in vec) else default
if isinstance(val, list) and len(val) == 3:
return _to_vec3(val)
# Handle legacy comma-separated strings "1,2,3" that parse_json_payload doesn't handle (since they aren't JSON arrays)
if isinstance(val, str):
s = val.strip()
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
# support "x,y,z" and "x y z"
parts = [p.strip()
for p in (s.split(",") if "," in s else s.split())]
if len(parts) == 3:
return _to_vec3(parts)
return default
position = _coerce_vec(position, default=position)
rotation = _coerce_vec(rotation, default=rotation)
scale = _coerce_vec(scale, default=scale)
offset = _coerce_vec(offset, default=offset)
# --- Normalize boolean parameters ---
save_as_prefab = coerce_bool(save_as_prefab)
set_active = coerce_bool(set_active)
find_all = coerce_bool(find_all)
@ -152,17 +193,16 @@ async def manage_gameobject(
includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized)
include_properties = coerce_bool(include_properties)
world_space = coerce_bool(world_space, default=True)
# If coercion fails, omit these fields (None) rather than preserving invalid input.
# --- Normalize integer parameters ---
page_size = coerce_int(page_size, default=None)
cursor = coerce_int(cursor, default=None)
max_components = coerce_int(max_components, default=None)
# Coerce 'component_properties' from JSON string to dict for client compatibility
component_properties = parse_json_payload(component_properties)
# Ensure final type is a dict (object) if provided
if component_properties is not None and not isinstance(component_properties, dict):
return {"success": False, "message": "component_properties must be a JSON object (dict)."}
# --- Normalize component_properties with detailed error handling ---
component_properties, comp_props_error = _normalize_component_properties(component_properties)
if comp_props_error:
return {"success": False, "message": comp_props_error}
try:
# Map tag to search_term when search_method is by_tag for backward compatibility

View File

@ -2,18 +2,73 @@
Defines the manage_material tool for interacting with Unity materials.
"""
import json
from typing import Annotated, Any, Literal, Union
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload
from services.tools.utils import parse_json_payload, coerce_int
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]:
"""
Normalize color parameter to [r, g, b] or [r, g, b, a] format.
Returns (parsed_color, error_message).
"""
if value is None:
return None, None
# Already a list - validate
if isinstance(value, (list, tuple)):
if len(value) in (3, 4):
try:
return [float(c) for c in value], None
except (ValueError, TypeError):
return None, f"color values must be numbers, got {value}"
return None, f"color must have 3 or 4 components, got {len(value)}"
# Try parsing as string
if isinstance(value, str):
if value in ("[object Object]", "undefined", "null", ""):
return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]"
parsed = parse_json_payload(value)
if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
try:
return [float(c) for c in parsed], None
except (ValueError, TypeError):
return None, f"color values must be numbers, got {parsed}"
return None, f"Failed to parse color string: {value}"
return None, f"color must be a list or JSON string, got {type(value).__name__}"
def _normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
"""
Normalize properties parameter to a dict.
"""
if value is None:
return None, None
if isinstance(value, dict):
return value, None
if isinstance(value, str):
if value in ("[object Object]", "undefined", "null", ""):
return None, f"properties received invalid value: '{value}'. Expected a JSON object"
parsed = parse_json_payload(value)
if isinstance(parsed, dict):
return parsed, None
return None, f"properties must parse to a dict, got {type(parsed).__name__}"
return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
@mcp_for_unity_tool(
description="Manages Unity materials (set properties, colors, shaders, etc). Read-only actions: ping, get_material_info. Modifying actions: create, set_material_shader_property, set_material_color, assign_material_to_renderer, set_renderer_color.",
annotations=ToolAnnotations(
@ -39,38 +94,40 @@ async def manage_material(
# create
shader: Annotated[str, "Shader name (default: Standard)"] | None = None,
properties: Annotated[Union[dict[str, Any], str], "Initial properties to set {name: value}."] | None = None,
properties: Annotated[dict[str, Any], "Initial properties to set as {name: value} dict."] | None = None,
# set_material_shader_property
value: Annotated[Union[list, float, int, str, bool, None], "Value to set (color array, float, texture path/instruction)"] | None = None,
value: Annotated[list | float | int | str | bool | None, "Value to set (color array, float, texture path/instruction)"] | None = None,
# set_material_color / set_renderer_color
color: Annotated[Union[list[float], list[int], str], "Color as [r,g,b] or [r,g,b,a]."] | None = None,
color: Annotated[list[float], "Color as [r, g, b] or [r, g, b, a] array."] | None = None,
# assign_material_to_renderer / set_renderer_color
target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None,
search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None,
slot: Annotated[int | str, "Material slot index"] | None = None,
slot: Annotated[int, "Material slot index (0-based)"] | None = None,
mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None,
) -> dict[str, Any]:
unity_instance = get_unity_instance_from_context(ctx)
# Parse inputs that might be stringified JSON
color = parse_json_payload(color)
properties = parse_json_payload(properties)
value = parse_json_payload(value)
# --- Normalize color with validation ---
color, color_error = _normalize_color(color)
if color_error:
return {"success": False, "message": color_error}
# Coerce slot to int if it's a string
if slot is not None:
if isinstance(slot, str):
try:
slot = int(slot)
except ValueError:
return {
"success": False,
"message": f"Invalid slot value: '{slot}' must be a valid integer"
}
# --- Normalize properties with validation ---
properties, props_error = _normalize_properties(properties)
if props_error:
return {"success": False, "message": props_error}
# --- Normalize value (parse JSON if string) ---
value = parse_json_payload(value)
if isinstance(value, str) and value in ("[object Object]", "undefined"):
return {"success": False, "message": f"value received invalid input: '{value}'"}
# --- Normalize slot to int ---
slot = coerce_int(slot)
# Prepare parameters for the C# handler
params_dict = {

View File

@ -90,38 +90,5 @@ fastmcp_server.middleware = fastmcp_server_middleware
sys.modules.setdefault("fastmcp.server", fastmcp_server)
sys.modules.setdefault("fastmcp.server.middleware", fastmcp_server_middleware)
# Stub minimal starlette modules to avoid optional dependency imports.
starlette = types.ModuleType("starlette")
starlette_endpoints = types.ModuleType("starlette.endpoints")
starlette_websockets = types.ModuleType("starlette.websockets")
starlette_requests = types.ModuleType("starlette.requests")
starlette_responses = types.ModuleType("starlette.responses")
class _DummyWebSocketEndpoint:
pass
class _DummyWebSocket:
pass
class _DummyRequest:
pass
class _DummyJSONResponse:
def __init__(self, *args, **kwargs):
pass
starlette_endpoints.WebSocketEndpoint = _DummyWebSocketEndpoint
starlette_websockets.WebSocket = _DummyWebSocket
starlette_requests.Request = _DummyRequest
starlette_responses.JSONResponse = _DummyJSONResponse
sys.modules.setdefault("starlette", starlette)
sys.modules.setdefault("starlette.endpoints", starlette_endpoints)
sys.modules.setdefault("starlette.websockets", starlette_websockets)
sys.modules.setdefault("starlette.requests", starlette_requests)
sys.modules.setdefault("starlette.responses", starlette_responses)
# Note: starlette is now a proper dependency (via mcp package), so we don't stub it anymore.
# The real starlette package will be imported when needed.

View File

@ -0,0 +1,200 @@
"""
Tests for the find_gameobjects tool.
This tool provides paginated GameObject search, returning instance IDs only.
"""
import pytest
from .test_helpers import DummyContext
import services.tools.find_gameobjects as find_go_mod
@pytest.mark.asyncio
async def test_find_gameobjects_basic_search(monkeypatch):
"""Test basic search returns instance IDs."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [12345, 67890],
"pageSize": 25,
"cursor": 0,
"totalCount": 2,
"hasMore": False,
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Player",
search_method="by_name",
)
assert resp.get("success") is True
assert captured["cmd"] == "find_gameobjects"
assert captured["params"]["searchTerm"] == "Player"
assert captured["params"]["searchMethod"] == "by_name"
@pytest.mark.asyncio
async def test_find_gameobjects_by_component(monkeypatch):
"""Test search by component type."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [111, 222, 333],
"pageSize": 25,
"cursor": 0,
"totalCount": 3,
"hasMore": False,
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Camera",
search_method="by_component",
)
assert resp.get("success") is True
assert captured["params"]["searchTerm"] == "Camera"
assert captured["params"]["searchMethod"] == "by_component"
@pytest.mark.asyncio
async def test_find_gameobjects_pagination_params(monkeypatch):
"""Test pagination parameters are passed correctly."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"instanceIDs": [444, 555],
"pageSize": 10,
"cursor": 20,
"totalCount": 50,
"hasMore": True,
"nextCursor": "30",
},
}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Enemy",
search_method="by_tag",
page_size="10",
cursor="20",
)
assert resp.get("success") is True
p = captured["params"]
assert p["pageSize"] == 10
assert p["cursor"] == 20
@pytest.mark.asyncio
async def test_find_gameobjects_boolean_coercion(monkeypatch):
"""Test boolean string coercion for include_inactive."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": []}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="HiddenObject",
search_method="by_name",
include_inactive="true",
)
assert resp.get("success") is True
p = captured["params"]
assert p["includeInactive"] is True
@pytest.mark.asyncio
async def test_find_gameobjects_by_layer(monkeypatch):
"""Test search by layer."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": [999]}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="UI",
search_method="by_layer",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_layer"
assert captured["params"]["searchTerm"] == "UI"
@pytest.mark.asyncio
async def test_find_gameobjects_by_path(monkeypatch):
"""Test search by hierarchy path."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceIDs": [777]}}
monkeypatch.setattr(
find_go_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await find_go_mod.find_gameobjects(
ctx=DummyContext(),
search_term="Canvas/Panel/Button",
search_method="by_path",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_path"
assert captured["params"]["searchTerm"] == "Canvas/Panel/Button"

View File

@ -0,0 +1,254 @@
"""
Tests for the GameObject resources.
Resources:
- unity://scene/gameobject/{instance_id}
- unity://scene/gameobject/{instance_id}/components
- unity://scene/gameobject/{instance_id}/component/{component_name}
"""
import pytest
from .test_helpers import DummyContext
import services.resources.gameobject as gameobject_res_mod
@pytest.mark.asyncio
async def test_get_gameobject_data(monkeypatch):
"""Test reading a single GameObject resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"instanceID": 12345,
"name": "Player",
"tag": "Player",
"layer": 0,
"activeSelf": True,
"activeInHierarchy": True,
"isStatic": False,
"path": "/Player",
"componentTypes": ["Transform", "PlayerController", "Rigidbody"],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject(
ctx=DummyContext(),
instance_id="12345",
)
assert resp.success is True
assert captured["params"]["instanceID"] == 12345
@pytest.mark.asyncio
async def test_get_gameobject_components(monkeypatch):
"""Test reading all components for a GameObject."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"cursor": 0,
"pageSize": 25,
"next_cursor": None,
"truncated": False,
"total": 3,
"items": [
{"typeName": "UnityEngine.Transform", "instanceID": 1, "enabled": True},
{"typeName": "UnityEngine.MeshRenderer", "instanceID": 2, "enabled": True},
{"typeName": "UnityEngine.BoxCollider", "instanceID": 3, "enabled": True},
],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
)
assert resp.success is True
assert captured["params"]["instanceID"] == 12345
@pytest.mark.asyncio
async def test_get_gameobject_components_pagination(monkeypatch):
"""Test pagination parameters for components resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"cursor": 10,
"pageSize": 5,
"next_cursor": "15",
"truncated": True,
"total": 20,
"items": [],
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
page_size=5,
cursor=10,
)
assert resp.success is True
p = captured["params"]
assert p["pageSize"] == 5
assert p["cursor"] == 10
@pytest.mark.asyncio
async def test_get_gameobject_components_include_properties(monkeypatch):
"""Test include_properties flag for components resource."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {
"items": [
{
"typeName": "UnityEngine.Rigidbody",
"instanceID": 123,
"mass": 1.0,
"drag": 0.0,
"useGravity": True,
}
]
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_components(
ctx=DummyContext(),
instance_id="12345",
include_properties=True,
)
assert resp.success is True
assert captured["params"]["includeProperties"] is True
@pytest.mark.asyncio
async def test_get_gameobject_component_single(monkeypatch):
"""Test reading a single component by name."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"typeName": "UnityEngine.Rigidbody",
"instanceID": 67890,
"mass": 5.0,
"drag": 0.1,
"angularDrag": 0.05,
"useGravity": True,
"isKinematic": False,
},
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_component(
ctx=DummyContext(),
instance_id="12345",
component_name="Rigidbody",
)
assert resp.success is True
p = captured["params"]
assert p["instanceID"] == 12345
assert p["componentName"] == "Rigidbody"
@pytest.mark.asyncio
async def test_get_gameobject_component_not_found(monkeypatch):
"""Test error when component is not found."""
async def fake_send(cmd, params, **kwargs):
return {
"success": False,
"message": "GameObject '12345' does not have a 'NonExistent' component.",
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject_component(
ctx=DummyContext(),
instance_id="12345",
component_name="NonExistent",
)
assert resp.success is False
assert "NonExistent" in (resp.message or "")
@pytest.mark.asyncio
async def test_get_gameobject_not_found(monkeypatch):
"""Test error when GameObject is not found."""
async def fake_send(cmd, params, **kwargs):
return {
"success": False,
"message": "GameObject with instanceID '99999' not found.",
}
monkeypatch.setattr(
gameobject_res_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await gameobject_res_mod.get_gameobject(
ctx=DummyContext(),
instance_id="99999",
)
assert resp.success is False
assert "99999" in (resp.message or "")

View File

@ -57,7 +57,7 @@ class TestManageAssetJsonParsing:
# Verify behavior: parsing fails with a clear error
assert result.get("success") is False
assert "failed to parse properties" in result.get("message", "")
assert "Failed to parse properties" in result.get("message", "")
@pytest.mark.asyncio
async def test_properties_dict_unchanged(self, monkeypatch):

View File

@ -0,0 +1,242 @@
"""
Tests for the manage_components tool.
This tool handles component lifecycle operations (add, remove, set_property).
"""
import pytest
from .test_helpers import DummyContext
import services.tools.manage_components as manage_comp_mod
@pytest.mark.asyncio
async def test_manage_components_add_single(monkeypatch):
"""Test adding a single component."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["cmd"] = cmd
captured["params"] = params
return {
"success": True,
"data": {
"addedComponents": [{"typeName": "UnityEngine.Rigidbody", "instanceID": 12345}]
},
}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Player",
component_type="Rigidbody",
)
assert resp.get("success") is True
assert captured["cmd"] == "manage_components"
assert captured["params"]["action"] == "add"
assert captured["params"]["target"] == "Player"
assert captured["params"]["componentType"] == "Rigidbody"
@pytest.mark.asyncio
async def test_manage_components_remove(monkeypatch):
"""Test removing a component."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345, "name": "Player"}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="remove",
target="Player",
component_type="Rigidbody",
)
assert resp.get("success") is True
assert captured["params"]["action"] == "remove"
assert captured["params"]["componentType"] == "Rigidbody"
@pytest.mark.asyncio
async def test_manage_components_set_property_single(monkeypatch):
"""Test setting a single component property."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
property="mass",
value=5.0,
)
assert resp.get("success") is True
assert captured["params"]["action"] == "set_property"
assert captured["params"]["property"] == "mass"
assert captured["params"]["value"] == 5.0
@pytest.mark.asyncio
async def test_manage_components_set_property_multiple(monkeypatch):
"""Test setting multiple component properties via properties dict."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
properties={"mass": 5.0, "drag": 0.5},
)
assert resp.get("success") is True
assert captured["params"]["action"] == "set_property"
assert captured["params"]["properties"] == {"mass": 5.0, "drag": 0.5}
@pytest.mark.asyncio
async def test_manage_components_set_property_json_string(monkeypatch):
"""Test setting component properties with JSON string input."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {"instanceID": 12345}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="set_property",
target="Player",
component_type="Rigidbody",
properties='{"mass": 10.0}', # JSON string
)
assert resp.get("success") is True
assert captured["params"]["properties"] == {"mass": 10.0}
@pytest.mark.asyncio
async def test_manage_components_add_with_properties(monkeypatch):
"""Test adding a component with initial properties."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {
"success": True,
"data": {"addedComponents": [{"typeName": "Rigidbody", "instanceID": 123}]},
}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Player",
component_type="Rigidbody",
properties={"mass": 2.0, "useGravity": False},
)
assert resp.get("success") is True
assert captured["params"]["properties"] == {"mass": 2.0, "useGravity": False}
@pytest.mark.asyncio
async def test_manage_components_search_method_passthrough(monkeypatch):
"""Test that search_method is correctly passed through."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target="Canvas/Panel",
component_type="Image",
search_method="by_path",
)
assert resp.get("success") is True
assert captured["params"]["searchMethod"] == "by_path"
@pytest.mark.asyncio
async def test_manage_components_target_by_id(monkeypatch):
"""Test targeting by instance ID."""
captured = {}
async def fake_send(cmd, params, **kwargs):
captured["params"] = params
return {"success": True, "data": {}}
monkeypatch.setattr(
manage_comp_mod,
"async_send_command_with_retry",
fake_send,
)
resp = await manage_comp_mod.manage_components(
ctx=DummyContext(),
action="add",
target=12345, # Integer instance ID
component_type="BoxCollider",
search_method="by_id",
)
assert resp.get("success") is True
assert captured["params"]["target"] == 12345
assert captured["params"]["searchMethod"] == "by_id"

View File

@ -21,6 +21,10 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
private int padAccumulator = 0;
private Vector3 padVector = Vector3.zero;
// Animation blend hashes
private static readonly int BlendXHash = Animator.StringToHash("BlendX");
private static readonly int BlendYHash = Animator.StringToHash("BlendY");
[Header("Tuning")]
public float maxReachDistance = 2f;
@ -31,6 +35,11 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
public bool HasTarget() { return currentTarget != null; }
public Transform GetCurrentTarget() => currentTarget;
// Simple selection logic (self-contained)
private Transform FindBestTarget()
{
@ -60,6 +69,7 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
}
// NL tests sometimes add comments above Update() as an anchor
// Build marker OK
private void Update()
{
if (reachOrigin == null) return;
@ -94,11 +104,11 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
return new Vector3(bx, by, 0f);
}
private void ApplyBlend(Vector3 blend)
private void ApplyBlend(Vector3 blend) // safe animation
{
if (animator == null) return;
animator.SetFloat("reachX", blend.x);
animator.SetFloat("reachY", blend.y);
if (animator == null) return; // safety check
animator.SetFloat(BlendXHash, blend.x);
animator.SetFloat(BlendYHash, blend.y);
}
public void TickBlendOnce()
@ -747,6 +757,19 @@ public class LongUnityScriptClaudeTest : MonoBehaviour
}
private void Pad0240()
{
// Tail test A
// Tail test B
// Tail test C
// idempotency test marker
void TestHelper() { /* placeholder */ }
void IncrementCounter() { padAccumulator++; }
// end of test modifications
// path test marker A
}
private void Pad0241()
{

View File

@ -0,0 +1,573 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Resources.Scene;
using UnityEngine.TestTools;
using Debug = UnityEngine.Debug;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Stress tests for the GameObject API redesign.
/// Tests volume operations, pagination, and performance with large datasets.
/// </summary>
[TestFixture]
public class GameObjectAPIStressTests
{
private List<GameObject> _createdObjects = new List<GameObject>();
private const int SMALL_BATCH = 10;
private const int MEDIUM_BATCH = 50;
private const int LARGE_BATCH = 100;
[SetUp]
public void SetUp()
{
_createdObjects.Clear();
}
[TearDown]
public void TearDown()
{
foreach (var go in _createdObjects)
{
if (go != null)
{
UnityEngine.Object.DestroyImmediate(go);
}
}
_createdObjects.Clear();
}
private GameObject CreateTestObject(string name)
{
var go = new GameObject(name);
_createdObjects.Add(go);
return go;
}
private static JObject ToJObject(object result)
{
if (result == null) return new JObject();
return result as JObject ?? JObject.FromObject(result);
}
#region Bulk GameObject Creation
[Test]
public void BulkCreate_SmallBatch_AllSucceed()
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < SMALL_BATCH; i++)
{
var result = ToJObject(ManageGameObject.HandleCommand(new JObject
{
["action"] = "create",
["name"] = $"BulkTest_{i}"
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to create object {i}");
// Track for cleanup
int instanceId = result["data"]?["instanceID"]?.Value<int>() ?? 0;
if (instanceId != 0)
{
var go = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
if (go != null) _createdObjects.Add(go);
}
}
sw.Stop();
Debug.Log($"[BulkCreate] Created {SMALL_BATCH} objects in {sw.ElapsedMilliseconds}ms");
Assert.Less(sw.ElapsedMilliseconds, 5000, "Bulk create took too long");
}
[Test]
public void BulkCreate_MediumBatch_AllSucceed()
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < MEDIUM_BATCH; i++)
{
var result = ToJObject(ManageGameObject.HandleCommand(new JObject
{
["action"] = "create",
["name"] = $"MediumBulk_{i}"
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to create object {i}");
int instanceId = result["data"]?["instanceID"]?.Value<int>() ?? 0;
if (instanceId != 0)
{
var go = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
if (go != null) _createdObjects.Add(go);
}
}
sw.Stop();
Debug.Log($"[BulkCreate] Created {MEDIUM_BATCH} objects in {sw.ElapsedMilliseconds}ms");
Assert.Less(sw.ElapsedMilliseconds, 15000, "Medium batch create took too long");
}
#endregion
#region Find GameObjects Pagination
[Test]
public void FindGameObjects_LargeBatch_PaginatesCorrectly()
{
// Create many objects with a unique marker component for reliable search
for (int i = 0; i < LARGE_BATCH; i++)
{
var go = CreateTestObject($"Searchable_{i:D3}");
go.AddComponent<GameObjectAPIStressTestMarker>();
}
// Find by searching for a specific object first
var firstResult = ToJObject(FindGameObjects.HandleCommand(new JObject
{
["searchTerm"] = "Searchable_000",
["searchMethod"] = "by_name",
["pageSize"] = 10
}));
Assert.IsTrue(firstResult["success"]?.Value<bool>() ?? false, "Should find specific named object");
var firstData = firstResult["data"] as JObject;
var firstIds = firstData?["instanceIDs"] as JArray;
Assert.IsNotNull(firstIds);
Assert.AreEqual(1, firstIds.Count, "Should find exactly one object with exact name match");
Debug.Log($"[FindGameObjects] Found object by exact name. Testing pagination with a unique marker component.");
// Now test pagination by searching for only the objects created by this test
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
{
["searchTerm"] = typeof(GameObjectAPIStressTestMarker).FullName,
["searchMethod"] = "by_component",
["pageSize"] = 25
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
Assert.IsNotNull(data);
var instanceIds = data["instanceIDs"] as JArray;
Assert.IsNotNull(instanceIds);
Assert.AreEqual(25, instanceIds.Count, "First page should have 25 items");
int totalCount = data["totalCount"]?.Value<int>() ?? 0;
Assert.AreEqual(LARGE_BATCH, totalCount, $"Should find exactly {LARGE_BATCH} objects created by this test");
bool hasMore = data["hasMore"]?.Value<bool>() ?? false;
Assert.IsTrue(hasMore, "Should have more pages");
Debug.Log($"[FindGameObjects] Found {totalCount} objects, first page has {instanceIds.Count}");
}
[Test]
public void FindGameObjects_PaginateThroughAll()
{
// Create objects - all will have a unique marker component
for (int i = 0; i < MEDIUM_BATCH; i++)
{
var go = CreateTestObject($"Paginate_{i:D3}");
go.AddComponent<GameObjectAPIStressTestMarker>();
}
// Track IDs we've created for verification
var createdIds = new HashSet<int>();
foreach (var go in _createdObjects)
{
if (go != null && go.name.StartsWith("Paginate_"))
{
createdIds.Add(go.GetInstanceID());
}
}
int pageSize = 10;
int cursor = 0;
int foundFromCreated = 0;
int pageCount = 0;
// Search by the unique marker component and check our created objects
while (true)
{
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
{
["searchTerm"] = typeof(GameObjectAPIStressTestMarker).FullName,
["searchMethod"] = "by_component",
["pageSize"] = pageSize,
["cursor"] = cursor
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
var instanceIds = data["instanceIDs"] as JArray;
// Count how many of our created objects are in this page
foreach (var id in instanceIds)
{
if (createdIds.Contains(id.Value<int>()))
{
foundFromCreated++;
}
}
pageCount++;
bool hasMore = data["hasMore"]?.Value<bool>() ?? false;
if (!hasMore) break;
cursor = data["nextCursor"]?.Value<int>() ?? cursor + pageSize;
// Safety limit
if (pageCount > 50) break;
}
Assert.AreEqual(MEDIUM_BATCH, foundFromCreated, $"Should find all {MEDIUM_BATCH} created objects across pages");
Debug.Log($"[Pagination] Found {foundFromCreated} created objects across {pageCount} pages");
}
#endregion
#region Component Operations at Scale
[Test]
public void AddComponents_MultipleToSingleObject()
{
var go = CreateTestObject("ComponentHost");
string[] componentTypeNames = new[]
{
"BoxCollider",
"Rigidbody",
"Light",
"Camera"
};
var sw = Stopwatch.StartNew();
foreach (var compType in componentTypeNames)
{
var result = ToJObject(ManageComponents.HandleCommand(new JObject
{
["action"] = "add",
["target"] = go.GetInstanceID().ToString(),
["searchMethod"] = "by_id",
["componentType"] = compType // Correct parameter name
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Failed to add {compType}: {result["message"]}");
}
sw.Stop();
Debug.Log($"[AddComponents] Added {componentTypeNames.Length} components in {sw.ElapsedMilliseconds}ms");
// Verify all components present
Assert.AreEqual(componentTypeNames.Length + 1, go.GetComponents<Component>().Length); // +1 for Transform
}
[Test]
public void GetComponents_ObjectWithManyComponents()
{
var go = CreateTestObject("HeavyComponents");
// Add many components - but skip AudioSource as it triggers deprecated API warnings
go.AddComponent<BoxCollider>();
go.AddComponent<SphereCollider>();
go.AddComponent<CapsuleCollider>();
go.AddComponent<MeshCollider>();
go.AddComponent<Rigidbody>();
go.AddComponent<Light>();
go.AddComponent<Camera>();
go.AddComponent<AudioListener>();
var sw = Stopwatch.StartNew();
// Use the resource handler for getting components
var result = ToJObject(GameObjectComponentsResource.HandleCommand(new JObject
{
["instanceID"] = go.GetInstanceID(),
["includeProperties"] = true,
["pageSize"] = 50
}));
sw.Stop();
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"GetComponents failed: {result["message"]}");
var data = result["data"] as JObject;
var components = data?["components"] as JArray;
Assert.IsNotNull(components);
Assert.AreEqual(9, components.Count); // 8 added + Transform
Debug.Log($"[GetComponents] Retrieved {components.Count} components with properties in {sw.ElapsedMilliseconds}ms");
}
[Test]
public void SetComponentProperties_ComplexRigidbody()
{
var go = CreateTestObject("RigidbodyTest");
go.AddComponent<Rigidbody>();
var result = ToJObject(ManageComponents.HandleCommand(new JObject
{
["action"] = "set_property",
["target"] = go.GetInstanceID().ToString(),
["searchMethod"] = "by_id",
["componentType"] = "Rigidbody", // Correct parameter name
["properties"] = new JObject // Correct parameter name
{
["mass"] = 10.5f,
["drag"] = 0.5f,
["angularDrag"] = 0.1f,
["useGravity"] = false,
["isKinematic"] = true
}
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false, $"Set property failed: {result["message"]}");
var rb = go.GetComponent<Rigidbody>();
Assert.AreEqual(10.5f, rb.mass, 0.01f);
Assert.AreEqual(0.5f, rb.drag, 0.01f);
Assert.AreEqual(0.1f, rb.angularDrag, 0.01f);
Assert.IsFalse(rb.useGravity);
Assert.IsTrue(rb.isKinematic);
}
#endregion
#region Deep Hierarchy Operations
[Test]
public void CreateDeepHierarchy_FindByPath()
{
// Create a deep hierarchy: Root/Level1/Level2/Level3/Target
var root = CreateTestObject("DeepRoot");
var current = root;
for (int i = 1; i <= 5; i++)
{
var child = CreateTestObject($"Level{i}");
child.transform.SetParent(current.transform);
current = child;
}
var target = CreateTestObject("DeepTarget");
target.transform.SetParent(current.transform);
// Find by path
var result = ToJObject(FindGameObjects.HandleCommand(new JObject
{
["searchTerm"] = "DeepRoot/Level1/Level2/Level3/Level4/Level5/DeepTarget",
["searchMethod"] = "by_path"
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
var ids = data?["instanceIDs"] as JArray;
Assert.IsNotNull(ids);
Assert.AreEqual(1, ids.Count);
Assert.AreEqual(target.GetInstanceID(), ids[0].Value<int>());
}
[Test]
public void GetHierarchy_LargeScene_Paginated()
{
// Create flat hierarchy with many objects
for (int i = 0; i < MEDIUM_BATCH; i++)
{
CreateTestObject($"HierarchyItem_{i:D3}");
}
var result = ToJObject(ManageScene.HandleCommand(new JObject
{
["action"] = "get_hierarchy",
["pageSize"] = 20,
["maxNodes"] = 100
}));
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
var items = data?["items"] as JArray;
Assert.IsNotNull(items);
Assert.GreaterOrEqual(items.Count, 1);
// Verify componentTypes is included
var firstItem = items[0] as JObject;
Assert.IsNotNull(firstItem?["componentTypes"], "Should include componentTypes");
Debug.Log($"[GetHierarchy] Retrieved {items.Count} items from hierarchy");
}
#endregion
#region Resource Read Performance
[Test]
public void GameObjectResource_ReadComplexObject()
{
var go = CreateTestObject("ComplexObject");
go.tag = "Player";
go.layer = 8;
go.isStatic = true;
// Add components - AudioSource is OK here since we're only reading component types, not serializing properties
go.AddComponent<Rigidbody>();
go.AddComponent<BoxCollider>();
go.AddComponent<AudioSource>();
// Add children
for (int i = 0; i < 5; i++)
{
var child = CreateTestObject($"Child_{i}");
child.transform.SetParent(go.transform);
}
var sw = Stopwatch.StartNew();
// Call the resource directly (no action param needed)
var result = ToJObject(GameObjectResource.HandleCommand(new JObject
{
["instanceID"] = go.GetInstanceID()
}));
sw.Stop();
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
Assert.AreEqual("ComplexObject", data?["name"]?.Value<string>());
Assert.AreEqual("Player", data?["tag"]?.Value<string>());
Assert.AreEqual(8, data?["layer"]?.Value<int>());
var componentTypes = data?["componentTypes"] as JArray;
Assert.IsNotNull(componentTypes);
Assert.AreEqual(4, componentTypes.Count); // Transform + 3 added
var children = data?["children"] as JArray;
Assert.IsNotNull(children);
Assert.AreEqual(5, children.Count);
Debug.Log($"[GameObjectResource] Read complex object in {sw.ElapsedMilliseconds}ms");
}
[Test]
public void ComponentsResource_ReadAllWithFullSerialization()
{
var go = CreateTestObject("FullSerialize");
var rb = go.AddComponent<Rigidbody>();
rb.mass = 5.5f;
rb.drag = 1.2f;
var col = go.AddComponent<BoxCollider>();
col.size = new Vector3(2, 3, 4);
col.center = new Vector3(0.5f, 0.5f, 0.5f);
// Skip AudioSource to avoid deprecated API warnings
var sw = Stopwatch.StartNew();
// Use the components resource handler
var result = ToJObject(GameObjectComponentsResource.HandleCommand(new JObject
{
["instanceID"] = go.GetInstanceID(),
["includeProperties"] = true
}));
sw.Stop();
Assert.IsTrue(result["success"]?.Value<bool>() ?? false);
var data = result["data"] as JObject;
var components = data?["components"] as JArray;
Assert.IsNotNull(components);
Assert.AreEqual(3, components.Count); // Transform + Rigidbody + BoxCollider
Debug.Log($"[ComponentsResource] Full serialization of {components.Count} components in {sw.ElapsedMilliseconds}ms");
// Verify serialized data includes properties
bool foundRigidbody = false;
foreach (JObject comp in components)
{
var typeName = comp["typeName"]?.Value<string>();
if (typeName != null && typeName.Contains("Rigidbody"))
{
foundRigidbody = true;
// GameObjectSerializer puts properties inside a "properties" nested object
var props = comp["properties"] as JObject;
Assert.IsNotNull(props, $"Rigidbody should have properties. Component data: {comp}");
float massValue = props["mass"]?.Value<float>() ?? 0;
Assert.AreEqual(5.5f, massValue, 0.01f, $"Mass should be 5.5");
}
}
Assert.IsTrue(foundRigidbody, "Should find Rigidbody with serialized properties");
}
#endregion
#region Concurrent-Like Operations
[Test]
public void RapidFireOperations_CreateModifyDelete()
{
var sw = Stopwatch.StartNew();
for (int i = 0; i < SMALL_BATCH; i++)
{
// Create
var createResult = ToJObject(ManageGameObject.HandleCommand(new JObject
{
["action"] = "create",
["name"] = $"RapidFire_{i}"
}));
Assert.IsTrue(createResult["success"]?.Value<bool>() ?? false, $"Create failed: {createResult["message"]}");
int instanceId = createResult["data"]?["instanceID"]?.Value<int>() ?? 0;
Assert.AreNotEqual(0, instanceId, "Instance ID should not be 0");
// Modify - use layer 0 (Default) to avoid layer name issues
var modifyResult = ToJObject(ManageGameObject.HandleCommand(new JObject
{
["action"] = "modify",
["target"] = instanceId.ToString(),
["searchMethod"] = "by_id",
["name"] = $"RapidFire_Modified_{i}", // Use name modification instead
["setActive"] = true
}));
Assert.IsTrue(modifyResult["success"]?.Value<bool>() ?? false, $"Modify failed: {modifyResult["message"]}");
// Delete
var deleteResult = ToJObject(ManageGameObject.HandleCommand(new JObject
{
["action"] = "delete",
["target"] = instanceId.ToString(),
["searchMethod"] = "by_id"
}));
Assert.IsTrue(deleteResult["success"]?.Value<bool>() ?? false, $"Delete failed: {deleteResult["message"]}");
}
sw.Stop();
Debug.Log($"[RapidFire] {SMALL_BATCH} create-modify-delete cycles in {sw.ElapsedMilliseconds}ms");
Assert.Less(sw.ElapsedMilliseconds, 10000, "Rapid fire operations took too long");
}
#endregion
}
/// <summary>
/// Marker component used for isolating component-based searches to objects created by this test fixture.
/// </summary>
public sealed class GameObjectAPIStressTestMarker : MonoBehaviour { }
}

View File

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

View File

@ -0,0 +1,482 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Comprehensive baseline tests for ManageGameObject "create" action.
/// These tests capture existing behavior before API redesign.
/// </summary>
public class ManageGameObjectCreateTests
{
private List<GameObject> createdObjects = new List<GameObject>();
[TearDown]
public void TearDown()
{
foreach (var go in createdObjects)
{
if (go != null)
{
Object.DestroyImmediate(go);
}
}
createdObjects.Clear();
}
private GameObject FindAndTrack(string name)
{
var go = GameObject.Find(name);
if (go != null && !createdObjects.Contains(go))
{
createdObjects.Add(go);
}
return go;
}
#region Basic Create Tests
[Test]
public void Create_WithNameOnly_CreatesEmptyGameObject()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestEmptyObject"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestEmptyObject");
Assert.IsNotNull(created, "GameObject should be created");
Assert.AreEqual("TestEmptyObject", created.name);
}
[Test]
public void Create_WithoutName_ReturnsError()
{
var p = new JObject
{
["action"] = "create"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without name");
}
[Test]
public void Create_WithEmptyName_ReturnsError()
{
var p = new JObject
{
["action"] = "create",
["name"] = ""
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail with empty name");
}
#endregion
#region Primitive Type Tests
[Test]
public void Create_PrimitiveCube_CreatesCubeWithComponents()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCube",
["primitiveType"] = "Cube"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCube");
Assert.IsNotNull(created, "Cube should be created");
Assert.IsNotNull(created.GetComponent<MeshFilter>(), "Cube should have MeshFilter");
Assert.IsNotNull(created.GetComponent<MeshRenderer>(), "Cube should have MeshRenderer");
Assert.IsNotNull(created.GetComponent<BoxCollider>(), "Cube should have BoxCollider");
}
[Test]
public void Create_PrimitiveSphere_CreatesSphereWithComponents()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestSphere",
["primitiveType"] = "Sphere"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestSphere");
Assert.IsNotNull(created, "Sphere should be created");
Assert.IsNotNull(created.GetComponent<SphereCollider>(), "Sphere should have SphereCollider");
}
[Test]
public void Create_PrimitiveCapsule_CreatesCapsule()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCapsule",
["primitiveType"] = "Capsule"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCapsule");
Assert.IsNotNull(created, "Capsule should be created");
Assert.IsNotNull(created.GetComponent<CapsuleCollider>(), "Capsule should have CapsuleCollider");
}
[Test]
public void Create_PrimitivePlane_CreatesPlane()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestPlane",
["primitiveType"] = "Plane"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestPlane");
Assert.IsNotNull(created, "Plane should be created");
}
[Test]
public void Create_PrimitiveCylinder_CreatesCylinder()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestCylinder",
["primitiveType"] = "Cylinder"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestCylinder");
Assert.IsNotNull(created, "Cylinder should be created");
}
[Test]
public void Create_PrimitiveQuad_CreatesQuad()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestQuad",
["primitiveType"] = "Quad"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestQuad");
Assert.IsNotNull(created, "Quad should be created");
}
[Test]
public void Create_InvalidPrimitiveType_HandlesGracefully()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestInvalidPrimitive",
["primitiveType"] = "InvalidType"
};
var result = ManageGameObject.HandleCommand(p);
// Should either fail or create empty object - capture current behavior
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Transform Tests
[Test]
public void Create_WithPosition_SetsPosition()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestPositioned",
["position"] = new JArray { 1.0f, 2.0f, 3.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestPositioned");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(1f, 2f, 3f), created.transform.position);
}
[Test]
public void Create_WithRotation_SetsRotation()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestRotated",
["rotation"] = new JArray { 0.0f, 90.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestRotated");
Assert.IsNotNull(created);
// Check Y rotation is approximately 90 degrees
Assert.AreEqual(90f, created.transform.eulerAngles.y, 0.1f);
}
[Test]
public void Create_WithScale_SetsScale()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestScaled",
["scale"] = new JArray { 2.0f, 3.0f, 4.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestScaled");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(2f, 3f, 4f), created.transform.localScale);
}
[Test]
public void Create_WithAllTransformProperties_SetsAll()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestFullTransform",
["position"] = new JArray { 5.0f, 6.0f, 7.0f },
["rotation"] = new JArray { 45.0f, 90.0f, 0.0f },
["scale"] = new JArray { 1.5f, 1.5f, 1.5f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestFullTransform");
Assert.IsNotNull(created);
Assert.AreEqual(new Vector3(5f, 6f, 7f), created.transform.position);
Assert.AreEqual(new Vector3(1.5f, 1.5f, 1.5f), created.transform.localScale);
}
#endregion
#region Parenting Tests
[Test]
public void Create_WithParentByName_SetsParent()
{
// Create parent first
var parent = new GameObject("TestParent");
createdObjects.Add(parent);
var p = new JObject
{
["action"] = "create",
["name"] = "TestChild",
["parent"] = "TestParent"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var child = FindAndTrack("TestChild");
Assert.IsNotNull(child);
Assert.AreEqual(parent.transform, child.transform.parent);
}
[Test]
public void Create_WithNonExistentParent_HandlesGracefully()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestOrphan",
["parent"] = "NonExistentParent"
};
var result = ManageGameObject.HandleCommand(p);
// Should either fail or create without parent - capture current behavior
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Tag and Layer Tests
[Test]
public void Create_WithTag_SetsTag()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestTagged",
["tag"] = "MainCamera" // Use built-in tag
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestTagged");
Assert.IsNotNull(created);
Assert.AreEqual("MainCamera", created.tag);
}
[Test]
public void Create_WithLayer_SetsLayer()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestLayered",
["layer"] = "UI" // Use built-in layer
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var created = FindAndTrack("TestLayered");
Assert.IsNotNull(created);
Assert.AreEqual(LayerMask.NameToLayer("UI"), created.layer);
}
[Test]
public void Create_WithInvalidTag_HandlesGracefully()
{
// Expect the error log from Unity about invalid tag
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
var p = new JObject
{
["action"] = "create",
["name"] = "TestInvalidTag",
["tag"] = "NonExistentTag12345"
};
var result = ManageGameObject.HandleCommand(p);
// Current behavior: logs error but may create object anyway
Assert.IsNotNull(result, "Should return a result");
// Clean up if object was created
FindAndTrack("TestInvalidTag");
}
#endregion
#region Response Structure Tests
[Test]
public void Create_Success_ReturnsInstanceID()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestInstanceID"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Check that instanceID is returned (case-insensitive check)
var instanceID = data["instanceID"]?.Value<int>() ?? data["InstanceID"]?.Value<int>();
Assert.IsTrue(instanceID.HasValue && instanceID.Value != 0,
$"Response should include a non-zero instanceID. Data: {data}");
FindAndTrack("TestInstanceID");
}
[Test]
public void Create_Success_ReturnsName()
{
var p = new JObject
{
["action"] = "create",
["name"] = "TestReturnedName"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Check name is in response
var nameValue = data["name"]?.ToString() ?? data["Name"]?.ToString();
Assert.IsTrue(!string.IsNullOrEmpty(nameValue) || data.ToString().Contains("TestReturnedName"),
"Response should include name");
FindAndTrack("TestReturnedName");
}
#endregion
}
}

View File

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

View File

@ -0,0 +1,380 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Comprehensive baseline tests for ManageGameObject "delete" action.
/// These tests capture existing behavior before API redesign.
/// </summary>
public class ManageGameObjectDeleteTests
{
private List<GameObject> testObjects = new List<GameObject>();
[TearDown]
public void TearDown()
{
foreach (var go in testObjects)
{
if (go != null)
{
Object.DestroyImmediate(go);
}
}
testObjects.Clear();
}
private GameObject CreateTestObject(string name)
{
var go = new GameObject(name);
testObjects.Add(go);
return go;
}
#region Basic Delete Tests
[Test]
public void Delete_ByName_DeletesObject()
{
var target = CreateTestObject("DeleteTargetByName");
var p = new JObject
{
["action"] = "delete",
["target"] = "DeleteTargetByName",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify object is deleted
var found = GameObject.Find("DeleteTargetByName");
Assert.IsNull(found, "Object should be deleted");
// Remove from our tracking list since it's deleted
testObjects.Remove(target);
}
[Test]
public void Delete_ByInstanceID_DeletesObject()
{
var target = CreateTestObject("DeleteTargetByID");
int instanceID = target.GetInstanceID();
var p = new JObject
{
["action"] = "delete",
["target"] = instanceID,
["searchMethod"] = "by_id"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify object is deleted
var found = GameObject.Find("DeleteTargetByID");
Assert.IsNull(found, "Object should be deleted");
testObjects.Remove(target);
}
[Test]
public void Delete_NonExistentObject_ReturnsError()
{
var p = new JObject
{
["action"] = "delete",
["target"] = "NonExistentObject12345",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail for non-existent object");
}
[Test]
public void Delete_WithoutTarget_ReturnsError()
{
var p = new JObject
{
["action"] = "delete"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without target");
}
#endregion
#region Search Method Tests
[Test]
public void Delete_ByTag_DeletesMatchingObjects()
{
// Current behavior: delete action finds first matching object and deletes it.
// This test verifies at least one tagged object is deleted.
var target1 = CreateTestObject("DeleteByTag1");
var target2 = CreateTestObject("DeleteByTag2");
// Use built-in tag
target1.tag = "MainCamera";
target2.tag = "MainCamera";
var p = new JObject
{
["action"] = "delete",
["target"] = "MainCamera",
["searchMethod"] = "by_tag"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify at least one object was deleted (current behavior deletes first match)
bool target1Deleted = target1 == null; // Unity Object == null check
bool target2Deleted = target2 == null;
Assert.IsTrue(target1Deleted || target2Deleted, "At least one tagged object should be deleted");
// Check response data for deletion info
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Clean up only surviving objects from tracking
if (!target1Deleted) testObjects.Remove(target1);
if (!target2Deleted) testObjects.Remove(target2);
}
[Test]
public void Delete_ByLayer_DeletesMatchingObjects()
{
var target = CreateTestObject("DeleteByLayer");
target.layer = LayerMask.NameToLayer("UI");
var p = new JObject
{
["action"] = "delete",
["target"] = "UI",
["searchMethod"] = "by_layer"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify the object was actually deleted
bool targetDeleted = target == null; // Unity Object == null check
Assert.IsTrue(targetDeleted, "Object on UI layer should be deleted");
Assert.IsFalse(testObjects.Contains(target) && target != null, "Deleted object should not be findable");
// Only remove from tracking if not already destroyed
if (!targetDeleted) testObjects.Remove(target);
}
[Test]
public void Delete_ByPath_DeletesObject()
{
var parent = CreateTestObject("DeleteParent");
var child = CreateTestObject("DeleteChild");
child.transform.SetParent(parent.transform);
var p = new JObject
{
["action"] = "delete",
["target"] = "DeleteParent/DeleteChild",
["searchMethod"] = "by_path"
};
var result = ManageGameObject.HandleCommand(p);
// Capture current behavior
Assert.IsNotNull(result, "Should return a result");
testObjects.Remove(child);
}
#endregion
#region Hierarchy Tests
[Test]
public void Delete_Parent_DeletesChildren()
{
var parent = CreateTestObject("DeleteParentWithChildren");
var child1 = CreateTestObject("Child1");
var child2 = CreateTestObject("Child2");
var grandchild = CreateTestObject("Grandchild");
child1.transform.SetParent(parent.transform);
child2.transform.SetParent(parent.transform);
grandchild.transform.SetParent(child1.transform);
var p = new JObject
{
["action"] = "delete",
["target"] = "DeleteParentWithChildren",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// All should be deleted
Assert.IsNull(GameObject.Find("DeleteParentWithChildren"), "Parent should be deleted");
Assert.IsNull(GameObject.Find("Child1"), "Child1 should be deleted");
Assert.IsNull(GameObject.Find("Child2"), "Child2 should be deleted");
Assert.IsNull(GameObject.Find("Grandchild"), "Grandchild should be deleted");
testObjects.Remove(parent);
testObjects.Remove(child1);
testObjects.Remove(child2);
testObjects.Remove(grandchild);
}
[Test]
public void Delete_Child_DoesNotDeleteParent()
{
var parent = CreateTestObject("ParentShouldSurvive");
var child = CreateTestObject("ChildToDelete");
child.transform.SetParent(parent.transform);
var p = new JObject
{
["action"] = "delete",
["target"] = "ChildToDelete",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Child deleted, parent survives
Assert.IsNull(GameObject.Find("ChildToDelete"), "Child should be deleted");
Assert.IsNotNull(GameObject.Find("ParentShouldSurvive"), "Parent should survive");
testObjects.Remove(child);
}
#endregion
#region Response Structure Tests
[Test]
public void Delete_Success_ReturnsDeletedCount()
{
var target = CreateTestObject("DeleteCountTest");
var p = new JObject
{
["action"] = "delete",
["target"] = "DeleteCountTest",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify object was actually deleted
bool targetDeleted = target == null;
Assert.IsTrue(targetDeleted, "Object should be deleted");
// Check for deleted count in response
var data = resultObj["data"];
Assert.IsNotNull(data, "Response should include data");
// Verify the actual count if present
if (data is JObject dataObj && dataObj.ContainsKey("deletedCount"))
{
Assert.AreEqual(1, dataObj.Value<int>("deletedCount"), "Should report 1 deleted object");
}
// Only remove from tracking if not already destroyed
if (!targetDeleted) testObjects.Remove(target);
}
#endregion
#region Edge Cases
[Test]
public void Delete_InactiveObject_StillDeletes()
{
var target = CreateTestObject("InactiveDeleteTarget");
target.SetActive(false);
var p = new JObject
{
["action"] = "delete",
["target"] = "InactiveDeleteTarget",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
// Capture current behavior for inactive objects
Assert.IsNotNull(result, "Should return a result");
testObjects.Remove(target);
}
[Test]
public void Delete_MultipleObjectsSameName_DeletesCorrectly()
{
// Expected behavior: delete action with by_name finds the FIRST matching object
// and deletes only that one. This is consistent with Unity's GameObject.Find behavior.
var target1 = CreateTestObject("DuplicateName");
var target2 = CreateTestObject("DuplicateName");
var p = new JObject
{
["action"] = "delete",
["target"] = "DuplicateName",
["searchMethod"] = "by_name"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
// Verify deletion occurred - at least one should be deleted
bool target1Deleted = target1 == null;
bool target2Deleted = target2 == null;
Assert.IsTrue(target1Deleted || target2Deleted, "At least one object should be deleted");
// Count remaining objects with the name to verify behavior
int remainingCount = 0;
if (!target1Deleted) remainingCount++;
if (!target2Deleted) remainingCount++;
// Document the actual behavior: first match is deleted, second survives
// If both are deleted, that's also acceptable (bulk delete mode)
Assert.IsTrue(remainingCount <= 1, $"Expected at most 1 remaining, got {remainingCount}");
// Clean up only survivors from tracking
if (!target1Deleted) testObjects.Remove(target1);
if (!target2Deleted) testObjects.Remove(target2);
}
#endregion
}
}

View File

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

View File

@ -0,0 +1,447 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// Comprehensive baseline tests for ManageGameObject "modify" action.
/// These tests capture existing behavior before API redesign.
/// </summary>
public class ManageGameObjectModifyTests
{
private List<GameObject> testObjects = new List<GameObject>();
[SetUp]
public void SetUp()
{
// Create a standard test object for each test
var go = new GameObject("ModifyTestObject");
go.transform.position = Vector3.zero;
go.transform.rotation = Quaternion.identity;
go.transform.localScale = Vector3.one;
testObjects.Add(go);
}
[TearDown]
public void TearDown()
{
foreach (var go in testObjects)
{
if (go != null)
{
Object.DestroyImmediate(go);
}
}
testObjects.Clear();
}
private GameObject CreateTestObject(string name)
{
var go = new GameObject(name);
testObjects.Add(go);
return go;
}
#region Target Resolution Tests
[Test]
public void Modify_ByName_FindsAndModifiesObject()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["searchMethod"] = "by_name",
["position"] = new JArray { 10.0f, 0.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(10f, 0f, 0f), testObjects[0].transform.position);
}
[Test]
public void Modify_ByInstanceID_FindsAndModifiesObject()
{
int instanceID = testObjects[0].GetInstanceID();
var p = new JObject
{
["action"] = "modify",
["target"] = instanceID,
["searchMethod"] = "by_id",
["position"] = new JArray { 20.0f, 0.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(20f, 0f, 0f), testObjects[0].transform.position);
}
[Test]
public void Modify_WithNameAlias_UsesNameAsTarget()
{
// When target is missing but name is provided, should use name as target
var p = new JObject
{
["action"] = "modify",
["name"] = "ModifyTestObject",
["position"] = new JArray { 30.0f, 0.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(30f, 0f, 0f), testObjects[0].transform.position);
}
[Test]
public void Modify_NonExistentTarget_ReturnsError()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "NonExistentObject12345",
["searchMethod"] = "by_name",
["position"] = new JArray { 0.0f, 0.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail for non-existent object");
}
[Test]
public void Modify_WithoutTarget_ReturnsError()
{
var p = new JObject
{
["action"] = "modify",
["position"] = new JArray { 0.0f, 0.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsFalse(resultObj.Value<bool>("success"), "Should fail without target");
}
#endregion
#region Transform Modification Tests
[Test]
public void Modify_Position_SetsNewPosition()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["position"] = new JArray { 1.0f, 2.0f, 3.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(1f, 2f, 3f), testObjects[0].transform.position);
}
[Test]
public void Modify_Rotation_SetsNewRotation()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["rotation"] = new JArray { 0.0f, 90.0f, 0.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(90f, testObjects[0].transform.eulerAngles.y, 0.1f);
}
[Test]
public void Modify_Scale_SetsNewScale()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["scale"] = new JArray { 2.0f, 3.0f, 4.0f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(2f, 3f, 4f), testObjects[0].transform.localScale);
}
[Test]
public void Modify_AllTransformProperties_SetsAll()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["position"] = new JArray { 5.0f, 6.0f, 7.0f },
["rotation"] = new JArray { 45.0f, 45.0f, 45.0f },
["scale"] = new JArray { 0.5f, 0.5f, 0.5f }
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(new Vector3(5f, 6f, 7f), testObjects[0].transform.position);
Assert.AreEqual(new Vector3(0.5f, 0.5f, 0.5f), testObjects[0].transform.localScale);
}
#endregion
#region Rename Tests
[Test]
public void Modify_Name_RenamesObject()
{
// Get instanceID first since name will change
int instanceID = testObjects[0].GetInstanceID();
var p = new JObject
{
["action"] = "modify",
["target"] = instanceID,
["searchMethod"] = "by_id",
["name"] = "RenamedObject" // Uses 'name' parameter, not 'newName'
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual("RenamedObject", testObjects[0].name);
}
[Test]
public void Modify_NameToEmpty_HandlesGracefully()
{
int instanceID = testObjects[0].GetInstanceID();
var p = new JObject
{
["action"] = "modify",
["target"] = instanceID,
["searchMethod"] = "by_id",
["name"] = "" // Empty name
};
var result = ManageGameObject.HandleCommand(p);
// Capture current behavior - may reject or allow empty name
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Reparenting Tests
[Test]
public void Modify_Parent_ReparentsObject()
{
var parent = CreateTestObject("NewParent");
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["parent"] = "NewParent"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(parent.transform, testObjects[0].transform.parent);
}
[Test]
public void Modify_ParentToNull_UnparentsObject()
{
// First parent the object
var parent = CreateTestObject("TempParent");
testObjects[0].transform.SetParent(parent.transform);
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["parent"] = JValue.CreateNull()
};
var result = ManageGameObject.HandleCommand(p);
// Capture current behavior for null parent
Assert.IsNotNull(result, "Should return a result");
}
[Test]
public void Modify_ParentToNonExistent_HandlesGracefully()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["parent"] = "NonExistentParent12345"
};
var result = ManageGameObject.HandleCommand(p);
// Should fail or handle gracefully
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Active State Tests
[Test]
public void Modify_SetActive_DeactivatesObject()
{
Assert.IsTrue(testObjects[0].activeSelf, "Object should start active");
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["setActive"] = false
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.IsFalse(testObjects[0].activeSelf, "Object should be deactivated");
}
[Test]
public void Modify_SetActive_ActivatesObject()
{
testObjects[0].SetActive(false);
Assert.IsFalse(testObjects[0].activeSelf, "Object should start inactive");
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["setActive"] = true
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.IsTrue(testObjects[0].activeSelf, "Object should be activated");
}
#endregion
#region Tag and Layer Tests
[Test]
public void Modify_Tag_SetsNewTag()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["tag"] = "MainCamera"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual("MainCamera", testObjects[0].tag);
}
[Test]
public void Modify_Layer_SetsNewLayer()
{
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["layer"] = "UI"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual(LayerMask.NameToLayer("UI"), testObjects[0].layer);
}
[Test]
public void Modify_InvalidTag_HandlesGracefully()
{
// Expect the error log from Unity about invalid tag
UnityEngine.TestTools.LogAssert.Expect(LogType.Error,
new System.Text.RegularExpressions.Regex("Tag:.*NonExistentTag12345.*not defined"));
var p = new JObject
{
["action"] = "modify",
["target"] = "ModifyTestObject",
["tag"] = "NonExistentTag12345"
};
var result = ManageGameObject.HandleCommand(p);
// Current behavior: logs error but continues
Assert.IsNotNull(result, "Should return a result");
}
#endregion
#region Multiple Modifications Tests
[Test]
public void Modify_MultipleProperties_AppliesAll()
{
var parent = CreateTestObject("MultiModifyParent");
int instanceID = testObjects[0].GetInstanceID();
var p = new JObject
{
["action"] = "modify",
["target"] = instanceID,
["searchMethod"] = "by_id",
["name"] = "MultiModifiedObject", // Uses 'name' not 'newName'
["position"] = new JArray { 100.0f, 200.0f, 300.0f },
["scale"] = new JArray { 5.0f, 5.0f, 5.0f },
["parent"] = "MultiModifyParent",
["tag"] = "MainCamera"
};
var result = ManageGameObject.HandleCommand(p);
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"), resultObj.ToString());
Assert.AreEqual("MultiModifiedObject", testObjects[0].name);
Assert.AreEqual(parent.transform, testObjects[0].transform.parent);
Assert.AreEqual("MainCamera", testObjects[0].tag);
}
#endregion
}
}

View File

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

View File

@ -69,81 +69,6 @@ namespace MCPForUnityTests.Editor.Tools
}
}
[Test]
public void GetComponents_ReturnsPagedMetadataByDefault()
{
// Arrange
testGameObject.AddComponent<Rigidbody>();
testGameObject.AddComponent<BoxCollider>();
var p = new JObject
{
["action"] = "get_components",
["target"] = testGameObject.name,
["searchMethod"] = "by_name",
["pageSize"] = 2
};
// Act
var raw = ManageGameObject.HandleCommand(p);
var result = raw as JObject ?? JObject.FromObject(raw);
// Assert
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
var data = result["data"] as JObject;
Assert.IsNotNull(data, "Expected data payload object.");
Assert.AreEqual(false, data.Value<bool>("includeProperties"));
var items = data["items"] as JArray;
Assert.IsNotNull(items, "Expected items array.");
Assert.AreEqual(2, items.Count, "Expected exactly pageSize items.");
var first = items[0] as JObject;
Assert.IsNotNull(first, "Expected item to be an object.");
Assert.IsNotNull(first["typeName"]);
Assert.IsNotNull(first["instanceID"]);
Assert.IsNull(first["properties"], "Metadata response should not include heavy serialized properties by default.");
}
[Test]
public void GetComponents_CanIncludePropertiesButStillPages()
{
// Arrange
testGameObject.AddComponent<Rigidbody>();
testGameObject.AddComponent<BoxCollider>();
var p = new JObject
{
["action"] = "get_components",
["target"] = testGameObject.name,
["searchMethod"] = "by_name",
["pageSize"] = 2,
["includeProperties"] = true
};
// Act
var raw = ManageGameObject.HandleCommand(p);
var result = raw as JObject ?? JObject.FromObject(raw);
// Assert
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
var data = result["data"] as JObject;
Assert.IsNotNull(data);
Assert.AreEqual(true, data.Value<bool>("includeProperties"));
var items = data["items"] as JArray;
Assert.IsNotNull(items);
Assert.IsTrue(items.Count > 0);
var first = items[0] as JObject;
Assert.IsNotNull(first);
Assert.IsNotNull(first["typeName"]);
Assert.IsNotNull(first["instanceID"]);
// Heuristic: property-including payload should have more than just typeName/instanceID.
Assert.Greater(first.Properties().Count(), 2, "Expected richer component payload when includeProperties=true.");
}
[Test]
public void ComponentResolver_Integration_WorksWithRealComponents()
{
@ -630,137 +555,5 @@ namespace MCPForUnityTests.Editor.Tools
UnityEngine.Object.DestroyImmediate(material2);
UnityEngine.Object.DestroyImmediate(testObject);
}
[Test]
public void AddComponent_StringArrayFormat_AppliesComponentProperties()
{
// Arrange - Create a GameObject to add component to
var testObject = new GameObject("AddComponentTestObject");
// Create params using string array format with top-level componentProperties
var addComponentParams = new JObject
{
["action"] = "add_component",
["target"] = testObject.name,
["search_method"] = "by_name",
["componentsToAdd"] = new JArray { "Rigidbody" },
["componentProperties"] = new JObject
{
["Rigidbody"] = new JObject
{
["mass"] = 7.5f,
["useGravity"] = false,
["drag"] = 2.0f
}
}
};
// Act
var result = ManageGameObject.HandleCommand(addComponentParams);
// Assert - Verify component was added
var rigidbody = testObject.GetComponent<Rigidbody>();
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
// Verify properties were set correctly during component creation
Assert.AreEqual(7.5f, rigidbody.mass, 0.001f,
"Mass should be set to 7.5 via componentProperties during add_component");
Assert.AreEqual(false, rigidbody.useGravity,
"UseGravity should be set to false via componentProperties during add_component");
Assert.AreEqual(2.0f, rigidbody.drag, 0.001f,
"Drag should be set to 2.0 via componentProperties during add_component");
// Verify result indicates success
Assert.IsNotNull(result, "Should return a result object");
var resultObj = result as JObject ?? JObject.FromObject(result);
Assert.IsTrue(resultObj.Value<bool>("success"),
"Result should indicate success when adding component with properties");
// Clean up
UnityEngine.Object.DestroyImmediate(testObject);
}
[Test]
public void AddComponent_ObjectFormat_StillAppliesComponentProperties()
{
// Arrange - Create a GameObject to add component to
var testObject = new GameObject("AddComponentObjectFormatTestObject");
// Create params using object array format (existing behavior)
var addComponentParams = new JObject
{
["action"] = "add_component",
["target"] = testObject.name,
["search_method"] = "by_name",
["componentsToAdd"] = new JArray
{
new JObject
{
["typeName"] = "Rigidbody",
["properties"] = new JObject
{
["mass"] = 3.5f,
["useGravity"] = true
}
}
}
};
// Act
var result = ManageGameObject.HandleCommand(addComponentParams);
// Assert - Verify component was added
var rigidbody = testObject.GetComponent<Rigidbody>();
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
// Verify properties were set correctly
Assert.AreEqual(3.5f, rigidbody.mass, 0.001f,
"Mass should be set to 3.5 via inline properties");
Assert.AreEqual(true, rigidbody.useGravity,
"UseGravity should be set to true via inline properties");
// Clean up
UnityEngine.Object.DestroyImmediate(testObject);
}
[Test]
public void AddComponent_ComponentNameFormat_AppliesComponentProperties()
{
// Arrange - Create a GameObject to add component to
var testObject = new GameObject("AddComponentNameFormatTestObject");
// Create params using componentName format (existing behavior)
var addComponentParams = new JObject
{
["action"] = "add_component",
["target"] = testObject.name,
["search_method"] = "by_name",
["componentName"] = "Rigidbody",
["componentProperties"] = new JObject
{
["Rigidbody"] = new JObject
{
["mass"] = 5.0f,
["drag"] = 1.5f
}
}
};
// Act
var result = ManageGameObject.HandleCommand(addComponentParams);
// Assert - Verify component was added
var rigidbody = testObject.GetComponent<Rigidbody>();
Assert.IsNotNull(rigidbody, "Rigidbody component should be added to GameObject");
// Verify properties were set correctly
Assert.AreEqual(5.0f, rigidbody.mass, 0.001f,
"Mass should be set to 5.0 via componentName format");
Assert.AreEqual(1.5f, rigidbody.drag, 0.001f,
"Drag should be set to 1.5 via componentName format");
// Clean up
UnityEngine.Object.DestroyImmediate(testObject);
}
}
}