Merge remote-tracking branch 'origin/main' into feat/telemetry

main
Justin Barnett 2025-09-04 12:12:14 -04:00
commit 8303ed1dbc
72 changed files with 10472 additions and 1330 deletions

View File

@ -0,0 +1,45 @@
# Unity NL Editing Suite — Natural Mode
You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate endtoend **naturallanguage code editing** on a representative Unity C# script using whatever capabilities and servers are already available in this session. Work autonomously. Do not ask the user for input. Do NOT spawn subagents, as they will not have access to the mcp server process on the top-level agent.
## Mission
1) **Discover capabilities.** Quietly inspect the tools and any connected servers that are available to you at session start. If the server offers a primer or capabilities resource, read it before acting.
2) **Choose a target file.** Prefer `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` if it exists; otherwise choose a simple, safe C# script under `TestProjects/UnityMCPTests/Assets/`.
3) **Perform a small set of realistic edits** using minimal, precise changes (not full-file rewrites). Examples of small edits you may choose from (pick 36 total):
- Insert a new, small helper method (e.g., a logger or counter) in a sensible location.
- Add a short anchor comment near a key method (e.g., above `Update()`), then add or modify a few lines nearby.
- Append an endofclass utility method (e.g., formatting or clamping helper).
- Make a safe, localized tweak to an existing method body (e.g., add a guard or a simple accumulator).
- Optionally include one idempotency/noop check (reapply an edit and confirm nothing breaks).
4) **Validate your edits.** Reread the modified regions and verify the changes exist, compilerisk is low, and surrounding structure remains intact.
5) **Report results.** Produce both:
- A JUnit XML at `reports/junit-nl-suite.xml` containing a single suite named `UnityMCP.NL` with one test case per subtest you executed (mark pass/fail and include helpful failure text).
- A summary markdown at `reports/junit-nl-suite.md` that explains what you attempted, what succeeded/failed, and any followups you would try.
6) **Be gentle and reversible.** Prefer targeted, minimal edits; avoid wide refactors or nondeterministic changes.
## Assumptions & Hints (nonprescriptive)
- A Unityoriented MCP server is expected to be connected. If a serverprovided **primer/capabilities** resource exists, read it first. If no primer is available, infer capabilities from your visible tools in the session.
- In CI/headless mode, when calling `mcp__unity__list_resources` or `mcp__unity__read_resource`, include:
- `ctx: {}`
- `project_root: "TestProjects/UnityMCPTests"` (the server will also accept the absolute path passed via env)
Example: `{ "ctx": {}, "under": "Assets/Scripts", "pattern": "*.cs", "project_root": "TestProjects/UnityMCPTests" }`
- If the preferred file isnt present, locate a fallback C# file with simple, local methods you can edit safely.
- If a compile command is available in this environment, you may optionally trigger it; if not, rely on structural checks and localized validation.
## Output Requirements (match NL suite conventions)
- JUnit XML at `$JUNIT_OUT` if set, otherwise `reports/junit-nl-suite.xml`.
- Single suite named `UnityMCP.NL`, one `<testcase>` per subtest; include `<failure>` on errors.
- Markdown at `$MD_OUT` if set, otherwise `reports/junit-nl-suite.md`.
Constraints (for fast publishing):
- Log allowed tools once as a single line: `AllowedTools: ...`.
- For every edit: Read → Write (with precondition hash) → Reread; on `{status:"stale_file"}` retry once after reread.
- Keep evidence to ±2040 lines windows; cap unified diffs to 300 lines and note truncation.
- End `<system-out>` with `VERDICT: PASS` or `VERDICT: FAIL`.
## Guardrails
- No destructive operations. Keep changes minimal and wellscoped.
- Dont leak secrets or environment details beyond whats needed in the reports.
- Work without user interaction; do not prompt for approval midflow.
> If capabilities discovery fails, still produce the two reports that clearly explain why you could not proceed and what evidence you gathered.

View File

@ -0,0 +1,240 @@
# Unity NL/T Editing Suite — Additive Test Design
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
---
## Mission
1) Pick target file (prefer):
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
2) Execute **all** NL/T tests in order using minimal, precise edits that **build on each other**.
3) Validate each edit with `mcp__unity__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`.
5) **NO RESTORATION** - tests build additively on previous state.
---
## Environment & Paths (CI)
- Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate.
- **Canonical URIs only**:
- Primary: `unity://path/Assets/...` (never embed `project_root` in the URI)
- Relative (when supported): `Assets/...`
CI provides:
- `$JUNIT_OUT=reports/junit-nl-suite.xml` (precreated; leave alone)
- `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit)
---
## Tool Mapping
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (nonoverlapping ranges)
- **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
---
## Additive Test Design Principles
**Key Changes from Reset-Based:**
1. **Dynamic Targeting**: Use `find_in_file` to locate methods/content, never hardcode line numbers
2. **State Awareness**: Each test expects the file state left by the previous test
3. **Content-Based Operations**: Target methods by signature, classes by name, not coordinates
4. **Cumulative Validation**: Ensure the file remains structurally sound throughout the sequence
5. **Composability**: Tests demonstrate how operations work together in real workflows
**State Tracking:**
- Track file SHA after each test to ensure operations succeeded
- Use content signatures (method names, comment markers) to verify expected state
- Validate structural integrity after each major change
---
## Execution Order & Additive Test Specs
### NL-0. Baseline State Capture
**Goal**: Establish initial file state and verify accessibility
**Actions**:
- Read file head and tail to confirm structure
- Locate key methods: `HasTarget()`, `GetCurrentTarget()`, `Update()`, `ApplyBlend()`
- Record initial SHA for tracking
- **Expected final state**: Unchanged baseline file
### NL-1. Core Method Operations (Additive State A)
**Goal**: Demonstrate method replacement operations
**Actions**:
- Replace `HasTarget()` method body: `public bool HasTarget() { return currentTarget != null; }`
- Insert `PrintSeries()` method after `GetCurrentTarget()`: `public void PrintSeries() { Debug.Log("1,2,3"); }`
- Verify both methods exist and are properly formatted
- Delete `PrintSeries()` method (cleanup for next test)
- **Expected final state**: `HasTarget()` modified, file structure intact, no temporary methods
### NL-2. Anchor Comment Insertion (Additive State B)
**Goal**: Demonstrate anchor-based insertions above methods
**Actions**:
- Use `find_in_file` to locate current position of `Update()` method
- Insert `// Build marker OK` comment line above `Update()` method
- Verify comment exists and `Update()` still functions
- **Expected final state**: State A + build marker comment above `Update()`
### NL-3. End-of-Class Content (Additive State C)
**Goal**: Demonstrate end-of-class insertions with smart brace matching
**Actions**:
- Use anchor pattern to find the class-ending brace (accounts for previous additions)
- Insert three comment lines before final class brace:
```
// Tail test A
// Tail test B
// Tail test C
```
- **Expected final state**: State B + tail comments before class closing brace
### NL-4. Console State Verification (No State Change)
**Goal**: Verify Unity console integration without file modification
**Actions**:
- Read Unity console messages (INFO level)
- Validate no compilation errors from previous operations
- **Expected final state**: State C (unchanged)
### T-A. Temporary Helper Lifecycle (Returns to State C)
**Goal**: Test insert → verify → delete cycle for temporary code
**Actions**:
- Find current position of `GetCurrentTarget()` method (may have shifted from NL-2 comment)
- Insert temporary helper: `private int __TempHelper(int a, int b) => a + b;`
- Verify helper method exists and compiles
- Delete helper method via structured delete operation
- **Expected final state**: Return to State C (helper removed, other changes intact)
### 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 */`
- Use `validate: "relaxed"` for interior-only edit
- Verify edit succeeded and file remains balanced
- **Expected final state**: State C + modified HasTarget() body
### T-C. Different Method Interior Edit (Additive State E)
**Goal**: Edit a different method to show operations don't interfere
**Actions**:
- Locate `ApplyBlend()` method using content search
- Edit interior line to add null check: `if (animator == null) return; // safety check`
- Preserve method signature and structure
- **Expected final state**: State D + modified ApplyBlend() method
### T-D. End-of-Class Helper (Additive State F)
**Goal**: Add permanent helper method at class end
**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 */ }`
- **Expected final state**: State E + TestHelper() method before class end
### T-E. Method Evolution Lifecycle (Additive State G)
**Goal**: Insert → modify → finalize a method through multiple operations
**Actions**:
- Insert basic method: `private int Counter = 0;`
- Update it: find and replace with `private int Counter = 42; // initialized`
- Add companion method: `private void IncrementCounter() { Counter++; }`
- **Expected final state**: State F + Counter field + IncrementCounter() method
### T-F. Atomic Multi-Edit (Additive State H)
**Goal**: Multiple coordinated edits in single atomic operation
**Actions**:
- Read current file state to compute precise ranges
- Atomic edit combining:
1. Add comment in `HasTarget()`: `// validated access`
2. Add comment in `ApplyBlend()`: `// safe animation`
3. Add final class comment: `// end of test modifications`
- All edits computed from same file snapshot, applied atomically
- **Expected final state**: State G + three coordinated comments
### T-G. Path Normalization Test (No State Change)
**Goal**: Verify URI forms work equivalently on modified file
**Actions**:
- Make identical edit using `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
- Then using `Assets/Scripts/LongUnityScriptClaudeTest.cs`
- Second should return `stale_file`, retry with updated SHA
- Verify both URI forms target same file
- **Expected final state**: State H (no content change, just path testing)
### T-H. Validation on Modified File (No State Change)
**Goal**: Ensure validation works correctly on heavily modified file
**Actions**:
- Run `validate_script(level:"standard")` on current state
- Verify no structural errors despite extensive modifications
- **Expected final state**: State H (validation only, no edits)
### T-I. Failure Surface Testing (No State Change)
**Goal**: Test error handling on real modified file
**Actions**:
- Attempt overlapping edits (should fail cleanly)
- Attempt edit with stale SHA (should fail cleanly)
- Verify error responses are informative
- **Expected final state**: State H (failed operations don't modify file)
### T-J. Idempotency on Modified File (Additive State I)
**Goal**: Verify operations behave predictably when repeated
**Actions**:
- Add unique marker comment: `// idempotency test marker`
- Attempt to add same comment again (should detect no-op)
- Remove marker, attempt removal again (should handle gracefully)
- **Expected final state**: State H + verified idempotent behavior
---
## Dynamic Targeting Examples
**Instead of hardcoded coordinates:**
```json
{"startLine": 31, "startCol": 26, "endLine": 31, "endCol": 58}
```
**Use content-aware targeting:**
```json
# Find current method location
find_in_file(pattern: "public bool HasTarget\\(\\)")
# Then compute edit ranges from found position
```
**Method targeting by signature:**
```json
{"op": "replace_method", "className": "LongUnityScriptClaudeTest", "methodName": "HasTarget"}
```
**Anchor-based insertions:**
```json
{"op": "anchor_insert", "anchor": "private void Update\\(\\)", "position": "before", "text": "// comment"}
```
---
## State Verification Patterns
**After each test:**
1. Verify expected content exists: `find_in_file` for key markers
2. Check structural integrity: `validate_script(level:"standard")`
3. Update SHA tracking for next test's preconditions
4. Log cumulative changes in test evidence
**Error Recovery:**
- If test fails, log current state but continue (don't restore)
- Next test adapts to actual current state, not expected state
- Demonstrates resilience of operations on varied file conditions
---
## Benefits of Additive Design
1. **Realistic Workflows**: Tests mirror actual development patterns
2. **Robust Operations**: Proves edits work on evolving files, not just pristine baselines
3. **Composability Validation**: Shows operations coordinate well together
4. **Simplified Infrastructure**: No restore scripts or snapshots needed
5. **Better Failure Analysis**: Failures don't cascade - each test adapts to current reality
6. **State Evolution Testing**: Validates SDK handles cumulative file modifications correctly
This additive approach produces a more realistic and maintainable test suite that better represents actual SDK usage patterns.

View File

@ -0,0 +1,234 @@
# Unity NL/T Editing Suite — CI Agent Contract
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,Bash(printf:*),Bash(echo:*),Bash(scripts/nlt-revert.sh:*),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
---
## Mission
1) Pick target file (prefer):
- `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs`
2) Execute **all** NL/T tests in order using minimal, precise edits.
3) Validate each edit with `mcp__unity__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`.
5) **Restore** the file after each test using the OSlevel helper (fast), not a fullfile text write.
---
## Environment & Paths (CI)
- Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate.
- **Canonical URIs only**:
- Primary: `unity://path/Assets/...` (never embed `project_root` in the URI)
- Relative (when supported): `Assets/...`
- File paths for the helper script are workspacerelative:
- `TestProjects/UnityMCPTests/Assets/...`
CI provides:
- `$JUNIT_OUT=reports/junit-nl-suite.xml` (precreated; leave alone)
- `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit)
- Helper script: `scripts/nlt-revert.sh` (snapshot/restore)
---
## Tool Mapping
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
- Allowed ops: `anchor_insert`, `replace_range`, `regex_replace` (no overlapping ranges within a single call)
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (nonoverlapping ranges)
- Multispan batches are computed from the same fresh read and sent atomically by default.
- Prefer `options.applyMode:"atomic"` when passing options for multiple spans; for singlespan, sequential is fine.
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
- **Validation**: `mcp__unity__validate_script(level:"standard")`
- For edits, you may pass `options.validate`:
- `standard` (default): fullfile delimiter balance checks.
- `relaxed`: scoped checks for interior, nonstructural text edits; do not use for header/signature/bracetouching changes.
- **Reporting**: `Write` small XML fragments to `reports/*_results.xml`
- **Editor state/flush**: `mcp__unity__manage_editor` (use sparingly; no project mutations)
- **Console readback**: `mcp__unity__read_console` (INFO capture only; do not assert in place of `validate_script`)
- **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)`
- For `script_apply_edits`: use `name` + workspacerelative `path` only (e.g., `name="LongUnityScriptClaudeTest"`, `path="Assets/Scripts"`). Do not pass `unity://...` URIs as `path`.
- For `apply_text_edits` / `read_resource`: use the URI form only (e.g., `uri="unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs"`). Do not concatenate `Assets/` with a `unity://...` URI.
- Never call generic Bash like `mkdir`; the revert helper creates needed directories. Use only `scripts/nlt-revert.sh` for snapshot/restore.
- If you believe a directory is missing, you are mistaken: the workflow pre-creates it and the snapshot helper creates it if needed. Do not attempt any Bash other than scripts/nlt-revert.sh:*.
### Structured edit ops (required usage)
# Insert a helper RIGHT BEFORE the final class brace (NL3, TD)
1) Prefer `script_apply_edits` with a regex capture on the final closing brace:
```json
{"op":"regex_replace",
"pattern":"(?s)(\\r?\\n\\s*\\})\\s*$",
"replacement":"\\n // Tail test A\\n // Tail test B\\n // Tail test C\\1"}
2) If the server returns `unsupported` (op not available) or `missing_field` (opspecific), FALL BACK to
`apply_text_edits`:
- Find the last `}` in the file (class closing brace) by scanning from end.
- Insert the three comment lines immediately before that index with one nonoverlapping range.
# Insert after GetCurrentTarget (TA/TE)
- Use `script_apply_edits` with:
```json
{"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"private int __TempHelper(int a,int b)=>a+b;\\n"}
```
# Delete the temporary helper (TA/TE)
- Prefer structured delete:
- Use `script_apply_edits` with `{ "op":"delete_method", "className":"LongUnityScriptClaudeTest", "methodName":"PrintSeries" }` (or `__TempHelper` for TA).
- If structured delete is unavailable, fall back to `apply_text_edits` with a single `replace_range` spanning the exact method block (bounds computed from a fresh read); avoid wholefile regex deletes.
# TB (replace method body)
- Use `mcp__unity__apply_text_edits` with a single `replace_range` strictly inside the `HasTarget` braces.
- Compute start/end from a fresh `read_resource` at test start. Do not edit signature or header.
- On `{status:"stale_file"}` retry once with the server-provided hash; if absent, re-read once and retry.
- On `bad_request`: write the testcase with `<failure>…</failure>`, restore, and continue to next test.
- On `missing_field`: FALL BACK per above; if the fallback also returns `unsupported` or `bad_request`, then fail as above.
> Dont use `mcp__unity__create_script`. Avoid the header/`using` region entirely.
Span formats for `apply_text_edits`:
- Prefer LSP ranges (0based): `{ "range": { "start": {"line": L, "character": C}, "end": {…} }, "newText": "…" }`
- Explicit fields are 1based: `{ "startLine": L1, "startCol": C1, "endLine": L2, "endCol": C2, "newText": "…" }`
- SDK preflights overlap after normalization; overlapping nonzero spans → `{status:"overlap"}` with conflicts and no file mutation.
- Optional debug: pass `strict:true` to reject explicit 0based fields (else they are normalized and a warning is emitted).
- Apply mode guidance: router defaults to atomic for multispan; you can explicitly set `options.applyMode` if needed.
---
## Output Rules (JUnit fragments only)
- For each test, create **one** file: `reports/<TESTID>_results.xml` containing exactly a single `<testcase ...> ... </testcase>`.
Put human-readable lines (PLAN/PROGRESS/evidence) **inside** `<system-out><![CDATA[ ... ]]></system-out>`.
- If content contains `]]>`, split CDATA: replace `]]>` with `]]]]><![CDATA[>`.
- Evidence windows only (±2040 lines). If showing a unified diff, cap at 100 lines and note truncation.
- **Never** open/patch `$JUNIT_OUT` or `$MD_OUT`; CI merges fragments and synthesizes Markdown.
- Write destinations must match: `^reports/[A-Za-z0-9._-]+_results\.xml$`
- Snapshot files must live under `reports/_snapshots/`
- Reject absolute paths and any path containing `..`
- Reject control characters and line breaks in filenames; enforce UTF8
- Cap basename length to ≤64 chars; cap any path segment to ≤100 and total path length to ≤255
- Bash(printf|echo) must write to stdout only. Do not use shell redirection, heredocs, or `tee` to create/modify files. The only allowed FS mutation is via `scripts/nlt-revert.sh`.
**Example fragment**
```xml
<testcase classname="UnityMCP.NL-T" name="NL-1. Method replace/insert/delete">
<system-out><![CDATA[
PLAN: 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 (len=15)
PROGRESS: 2/15 completed
pre_sha=<...>
... evidence windows ...
VERDICT: PASS
]]></system-out>
</testcase>
```
Note: Emit the PLAN line only in NL0 (do not repeat it for later tests).
### Fast Restore Strategy (OSlevel)
- Snapshot once at NL0, then restore after each test via the helper.
- Snapshot (once after confirming the target):
```bash
scripts/nlt-revert.sh snapshot "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline"
```
- Log `snapshot_sha=...` printed by the script.
- Restore (after each mutating test):
```bash
scripts/nlt-revert.sh restore "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline"
```
- Then `read_resource` to confirm and (optionally) `validate_script(level:"standard")`.
- If the helper fails: fall back once to a guarded fullfile restore using the baseline bytes; then continue.
### Guarded Write Pattern (for edits, not restores)
- Before any mutation: `res = mcp__unity__read_resource(uri)`; `pre_sha = sha256(res.bytes)`.
- Write with `precondition_sha256 = pre_sha` on `apply_text_edits`/`script_apply_edits`.
- To compute `pre_sha` without reading file contents, you may instead call `mcp__unity__get_sha(uri).sha256`.
- On `{status:"stale_file"}`:
- Retry once using the server-provided hash (e.g., `data.current_sha256` or `data.expected_sha256`, per API schema).
- If absent, one re-read then a final retry. No loops.
- After success: immediately re-read via `res2 = mcp__unity__read_resource(uri)` and set `pre_sha = sha256(res2.bytes)` before any further edits in the same test.
- Prefer anchors (`script_apply_edits`) for end-of-class / above-method insertions. Keep edits inside method bodies. Avoid header/using.
**On nonJSON/transport errors (timeout, EOF, connection closed):**
- Write `reports/<TESTID>_results.xml` with a `<testcase>` that includes a `<failure>` or `<error>` node capturing the error text.
- Run the OS restore via `scripts/nlt-revert.sh restore …`.
- Continue to the next test (do not abort).
**If any write returns `bad_request`, or `unsupported` after a fallback attempt:**
- Write `reports/<TESTID>_results.xml` with a `<testcase>` that includes a `<failure>` node capturing the server error, include evidence, and end with `VERDICT: FAIL`.
- Run `scripts/nlt-revert.sh restore ...` and continue to the next test.
### Execution Order (fixed)
- Run exactly: 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 (15 total).
- Before NL-1..T-J: Bash(scripts/nlt-revert.sh:restore "<target>" "reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline") IF the baseline exists; skip for NL-0.
- NL-0 must include the PLAN line (len=15).
- After each testcase, include `PROGRESS: <k>/15 completed`.
### Test Specs (concise)
- NL0. Sanity reads — Tail ~120; ±40 around `Update()`. Then snapshot via helper.
- NL1. Replace/insert/delete — `HasTarget → return currentTarget != null;`; insert `PrintSeries()` after `GetCurrentTarget` logging "1,2,3"; verify; delete `PrintSeries()`; restore.
- NL2. Anchor comment — Insert `// Build marker OK` above `public void Update(...)`; restore.
- NL3. Endofclass — Insert `// Tail test A/B/C` (3 lines) before final brace; restore.
- NL4. Compile trigger — Record INFO only.
### TA. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore.
### TB. Replace body — Single `replace_range` inside `HasTarget`; restore.
- Options: pass {"validate":"relaxed"} for interior one-line edits.
### TC. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore.
- Options: pass {"validate":"relaxed"} for interior one-line edits.
### TD. Endofclass (anchor) — Insert helper before final brace; remove; restore.
### TE. Lifecycle — Insert → update → delete via regex; restore.
### TF. Atomic batch — One `mcp__unity__apply_text_edits` call (text ranges only)
- Compute all three edits from the **same fresh read**:
1) Two small interior `replace_range` tweaks.
2) One **endofclass insertion**: find the **index of the final `}`** for the class; create a zerowidth range `[idx, idx)` and set `replacement` to the 3line comment block.
- Send all three ranges in **one call**, sorted **descending by start index** to avoid offset drift.
- Expect allornothing semantics; on `{status:"overlap"}` or `{status:"bad_request"}`, write the testcase fragment with `<failure>…</failure>`, **restore**, and continue.
- Options: pass {"applyMode":"atomic"} to enforce allornothing.
- TG. Path normalization — Make the same edit with `unity://path/Assets/...` then `Assets/...`. Without refreshing `precondition_sha256`, the second attempt returns `{stale_file}`; retry with the server-provided hash to confirm both forms resolve to the same file.
### T-H. Validation (standard)
- Restore baseline (helper call above).
- Perform a harmless interior tweak (or none), then MUST call:
mcp__unity__validate_script(level:"standard")
- Write the validator output to system-out; VERDICT: PASS if standard is clean, else include <failure> with the validator message and continue.
### T-I. Failure surfaces (expected)
- Restore baseline.
- (1) OVERLAP:
* Fresh read of file; compute two interior ranges that overlap inside HasTarget.
* Prefer LSP ranges (0based) or explicit 1based fields; ensure both spans come from the same snapshot.
* Single mcp__unity__apply_text_edits call with both ranges.
* Expect `{status:"overlap"}` (SDK preflight) → record as PASS; else FAIL. Restore.
- (2) STALE_FILE:
* Fresh read → pre_sha.
* Make a tiny legit edit with pre_sha; success.
* Attempt another edit reusing the OLD pre_sha.
* Expect {status:"stale_file"} → record as PASS; else FAIL. Re-read to refresh, restore.
### Pertest error handling and recovery
- For each test (NL0..TJ), use a try/finally pattern:
- Always write a testcase fragment and perform restore in finally, even when tools return error payloads.
- try: run the test steps; always write `reports/<ID>_results.xml` with PASS/FAIL/ERROR
- finally: run Bash(scripts/nlt-revert.sh:restore …baseline) to restore the target file
- On any transport/JSON/tool exception:
- catch and write a `<testcase>` fragment with an `<error>` node (include the message), then proceed to the next test.
- After NL4 completes, proceed directly to TA regardless of any earlier validator warnings (do not abort the run).
- (3) USING_GUARD (optional):
* Attempt a 1-line insert above the first 'using'.
* Expect {status:"using_guard"} → record as PASS; else note 'not emitted'. Restore.
### T-J. Idempotency
- Restore baseline.
- Repeat a replace_range twice (second call may be noop). Validate standard after each.
- Insert or ensure a tiny comment, then delete it twice (second delete may be noop).
- Restore and PASS unless an error/structural break occurred.
### Status & Reporting
- Safeguard statuses are nonfatal; record and continue.
- End each testcase `<system-out>` with `VERDICT: PASS` or `VERDICT: FAIL`.

113
.github/scripts/mark_skipped.py vendored Executable file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
Post-processes a JUnit XML so that "expected"/environmental failures
(e.g., permission prompts, empty MCP resources, or schema hiccups)
are converted to <skipped/>. Leaves real failures intact.
Usage:
python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml
"""
from __future__ import annotations
import sys
import os
import re
import xml.etree.ElementTree as ET
PATTERNS = [
r"\bpermission\b",
r"\bpermissions\b",
r"\bautoApprove\b",
r"\bapproval\b",
r"\bdenied\b",
r"requested\s+permissions",
r"^MCP resources list is empty$",
r"No MCP resources detected",
r"aggregator.*returned\s*\[\s*\]",
r"Unknown resource:\s*unity://",
r"Input should be a valid dictionary.*ctx",
r"validation error .* ctx",
]
def should_skip(msg: str) -> bool:
if not msg:
return False
msg_l = msg.strip()
for pat in PATTERNS:
if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE):
return True
return False
def summarize_counts(ts: ET.Element):
tests = 0
failures = 0
errors = 0
skipped = 0
for case in ts.findall("testcase"):
tests += 1
if case.find("failure") is not None:
failures += 1
if case.find("error") is not None:
errors += 1
if case.find("skipped") is not None:
skipped += 1
return tests, failures, errors, skipped
def main(path: str) -> int:
if not os.path.exists(path):
print(f"[mark_skipped] No JUnit at {path}; nothing to do.")
return 0
try:
tree = ET.parse(path)
except ET.ParseError as e:
print(f"[mark_skipped] Could not parse {path}: {e}")
return 0
root = tree.getroot()
suites = root.findall("testsuite") if root.tag == "testsuites" else [root]
changed = False
for ts in suites:
for case in list(ts.findall("testcase")):
nodes = [n for n in list(case) if n.tag in ("failure", "error")]
if not nodes:
continue
# If any node matches skip patterns, convert the whole case to skipped.
first_match_text = None
to_skip = False
for n in nodes:
msg = (n.get("message") or "") + "\n" + (n.text or "")
if should_skip(msg):
first_match_text = (n.text or "").strip() or first_match_text
to_skip = True
if to_skip:
for n in nodes:
case.remove(n)
reason = "Marked skipped: environment/permission precondition not met"
skip = ET.SubElement(case, "skipped")
skip.set("message", reason)
skip.text = first_match_text or reason
changed = True
# Recompute tallies per testsuite
tests, failures, errors, skipped = summarize_counts(ts)
ts.set("tests", str(tests))
ts.set("failures", str(failures))
ts.set("errors", str(errors))
ts.set("skipped", str(skipped))
if changed:
tree.write(path, encoding="utf-8", xml_declaration=True)
print(f"[mark_skipped] Updated {path}: converted environmental failures to skipped.")
else:
print(f"[mark_skipped] No environmental failures detected in {path}.")
return 0
if __name__ == "__main__":
target = (
sys.argv[1]
if len(sys.argv) > 1
else os.environ.get("JUNIT_OUT", "reports/junit-nl-suite.xml")
)
raise SystemExit(main(target))

View File

@ -0,0 +1,356 @@
name: Claude Mini NL Test Suite (Unity live)
on:
workflow_dispatch: {}
permissions:
contents: read
checks: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
UNITY_VERSION: 2021.3.45f1
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3
UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home
jobs:
nl-suite:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
JUNIT_OUT: reports/junit-nl-suite.xml
MD_OUT: reports/junit-nl-suite.md
steps:
# ---------- Detect secrets ----------
- 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" ]; } || [ -n "$UNITY_SERIAL" ]; 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 UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then
uv pip install -e UnityMcpBridge/UnityMcpServer~/src
elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then
uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt
elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then
uv pip install -e UnityMcpBridge/UnityMcpServer~/
elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then
uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt
else
echo "No MCP Python deps found (skipping)"
fi
# ---------- License prime on host (handles ULF or EBL) ----------
- name: Prime Unity license on host (GameCI)
if: steps.detect.outputs.unity_ok == 'true'
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
with:
projectPath: TestProjects/UnityMCPTests
testMode: EditMode
customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics
unityVersion: ${{ env.UNITY_VERSION }}
# (Optional) Show where the license actually got written
- name: Inspect GameCI license caches (host)
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true
# ---------- Clean any stale MCP status from previous runs ----------
- name: Clean old MCP status
run: |
set -eux
mkdir -p "$HOME/.unity-mcp"
rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true
# ---------- Start headless Unity that stays up (bridge enabled) ----------
- name: Start Unity (persistent bridge)
if: steps.detect.outputs.unity_ok == 'true'
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -eu
if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then
echo "Unity project not found; failing fast."
exit 1
fi
mkdir -p "$HOME/.unity-mcp"
MANUAL_ARG=()
if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then
MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf)
fi
EBL_ARGS=()
[ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL")
[ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL")
[ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD")
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=/root/.unity-mcp \
-e UNITY_MCP_BIND_HOST=127.0.0.1 \
-v "${{ github.workspace }}:/workspace" -w /workspace \
-v "${{ env.UNITY_CACHE_ROOT }}:/root" \
-v "$HOME/.unity-mcp:/root/.unity-mcp" \
${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-stackTraceLogType Full \
-projectPath /workspace/TestProjects/UnityMCPTests \
"${MANUAL_ARG[@]}" \
"${EBL_ARGS[@]}" \
-executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect
# ---------- Wait for Unity bridge (fail fast if not running/ready) ----------
- name: Wait for Unity bridge (robust)
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -euo pipefail
if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then
echo "Unity container failed to start"; docker ps -a || true; exit 1
fi
docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$!
deadline=$((SECONDS+420)); READY=0
try_connect_host() {
P="$1"
timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true
if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi
return 1
}
# in-container probe will try IPv4 then IPv6 via nc or /dev/tcp
while [ $SECONDS -lt $deadline ]; do
if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then
READY=1; echo "Bridge ready (log markers)"; break
fi
PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true)
if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then
READY=1; echo "Bridge ready on port $PORT"; break
fi
if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then
echo "Licensing error detected"; break
fi
sleep 2
done
kill $LOGPID || true
if [ "$READY" != "1" ]; then
echo "Bridge not ready; diagnostics:"
echo "== status files =="; ls -la "$HOME/.unity-mcp" || true
echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done
echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true'
echo "== tail of Unity log =="
docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
exit 1
fi
# ---------- Make MCP config available to the action ----------
- name: Write MCP config (.claude/mcp.json)
run: |
set -eux
mkdir -p .claude
cat > .claude/mcp.json <<JSON
{
"mcpServers": {
"unity": {
"command": "uv",
"args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"],
"transport": { "type": "stdio" },
"env": {
"PYTHONUNBUFFERED": "1",
"MCP_LOG_LEVEL": "debug",
"UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests"
}
}
}
}
JSON
# ---------- Ensure reports dir exists ----------
- name: Prepare reports
run: |
set -eux
mkdir -p reports
# ---------- Run full NL suite once ----------
- name: Run Claude NL suite (single pass)
uses: anthropics/claude-code-base-action@beta
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
env:
JUNIT_OUT: reports/junit-nl-suite.xml
MD_OUT: reports/junit-nl-suite.md
with:
use_node_cache: false
prompt_file: .claude/prompts/nl-unity-claude-tests-mini.md
mcp_config: .claude/mcp.json
allowed_tools: "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"
disallowed_tools: "TodoWrite,Task"
model: "claude-3-7-sonnet-latest"
timeout_minutes: "30"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Normalize JUnit for consumer actions (strong)
if: always()
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
import xml.etree.ElementTree as ET
import 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-nl-suite.xml'))
out = Path('reports/junit-for-actions.xml')
out.parent.mkdir(parents=True, exist_ok=True)
if not src.exists():
# Try to use any existing XML as a source (e.g., claude-nl-tests.xml)
candidates = sorted(Path('reports').glob('*.xml'))
if candidates:
src = candidates[0]
else:
print("WARN: no XML source found for normalization")
if src.exists():
try:
root = ET.parse(src).getroot()
rtag = localname(root.tag)
if rtag == 'testsuites' and len(root) == 1 and localname(root[0].tag) == 'testsuite':
ET.ElementTree(root[0]).write(out, encoding='utf-8', xml_declaration=True)
else:
out.write_bytes(src.read_bytes())
except Exception as e:
print("Normalization error:", e)
out.write_bytes(src.read_bytes())
# Always create a second copy with a junit-* name so wildcard patterns match too
if out.exists():
Path('reports/junit-nl-suite-copy.xml').write_bytes(out.read_bytes())
PY
- name: "Debug: list report files"
if: always()
shell: bash
run: |
set -eux
ls -la reports || true
shopt -s nullglob
for f in reports/*.xml; do
echo "===== $f ====="
head -n 40 "$f" || true
done
# sanitize only the markdown (does not touch JUnit xml)
- name: Sanitize markdown (all shards)
if: always()
run: |
set -eu
python - <<'PY'
from pathlib import Path
rp=Path('reports')
rp.mkdir(parents=True, exist_ok=True)
for p in rp.glob('*.md'):
b=p.read_bytes().replace(b'\x00', b'')
s=b.decode('utf-8','replace').replace('\r\n','\n')
p.write_text(s, encoding='utf-8', newline='\n')
PY
- name: NL/T details → Job Summary
if: always()
run: |
echo "## Unity NL/T Editing Suite — Full Coverage" >> $GITHUB_STEP_SUMMARY
python - <<'PY' >> $GITHUB_STEP_SUMMARY
from pathlib import Path
p = Path('reports/junit-nl-suite.md') if Path('reports/junit-nl-suite.md').exists() else Path('reports/claude-nl-tests.md')
if p.exists():
text = p.read_bytes().decode('utf-8', 'replace')
MAX = 65000
print(text[:MAX])
if len(text) > MAX:
print("\n\n_…truncated in summary; full report is in artifacts._")
else:
print("_No markdown report found._")
PY
- name: Fallback JUnit if missing
if: always()
run: |
set -eu
mkdir -p reports
if [ ! -f reports/junit-for-actions.xml ]; then
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0">' \
' <testcase classname="UnityMCP.NL-T" name="NL-Suite.Execution" time="0.0">' \
' <failure><![CDATA[No JUnit was produced by the NL suite step. See the '"'"'Run Claude NL suite (single pass)'"'"' logs.]]></failure>' \
' </testcase>' \
'</testsuite>' \
> reports/junit-for-actions.xml
fi
- name: Publish JUnit reports
if: always()
uses: mikepenz/action-junit-report@v5
with:
report_paths: 'reports/junit-for-actions.xml'
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-nl-suite-artifacts
path: reports/**
# ---------- Always stop Unity ----------
- name: Stop Unity
if: always()
run: |
docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
docker rm -f unity-mcp || true

543
.github/workflows/claude-nl-suite.yml vendored Normal file
View File

@ -0,0 +1,543 @@
name: Claude NL/T Full Suite (Unity live)
on:
workflow_dispatch: {}
permissions:
contents: read
checks: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
UNITY_VERSION: 2021.3.45f1
UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3
UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home
jobs:
nl-suite:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
JUNIT_OUT: reports/junit-nl-suite.xml
MD_OUT: reports/junit-nl-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" ]; } || [ -n "$UNITY_SERIAL" ]; 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 UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then
uv pip install -e UnityMcpBridge/UnityMcpServer~/src
elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then
uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt
elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then
uv pip install -e UnityMcpBridge/UnityMcpServer~/
elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then
uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt
else
echo "No MCP Python deps found (skipping)"
fi
# ---------- License prime on host (GameCI) ----------
- name: Prime Unity license on host (GameCI)
if: steps.detect.outputs.unity_ok == 'true'
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
with:
projectPath: TestProjects/UnityMCPTests
testMode: EditMode
customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics
unityVersion: ${{ env.UNITY_VERSION }}
# (Optional) Inspect license caches
- name: Inspect GameCI license caches (host)
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -eux
find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true
# ---------- Clean old MCP status ----------
- name: Clean old MCP status
run: |
set -eux
mkdir -p "$HOME/.unity-mcp"
rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true
# ---------- Start headless Unity (persistent bridge) ----------
- name: Start Unity (persistent bridge)
if: steps.detect.outputs.unity_ok == 'true'
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
run: |
set -eu
if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then
echo "Unity project not found; failing fast."
exit 1
fi
mkdir -p "$HOME/.unity-mcp"
MANUAL_ARG=()
if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then
MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf)
fi
EBL_ARGS=()
[ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL")
[ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL")
[ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD")
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=/root/.unity-mcp \
-e UNITY_MCP_BIND_HOST=127.0.0.1 \
-v "${{ github.workspace }}:/workspace" -w /workspace \
-v "${{ env.UNITY_CACHE_ROOT }}:/root" \
-v "$HOME/.unity-mcp:/root/.unity-mcp" \
${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
-stackTraceLogType Full \
-projectPath /workspace/TestProjects/UnityMCPTests \
"${MANUAL_ARG[@]}" \
"${EBL_ARGS[@]}" \
-executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect
# ---------- Wait for Unity bridge ----------
- name: Wait for Unity bridge (robust)
if: steps.detect.outputs.unity_ok == 'true'
run: |
set -euo pipefail
if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then
echo "Unity container failed to start"; docker ps -a || true; exit 1
fi
docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$!
deadline=$((SECONDS+420)); READY=0
try_connect_host() {
P="$1"
timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true
if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi
return 1
}
while [ $SECONDS -lt $deadline ]; do
if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then
READY=1; echo "Bridge ready (log markers)"; break
fi
PORT=$(python3 -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true)
if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then
READY=1; echo "Bridge ready on port $PORT"; break
fi
if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then
echo "Licensing error detected"; break
fi
sleep 2
done
kill $LOGPID || true
if [ "$READY" != "1" ]; then
echo "Bridge not ready; diagnostics:"
echo "== status files =="; ls -la "$HOME/.unity-mcp" || true
echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done
echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true'
echo "== tail of Unity log =="
docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
exit 1
fi
# ---------- MCP client config ----------
- name: Write MCP config (.claude/mcp.json)
run: |
set -eux
mkdir -p .claude
cat > .claude/mcp.json <<JSON
{
"mcpServers": {
"unity": {
"command": "uv",
"args": ["run","--active","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"],
"transport": { "type": "stdio" },
"env": {
"PYTHONUNBUFFERED": "1",
"MCP_LOG_LEVEL": "debug",
"UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests"
}
}
}
}
JSON
# ---------- Reports & helper ----------
- name: Prepare reports and dirs
run: |
set -eux
rm -f reports/*.xml reports/*.md || true
mkdir -p reports reports/_snapshots scripts
- name: Create report skeletons
run: |
set -eu
cat > "$JUNIT_OUT" <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<testsuites><testsuite name="UnityMCP.NL-T" tests="1" failures="1" errors="0" skipped="0" time="0">
<testcase name="NL-Suite.Bootstrap" classname="UnityMCP.NL-T">
<failure message="bootstrap">Bootstrap placeholder; suite will append real tests.</failure>
</testcase>
</testsuite></testsuites>
XML
printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT"
- name: Write safe revert helper (scripts/nlt-revert.sh)
shell: bash
run: |
set -eux
cat > scripts/nlt-revert.sh <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
sub="${1:-}"; target_rel="${2:-}"; snap="${3:-}"
WS="${GITHUB_WORKSPACE:-$PWD}"
ROOT="$WS/TestProjects/UnityMCPTests"
t_abs="$(realpath -m "$WS/$target_rel")"
s_abs="$(realpath -m "$WS/$snap")"
if [[ "$t_abs" != "$ROOT/Assets/"* ]]; then
echo "refuse: target outside allowed scope: $t_abs" >&2; exit 2
fi
mkdir -p "$(dirname "$s_abs")"
case "$sub" in
snapshot)
cp -f "$t_abs" "$s_abs"
sha=$(sha256sum "$s_abs" | awk '{print $1}')
echo "snapshot_sha=$sha"
;;
restore)
if [[ ! -f "$s_abs" ]]; then echo "snapshot missing: $s_abs" >&2; exit 3; fi
cp -f "$s_abs" "$t_abs"
touch "$t_abs"
sha=$(sha256sum "$t_abs" | awk '{print $1}')
echo "restored_sha=$sha"
;;
*)
echo "usage: $0 snapshot|restore <target_rel_path> <snapshot_path>" >&2; exit 1
;;
esac
BASH
chmod +x scripts/nlt-revert.sh
# ---------- Snapshot baseline (pre-agent) ----------
- name: Snapshot baseline (pre-agent)
if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true'
shell: bash
run: |
set -euo pipefail
TARGET="TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
SNAP="reports/_snapshots/LongUnityScriptClaudeTest.cs.baseline"
scripts/nlt-revert.sh snapshot "$TARGET" "$SNAP"
# ---------- Run suite ----------
- name: Run Claude NL suite (single 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
with:
use_node_cache: false
prompt_file: .claude/prompts/nl-unity-suite-full-additive.md
mcp_config: .claude/mcp.json
allowed_tools: >-
Write,
Bash(scripts/nlt-revert.sh:*),
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
disallowed_tools: TodoWrite,Task
model: claude-3-7-sonnet-latest
timeout_minutes: "30"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# ---------- Merge testcase fragments into JUnit ----------
- name: Normalize/assemble JUnit in-place (single file)
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-nl-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)
fragments = sorted(Path('reports').glob('*_results.xml'))
added = 0
for frag in fragments:
try:
froot = ET.parse(frag).getroot()
if localname(froot.tag) == 'testcase':
suite.append(froot); added += 1
else:
for tc in froot.findall('.//testcase'):
suite.append(tc); added += 1
except Exception:
txt = Path(frag).read_text(encoding='utf-8', errors='replace')
for m in re.findall(r'<testcase[\\s\\S]*?</testcase>', txt, flags=re.DOTALL):
try: suite.append(ET.fromstring(m)); added += 1
except Exception: pass
if added:
# Drop bootstrap placeholder and recompute counts
removed_bootstrap = 0
for tc in list(suite.findall('.//testcase')):
name = (tc.get('name') or '')
if name == 'NL-Suite.Bootstrap':
suite.remove(tc)
removed_bootstrap += 1
testcases = suite.findall('.//testcase')
tests_cnt = len(testcases)
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(tests_cnt))
suite.set('failures', str(failures_cnt))
suite.set('errors', str(0))
suite.set('skipped', str(0))
tree.write(src, encoding='utf-8', xml_declaration=True)
print(f"Added {added} testcase fragments; removed bootstrap={removed_bootstrap}; tests={tests_cnt}; failures={failures_cnt}")
PY
# ---------- Markdown summary from JUnit ----------
- name: Build markdown summary from JUnit
if: always()
shell: bash
run: |
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path
import os, html
def localname(tag: str) -> str:
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml'))
md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md'))
# Ensure destination directory exists even if earlier prep steps were skipped
md_out.parent.mkdir(parents=True, exist_ok=True)
if not src.exists():
md_out.write_text("# Unity NL/T Editing Suite 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'))
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
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']
name_to_case = {(tc.get('name') or ''): tc for tc in cases}
def status_for(prefix: str):
for name, tc in name_to_case.items():
if name.startswith(prefix):
return not ((tc.find('failure') is not None) or (tc.find('error') is not None))
return None
lines = []
lines += [
'# Unity NL/T Editing Suite Test Results',
'',
f'Totals: {passed} passed, {failures} failed, {total} total',
'',
'## Test Checklist'
]
for p in desired:
st = status_for(p)
lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)"))
lines.append('')
# Rich per-test system-out details
lines.append('## Test Details')
def order_key(n: str):
try:
if n.startswith('NL-') and n[3].isdigit():
return (0, int(n.split('.')[0].split('-')[1]))
except Exception:
pass
if n.startswith('T-') and len(n) > 2 and n[2].isalpha():
return (1, ord(n[2]))
return (2, n)
MAX_CHARS = 2000
for name in sorted(name_to_case.keys(), key=order_key):
tc = name_to_case[name]
status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL"
lines.append(f"### {name} — {status_badge}")
so = tc.find('system-out')
text = '' if so is None or so.text is None else so.text.replace('\r\n','\n')
# Unescape XML entities so code reads naturally (e.g., => instead of =&gt;)
if text:
text = html.unescape(text)
if text.strip():
t = text.strip()
if len(t) > MAX_CHARS:
t = t[:MAX_CHARS] + "\n…(truncated)"
# Use a safer fence if content contains triple backticks
fence = '```'
if '```' in t:
fence = '````'
lines.append(fence)
lines.append(t)
lines.append(fence)
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()
body = (node.text or '').strip()
if msg: lines.append(f"- Message: {msg}")
if body: lines.append(f"- Detail: {body.splitlines()[0][:500]}")
lines.append('')
md_out.write_text('\n'.join(lines), encoding='utf-8')
PY
- name: "Debug: list report files"
if: always()
shell: bash
run: |
set -eux
ls -la reports || true
shopt -s nullglob
for f in reports/*.xml; do
echo "===== $f ====="
head -n 40 "$f" || true
done
# ---------- Collect execution transcript (if present) ----------
- name: Collect action execution transcript
if: always()
shell: bash
run: |
set -eux
if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then
cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json
elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then
cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json
fi
- name: Sanitize markdown (normalize newlines)
if: always()
run: |
set -eu
python3 - <<'PY'
from pathlib import Path
rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True)
for p in rp.glob('*.md'):
b=p.read_bytes().replace(b'\x00', b'')
s=b.decode('utf-8','replace').replace('\r\n','\n')
p.write_text(s, encoding='utf-8', newline='\n')
PY
- name: NL/T details → Job Summary
if: always()
run: |
echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY
python3 - <<'PY' >> $GITHUB_STEP_SUMMARY
from pathlib import Path
p = Path('reports/junit-nl-suite.md')
if p.exists():
text = p.read_bytes().decode('utf-8', 'replace')
MAX = 65000
print(text[:MAX])
if len(text) > MAX:
print("\n\n_…truncated; full report in artifacts._")
else:
print("_No markdown report found._")
PY
- name: Fallback JUnit if missing
if: always()
run: |
set -eu
mkdir -p reports
if [ ! -f "$JUNIT_OUT" ]; then
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<testsuite name="UnityMCP.NL-T" tests="1" failures="1" time="0">' \
' <testcase classname="UnityMCP.NL-T" name="NL-Suite.Execution" time="0.0">' \
' <failure><![CDATA[No JUnit was produced by the NL suite step. See the step logs.]]></failure>' \
' </testcase>' \
'</testsuite>' \
> "$JUNIT_OUT"
fi
- 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 (reports + fragments + transcript)
if: always()
uses: actions/upload-artifact@v4
with:
name: claude-nl-suite-artifacts
path: |
${{ env.JUNIT_OUT }}
${{ env.MD_OUT }}
reports/*_results.xml
reports/claude-execution-output.json
retention-days: 7
# ---------- Always stop Unity ----------
- name: Stop Unity
if: always()
run: |
docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
docker rm -f unity-mcp || true

8
.gitignore vendored
View File

@ -33,4 +33,10 @@ CONTRIBUTING.md.meta
.idea/
.vscode/
.aider*
.DS_Store*
.DS_Store*
# Unity test project lock files
TestProjects/UnityMCPTests/Packages/packages-lock.json
# Backup artifacts
*.backup
*.backup.meta

View File

@ -66,6 +66,41 @@ To find it reliably:
Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server.
## CI Test Workflow (GitHub Actions)
We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge.
- Trigger: Workflow dispatch (`Claude NL suite (Unity live)`).
- Image: `UNITY_IMAGE` (UnityCI) pulled by tag; the job resolves a digest at runtime. Logs are sanitized.
- Reports: JUnit at `reports/junit-nl-suite.xml`, Markdown at `reports/junit-nl-suite.md`.
- Publishing: JUnit is normalized to `reports/junit-for-actions.xml` and published; artifacts upload all files under `reports/`.
### Test target script
- The repo includes a long, standalone C# script used to exercise larger edits and windows:
- `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs`
Use this file locally and in CI to validate multi-edit batches, anchor inserts, and windowed reads on a sizable script.
### Add a new NL test
- Edit `.claude/prompts/nl-unity-claude-tests-mini.md` (or `nl-unity-suite-full.md` for the larger suite).
- Follow the conventions: single `<testsuite>` root, one `<testcase>` per sub-test, end system-out with `VERDICT: PASS|FAIL`.
- Keep edits minimal and reversible; include evidence windows and compact diffs.
### Run the suite
1) Push your branch, then manually run the workflow from the Actions tab.
2) The job writes reports into `reports/` and uploads artifacts.
3) The “JUnit Test Report” check summarizes results; open the Job Summary for full markdown.
### View results
- Job Summary: inline markdown summary of the run on the Actions tab in GitHub
- Check: “JUnit Test Report” on the PR/commit.
- Artifacts: `claude-nl-suite-artifacts` includes XML and MD.
### MCP Connection Debugging
- *Enable debug logs* in the Unity MCP window (inside the Editor) to view connection status, auto-setup results, and MCP client paths. It shows:
- bridge startup/port, client connections, strict framing negotiation, and parsed frames
- auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and surfaced errors
- In CI, the job tails Unity logs (redacted for serial/license/password/token) and prints socket/status JSON diagnostics if startup fails.
## Workflow
1. **Make changes** to your source code in this directory

View File

@ -1,6 +1,6 @@
# MCP for Unity ✨
#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp), the AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces)
#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp) -- the best AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces)
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive)
@ -43,6 +43,9 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
* `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations.
* `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project").
* `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches.
* `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.
</details>
---

View File

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

View File

@ -1,11 +1,15 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Collections;
public class Hello : MonoBehaviour
{
// Use this for initialization
void Start()
{
Debug.Log("Hello World");
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfbabf507ab1245178d1a8e745d8d283

View File

@ -0,0 +1,18 @@
using UnityEngine;
namespace TestNamespace
{
public class CustomComponent : MonoBehaviour
{
[SerializeField]
private string customText = "Hello from custom asmdef!";
[SerializeField]
private float customFloat = 42.0f;
void Start()
{
Debug.Log($"CustomComponent started: {customText}, value: {customFloat}");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78ee39b9744834fe390a4c7c5634eb5a

View File

@ -0,0 +1,14 @@
{
"name": "TestAsmdef",
"rootNamespace": "TestNamespace",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 72f6376fa7bdc4220b11ccce20108cdc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -3,6 +3,7 @@
"rootNamespace": "",
"references": [
"MCPForUnity.Editor",
"TestAsmdef",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],

View File

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using static MCPForUnity.Editor.Tools.ManageGameObject;
namespace MCPForUnityTests.Editor.Tools
{
public class AIPropertyMatchingTests
{
private List<string> sampleProperties;
[SetUp]
public void SetUp()
{
sampleProperties = new List<string>
{
"maxReachDistance",
"maxHorizontalDistance",
"maxVerticalDistance",
"moveSpeed",
"healthPoints",
"playerName",
"isEnabled",
"mass",
"velocity",
"transform"
};
}
[Test]
public void GetAllComponentProperties_ReturnsValidProperties_ForTransform()
{
var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform));
Assert.IsNotEmpty(properties, "Transform should have properties");
Assert.Contains("position", properties, "Transform should have position property");
Assert.Contains("rotation", properties, "Transform should have rotation property");
Assert.Contains("localScale", properties, "Transform should have localScale property");
}
[Test]
public void GetAllComponentProperties_ReturnsEmpty_ForNullType()
{
var properties = ComponentResolver.GetAllComponentProperties(null);
Assert.IsEmpty(properties, "Null type should return empty list");
}
[Test]
public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties);
Assert.IsEmpty(suggestions, "Null input should return no suggestions");
}
[Test]
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties);
Assert.IsEmpty(suggestions, "Empty input should return no suggestions");
}
[Test]
public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List<string>());
Assert.IsEmpty(suggestions, "Empty property list should return no suggestions");
}
[Test]
public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces");
Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match");
}
[Test]
public void GetAIPropertySuggestions_FindsMultipleWordMatches()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance");
Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance");
Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance");
}
[Test]
public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S
Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital");
}
[Test]
public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms()
{
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties);
// Note: Current algorithm might not find "mass" but should handle it gracefully
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
}
[Test]
public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber()
{
// Test with input that might match many properties
var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties);
Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer");
}
[Test]
public void GetAIPropertySuggestions_CachesResults()
{
var input = "Max Reach Distance";
// First call
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
// Second call should use cache (tested indirectly by ensuring consistency)
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties);
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent");
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical");
}
[Test]
public void GetAIPropertySuggestions_HandlesUnityNamingConventions()
{
var unityStyleProperties = new List<string> { "isKinematic", "useGravity", "maxLinearVelocity" };
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties);
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties);
var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties);
Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention");
Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention");
Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention");
}
[Test]
public void GetAIPropertySuggestions_PrioritizesExactMatches()
{
var properties = new List<string> { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" };
var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties);
Assert.IsNotEmpty(suggestions, "Should find suggestions");
Assert.Contains("speed", suggestions, "Exact match should be included in results");
// Note: Implementation may or may not prioritize exact matches first
}
[Test]
public void GetAIPropertySuggestions_HandlesCaseInsensitive()
{
var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties);
var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties);
Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input");
Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e4468da1a15349029e52570b84ec4b0

View File

@ -0,0 +1,146 @@
using System;
using NUnit.Framework;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using static MCPForUnity.Editor.Tools.ManageGameObject;
namespace MCPForUnityTests.Editor.Tools
{
public class ComponentResolverTests
{
[Test]
public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName()
{
bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error);
Assert.IsTrue(result, "Should resolve Transform component");
Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type");
Assert.IsEmpty(error, "Should have no error message");
}
[Test]
public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName()
{
bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error);
Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component");
Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type");
Assert.IsEmpty(error, "Should have no error message");
}
[Test]
public void TryResolve_ReturnsTrue_ForCustomComponentShortName()
{
bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve CustomComponent");
Assert.IsNotNull(type, "Should return valid type");
Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name");
Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type");
Assert.IsEmpty(error, "Should have no error message");
}
[Test]
public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName()
{
bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent");
Assert.IsNotNull(type, "Should return valid type");
Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name");
Assert.AreEqual("TestNamespace.CustomComponent", type.FullName, "Should have correct full name");
Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Should be a Component type");
Assert.IsEmpty(error, "Should have no error message");
}
[Test]
public void TryResolve_ReturnsFalse_ForNonExistentComponent()
{
bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error);
Assert.IsFalse(result, "Should not resolve non-existent component");
Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message");
Assert.That(error, Does.Contain("not found"), "Error should mention component not found");
}
[Test]
public void TryResolve_ReturnsFalse_ForEmptyString()
{
bool result = ComponentResolver.TryResolve("", out Type type, out string error);
Assert.IsFalse(result, "Should not resolve empty string");
Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message");
}
[Test]
public void TryResolve_ReturnsFalse_ForNullString()
{
bool result = ComponentResolver.TryResolve(null, out Type type, out string error);
Assert.IsFalse(result, "Should not resolve null string");
Assert.IsNull(type, "Should return null type");
Assert.IsNotEmpty(error, "Should have error message");
Assert.That(error, Does.Contain("null or empty"), "Error should mention null or empty");
}
[Test]
public void TryResolve_CachesResolvedTypes()
{
// First call
bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1);
// Second call should use cache
bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2);
Assert.IsTrue(result1, "First call should succeed");
Assert.IsTrue(result2, "Second call should succeed");
Assert.AreSame(type1, type2, "Should return same type instance (cached)");
Assert.IsEmpty(error1, "First call should have no error");
Assert.IsEmpty(error2, "Second call should have no error");
}
[Test]
public void TryResolve_PrefersPlayerAssemblies()
{
// Test that custom user scripts (in Player assemblies) are found
bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error);
Assert.IsTrue(result, "Should resolve user script from Player assembly");
Assert.IsNotNull(type, "Should return valid type");
// Verify it's not from an Editor assembly by checking the assembly name
string assemblyName = type.Assembly.GetName().Name;
Assert.That(assemblyName, Does.Not.Contain("Editor"),
"User script should come from Player assembly, not Editor assembly");
// Verify it's from the TestAsmdef assembly (which is a Player assembly)
Assert.AreEqual("TestAsmdef", assemblyName,
"CustomComponent should be resolved from TestAsmdef assembly");
}
[Test]
public void TryResolve_HandlesDuplicateNames_WithAmbiguityError()
{
// This test would need duplicate component names to be meaningful
// For now, test with a built-in component that should not have duplicates
bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error);
Assert.IsTrue(result, "Transform should resolve uniquely");
Assert.AreEqual(typeof(Transform), type, "Should return correct type");
Assert.IsEmpty(error, "Should have no ambiguity error");
}
[Test]
public void ResolvedType_IsValidComponent()
{
bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error);
Assert.IsTrue(result, "Should resolve Rigidbody");
Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component");
Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) ||
typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c15ba6502927e4901a43826c43debd7c

View File

@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEditor;
using UnityEngine.TestTools;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
public class ManageGameObjectTests
{
private GameObject testGameObject;
[SetUp]
public void SetUp()
{
// Create a test GameObject for each test
testGameObject = new GameObject("TestObject");
}
[TearDown]
public void TearDown()
{
// Clean up test GameObject
if (testGameObject != null)
{
UnityEngine.Object.DestroyImmediate(testGameObject);
}
}
[Test]
public void HandleCommand_ReturnsError_ForNullParams()
{
var result = ManageGameObject.HandleCommand(null);
Assert.IsNotNull(result, "Should return a result object");
// Note: Actual error checking would need access to Response structure
}
[Test]
public void HandleCommand_ReturnsError_ForEmptyParams()
{
var emptyParams = new JObject();
var result = ManageGameObject.HandleCommand(emptyParams);
Assert.IsNotNull(result, "Should return a result object for empty params");
}
[Test]
public void HandleCommand_ProcessesValidCreateAction()
{
var createParams = new JObject
{
["action"] = "create",
["name"] = "TestCreateObject"
};
var result = ManageGameObject.HandleCommand(createParams);
Assert.IsNotNull(result, "Should return a result for valid create action");
// Clean up - find and destroy the created object
var createdObject = GameObject.Find("TestCreateObject");
if (createdObject != null)
{
UnityEngine.Object.DestroyImmediate(createdObject);
}
}
[Test]
public void ComponentResolver_Integration_WorksWithRealComponents()
{
// Test that our ComponentResolver works with actual Unity components
var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error);
Assert.IsTrue(transformResult, "Should resolve Transform component");
Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type");
Assert.IsEmpty(error, "Should have no error for valid component");
}
[Test]
public void ComponentResolver_Integration_WorksWithBuiltInComponents()
{
var components = new[]
{
("Rigidbody", typeof(Rigidbody)),
("Collider", typeof(Collider)),
("Renderer", typeof(Renderer)),
("Camera", typeof(Camera)),
("Light", typeof(Light))
};
foreach (var (componentName, expectedType) in components)
{
var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error);
// Some components might not resolve (abstract classes), but the method should handle gracefully
if (result)
{
Assert.IsTrue(expectedType.IsAssignableFrom(actualType),
$"{componentName} should resolve to assignable type");
}
else
{
Assert.IsNotEmpty(error, $"Should have error message for {componentName}");
}
}
}
[Test]
public void PropertyMatching_Integration_WorksWithRealGameObject()
{
// Add a Rigidbody to test real property matching
var rigidbody = testGameObject.AddComponent<Rigidbody>();
var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody));
Assert.IsNotEmpty(properties, "Rigidbody should have properties");
Assert.Contains("mass", properties, "Rigidbody should have mass property");
Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property");
// Test AI suggestions
var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties);
Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'");
}
[Test]
public void PropertyMatching_HandlesMonoBehaviourProperties()
{
var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour));
Assert.IsNotEmpty(properties, "MonoBehaviour should have properties");
Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property");
Assert.Contains("name", properties, "MonoBehaviour should have name property");
Assert.Contains("tag", properties, "MonoBehaviour should have tag property");
}
[Test]
public void PropertyMatching_HandlesCaseVariations()
{
var testProperties = new List<string> { "maxReachDistance", "playerHealth", "movementSpeed" };
var testCases = new[]
{
("max reach distance", "maxReachDistance"),
("Max Reach Distance", "maxReachDistance"),
("MAX_REACH_DISTANCE", "maxReachDistance"),
("player health", "playerHealth"),
("movement speed", "movementSpeed")
};
foreach (var (input, expected) in testCases)
{
var suggestions = ComponentResolver.GetAIPropertySuggestions(input, testProperties);
Assert.Contains(expected, suggestions, $"Should suggest {expected} for input '{input}'");
}
}
[Test]
public void ErrorHandling_ReturnsHelpfulMessages()
{
// This test verifies that error messages are helpful and contain suggestions
var testProperties = new List<string> { "mass", "velocity", "drag", "useGravity" };
var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties);
// Even if no perfect match, should return valid list
Assert.IsNotNull(suggestions, "Should return valid suggestions list");
// Test with completely invalid input
var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties);
Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully");
}
[Test]
public void PerformanceTest_CachingWorks()
{
var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform));
var input = "Test Property Name";
// First call - populate cache
var startTime = System.DateTime.UtcNow;
var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties);
var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
// Second call - should use cache
startTime = System.DateTime.UtcNow;
var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties);
var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds;
Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical");
CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly");
// Second call should be faster (though this test might be flaky)
Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower");
}
[Test]
public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes()
{
// Arrange - add Transform and Rigidbody components to test with
var transform = testGameObject.transform;
var rigidbody = testGameObject.AddComponent<Rigidbody>();
// Create a params object with mixed valid and invalid properties
var setPropertiesParams = new JObject
{
["action"] = "modify",
["target"] = testGameObject.name,
["search_method"] = "by_name",
["componentProperties"] = new JObject
{
["Transform"] = new JObject
{
["localPosition"] = new JObject { ["x"] = 1.0f, ["y"] = 2.0f, ["z"] = 3.0f }, // Valid
["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation)
["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid
},
["Rigidbody"] = new JObject
{
["mass"] = 5.0f, // Valid
["invalidProp"] = "test", // Invalid - doesn't exist
["useGravity"] = true // Valid
}
}
};
// Store original values to verify changes
var originalLocalPosition = transform.localPosition;
var originalLocalScale = transform.localScale;
var originalMass = rigidbody.mass;
var originalUseGravity = rigidbody.useGravity;
Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}");
// Expect the warning logs from the invalid properties
LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'rotatoin' not found"));
LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'invalidProp' not found"));
// Act
var result = ManageGameObject.HandleCommand(setPropertiesParams);
Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}");
Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}");
Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}");
// Assert - verify that valid properties were set despite invalid ones
Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition,
"Valid localPosition should be set even with other invalid properties");
Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale,
"Valid localScale should be set even with other invalid properties");
Assert.AreEqual(5.0f, rigidbody.mass, 0.001f,
"Valid mass should be set even with other invalid properties");
Assert.AreEqual(true, rigidbody.useGravity,
"Valid useGravity should be set even with other invalid properties");
// Verify the result indicates errors (since we had invalid properties)
Assert.IsNotNull(result, "Should return a result object");
// The collect-and-continue behavior means we should get an error response
// that contains info about the failed properties, but valid ones were still applied
// This proves the collect-and-continue behavior is working
}
[Test]
public void SetComponentProperties_ContinuesAfterException()
{
// Arrange - create scenario that might cause exceptions
var rigidbody = testGameObject.AddComponent<Rigidbody>();
// Set initial values that we'll change
rigidbody.mass = 1.0f;
rigidbody.useGravity = true;
var setPropertiesParams = new JObject
{
["action"] = "modify",
["target"] = testGameObject.name,
["search_method"] = "by_name",
["componentProperties"] = new JObject
{
["Rigidbody"] = new JObject
{
["mass"] = 2.5f, // Valid - should be set
["velocity"] = "invalid_type", // Invalid type - will cause exception
["useGravity"] = false // Valid - should still be set after exception
}
}
};
// Expect the error logs from the invalid property
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Unexpected error converting token to UnityEngine.Vector3"));
LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("SetProperty.*Failed to set 'velocity'"));
LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("Property 'velocity' not found"));
// Act
var result = ManageGameObject.HandleCommand(setPropertiesParams);
// Assert - verify that valid properties before AND after the exception were still set
Assert.AreEqual(2.5f, rigidbody.mass, 0.001f,
"Mass should be set even if later property causes exception");
Assert.AreEqual(false, rigidbody.useGravity,
"UseGravity should be set even if previous property caused exception");
Assert.IsNotNull(result, "Should return a result even with exceptions");
// The key test: processing continued after the exception and set useGravity
// This proves the collect-and-continue behavior works even with exceptions
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5931268353eab4ea5baa054e6231e824

View File

@ -0,0 +1,182 @@
using System;
using NUnit.Framework;
using UnityEngine;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools;
using System.Reflection;
namespace MCPForUnityTests.Editor.Tools
{
/// <summary>
/// In-memory tests for ManageScript validation logic.
/// These tests focus on the validation methods directly without creating files.
/// </summary>
public class ManageScriptValidationTests
{
[Test]
public void HandleCommand_NullParams_ReturnsError()
{
var result = ManageScript.HandleCommand(null);
Assert.IsNotNull(result, "Should handle null parameters gracefully");
}
[Test]
public void HandleCommand_InvalidAction_ReturnsError()
{
var paramsObj = new JObject
{
["action"] = "invalid_action",
["name"] = "TestScript",
["path"] = "Assets/Scripts"
};
var result = ManageScript.HandleCommand(paramsObj);
Assert.IsNotNull(result, "Should return error result for invalid action");
}
[Test]
public void CheckBalancedDelimiters_ValidCode_ReturnsTrue()
{
string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}";
bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected);
Assert.IsTrue(result, "Valid C# code should pass balance check");
}
[Test]
public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse()
{
string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace";
bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected);
Assert.IsFalse(result, "Unbalanced code should fail balance check");
}
[Test]
public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue()
{
string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}";
bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected);
Assert.IsTrue(result, "Code with braces in strings should pass balance check");
}
[Test]
public void CheckScopedBalance_ValidCode_ReturnsTrue()
{
string validCode = "{ Debug.Log(\"test\"); }";
bool result = CallCheckScopedBalance(validCode, 0, validCode.Length);
Assert.IsTrue(result, "Valid scoped code should pass balance check");
}
[Test]
public void CheckScopedBalance_ShouldTolerateOuterContext_ReturnsTrue()
{
// This simulates a snippet extracted from a larger context
string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context";
bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length);
// Scoped balance should tolerate some imbalance from outer context
Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance");
}
[Test]
public void TicTacToe3D_ValidationScenario_DoesNotCrash()
{
// Test the scenario that was causing issues without file I/O
string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}";
// Test that the validation methods don't crash on this code
bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected);
bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length);
Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation");
Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation");
}
// Helper methods to access private ManageScript methods via reflection
private bool CallCheckBalancedDelimiters(string contents, out int line, out char expected)
{
line = 0;
expected = ' ';
try
{
var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters",
BindingFlags.NonPublic | BindingFlags.Static);
if (method != null)
{
var parameters = new object[] { contents, line, expected };
var result = (bool)method.Invoke(null, parameters);
line = (int)parameters[1];
expected = (char)parameters[2];
return result;
}
}
catch (Exception ex)
{
Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}");
}
// Fallback: basic structural check
return BasicBalanceCheck(contents);
}
private bool CallCheckScopedBalance(string text, int start, int end)
{
try
{
var method = typeof(ManageScript).GetMethod("CheckScopedBalance",
BindingFlags.NonPublic | BindingFlags.Static);
if (method != null)
{
return (bool)method.Invoke(null, new object[] { text, start, end });
}
}
catch (Exception ex)
{
Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}");
}
return true; // Default to passing if we can't test the actual method
}
private bool BasicBalanceCheck(string contents)
{
// Simple fallback balance check
int braceCount = 0;
bool inString = false;
bool escaped = false;
for (int i = 0; i < contents.Length; i++)
{
char c = contents[i];
if (escaped)
{
escaped = false;
continue;
}
if (inString)
{
if (c == '\\') escaped = true;
else if (c == '"') inString = false;
continue;
}
if (c == '"') inString = true;
else if (c == '{') braceCount++;
else if (c == '}') braceCount--;
if (braceCount < 0) return false;
}
return braceCount == 0;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b8f7e3d1c4a2b5f8e9d6c3a7b1e4f7d2

View File

@ -1,417 +0,0 @@
{
"dependencies": {
"com.coplaydev.unity-mcp": {
"version": "file:../../../UnityMcpBridge",
"depth": 0,
"source": "local",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.0.2"
}
},
"com.unity.collab-proxy": {
"version": "2.5.2",
"depth": 0,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.editorcoroutines": {
"version": "1.0.0",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.ext.nunit": {
"version": "1.0.6",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.feature.development": {
"version": "1.0.1",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.ide.visualstudio": "2.0.22",
"com.unity.ide.rider": "3.0.31",
"com.unity.ide.vscode": "1.2.5",
"com.unity.editorcoroutines": "1.0.0",
"com.unity.performance.profile-analyzer": "1.2.2",
"com.unity.test-framework": "1.1.33",
"com.unity.testtools.codecoverage": "1.2.6"
}
},
"com.unity.ide.rider": {
"version": "3.0.31",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.ext.nunit": "1.0.6"
},
"url": "https://packages.unity.com"
},
"com.unity.ide.visualstudio": {
"version": "2.0.22",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.test-framework": "1.1.9"
},
"url": "https://packages.unity.com"
},
"com.unity.ide.vscode": {
"version": "1.2.5",
"depth": 0,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.ide.windsurf": {
"version": "https://github.com/Asuta/com.unity.ide.windsurf.git",
"depth": 0,
"source": "git",
"dependencies": {
"com.unity.test-framework": "1.1.9"
},
"hash": "6161accf3e7beab96341813913e714c7e2fb5c5d"
},
"com.unity.nuget.newtonsoft-json": {
"version": "3.2.1",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.performance.profile-analyzer": {
"version": "1.2.2",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.settings-manager": {
"version": "1.0.3",
"depth": 2,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
},
"com.unity.test-framework": {
"version": "1.1.33",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.ext.nunit": "1.0.6",
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.testtools.codecoverage": {
"version": "1.2.6",
"depth": 1,
"source": "registry",
"dependencies": {
"com.unity.test-framework": "1.0.16",
"com.unity.settings-manager": "1.0.1"
},
"url": "https://packages.unity.com"
},
"com.unity.textmeshpro": {
"version": "3.0.6",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.ugui": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.timeline": {
"version": "1.6.5",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.director": "1.0.0",
"com.unity.modules.animation": "1.0.0",
"com.unity.modules.particlesystem": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.ugui": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.ui": "1.0.0",
"com.unity.modules.imgui": "1.0.0"
}
},
"com.unity.visualscripting": {
"version": "1.9.4",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.ugui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
},
"url": "https://packages.unity.com"
},
"com.unity.modules.ai": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.androidjni": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.animation": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.assetbundle": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.audio": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.cloth": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.physics": "1.0.0"
}
},
"com.unity.modules.director": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.animation": "1.0.0"
}
},
"com.unity.modules.imageconversion": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.imgui": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.jsonserialize": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.particlesystem": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.physics": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.physics2d": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.screencapture": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.imageconversion": "1.0.0"
}
},
"com.unity.modules.subsystems": {
"version": "1.0.0",
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.modules.jsonserialize": "1.0.0"
}
},
"com.unity.modules.terrain": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.terrainphysics": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.terrain": "1.0.0"
}
},
"com.unity.modules.tilemap": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.physics2d": "1.0.0"
}
},
"com.unity.modules.ui": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.uielements": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.ui": "1.0.0",
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0",
"com.unity.modules.uielementsnative": "1.0.0"
}
},
"com.unity.modules.uielementsnative": {
"version": "1.0.0",
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.modules.ui": "1.0.0",
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
}
},
"com.unity.modules.umbra": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.unityanalytics": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
}
},
"com.unity.modules.unitywebrequest": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.unitywebrequestassetbundle": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.assetbundle": "1.0.0",
"com.unity.modules.unitywebrequest": "1.0.0"
}
},
"com.unity.modules.unitywebrequestaudio": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.audio": "1.0.0"
}
},
"com.unity.modules.unitywebrequesttexture": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.imageconversion": "1.0.0"
}
},
"com.unity.modules.unitywebrequestwww": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.unitywebrequest": "1.0.0",
"com.unity.modules.unitywebrequestassetbundle": "1.0.0",
"com.unity.modules.unitywebrequestaudio": "1.0.0",
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.assetbundle": "1.0.0",
"com.unity.modules.imageconversion": "1.0.0"
}
},
"com.unity.modules.vehicles": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.physics": "1.0.0"
}
},
"com.unity.modules.video": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.ui": "1.0.0",
"com.unity.modules.unitywebrequest": "1.0.0"
}
},
"com.unity.modules.vr": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.jsonserialize": "1.0.0",
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.xr": "1.0.0"
}
},
"com.unity.modules.wind": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {}
},
"com.unity.modules.xr": {
"version": "1.0.0",
"depth": 0,
"source": "builtin",
"dependencies": {
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0",
"com.unity.modules.subsystems": "1.0.0"
}
}
}
}

View File

@ -1,6 +1,4 @@
{
"m_Name": "Settings",
"m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json",
"m_Dictionary": {
"m_DictionaryValues": []
}

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be61633e00d934610ac1ff8192ffbe3d

View File

@ -19,6 +19,11 @@ namespace MCPForUnity.Editor.Data
".cursor",
"mcp.json"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
"mcp.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor",
@ -35,6 +40,10 @@ namespace MCPForUnity.Editor.Data
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude.json"
@ -52,6 +61,12 @@ namespace MCPForUnity.Editor.Data
"windsurf",
"mcp_config.json"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
"windsurf",
"mcp_config.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codeium",
@ -70,22 +85,21 @@ namespace MCPForUnity.Editor.Data
"Claude",
"claude_desktop_config.json"
),
// For macOS, Claude Desktop stores config under ~/Library/Application Support/Claude
// For Linux, it remains under ~/.config/Claude
linuxConfigPath = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
"Claude",
"claude_desktop_config.json"
)
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config",
"Claude",
"claude_desktop_config.json"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
"Claude",
"claude_desktop_config.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config",
"Claude",
"claude_desktop_config.json"
),
mcpType = McpTypes.ClaudeDesktop,
configStatus = "Not Configured",
},
@ -100,24 +114,23 @@ namespace MCPForUnity.Editor.Data
"User",
"mcp.json"
),
// For macOS, VSCode stores user config under ~/Library/Application Support/Code/User
// For Linux, it remains under ~/.config/Code/User
linuxConfigPath = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
"Code",
"User",
"mcp.json"
)
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config",
"Code",
"User",
"mcp.json"
),
// macOS: ~/Library/Application Support/Code/User/mcp.json
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Application Support",
"Code",
"User",
"mcp.json"
),
// Linux: ~/.config/Code/User/mcp.json
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config",
"Code",
"User",
"mcp.json"
),
mcpType = McpTypes.VSCode,
configStatus = "Not Configured",
},
@ -131,6 +144,12 @@ namespace MCPForUnity.Editor.Data
"settings",
"mcp.json"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".kiro",
"settings",
"mcp.json"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".kiro",

View File

@ -54,7 +54,7 @@ namespace MCPForUnity.Editor.Helpers
// For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners
string effectiveDir = directory;
#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX
bool isCursor = !isVSCode && (client == null || client.mcpType != Models.McpTypes.VSCode);
bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode);
if (isCursor && !string.IsNullOrEmpty(directory))
{
// Replace canonical path segment with the symlink path if present
@ -65,7 +65,11 @@ namespace MCPForUnity.Editor.Helpers
// Normalize to full path style
if (directory.Contains(canonical))
{
effectiveDir = directory.Replace(canonical, symlinkSeg);
var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/');
if (System.IO.Directory.Exists(candidate))
{
effectiveDir = candidate;
}
}
else
{
@ -76,7 +80,11 @@ namespace MCPForUnity.Editor.Helpers
{
string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty;
string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/...
effectiveDir = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/');
string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/');
if (System.IO.Directory.Exists(candidate))
{
effectiveDir = candidate;
}
}
}
}

View File

@ -25,19 +25,32 @@ namespace MCPForUnity.Editor.Helpers
if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing)
{
// Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs.
EditorApplication.delayCall += () =>
{
string error = null;
System.Exception capturedEx = null;
try
{
// Ensure any UnityEditor API usage inside runs on the main thread
ServerInstaller.EnsureServerInstalled();
}
catch (System.Exception ex)
{
Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message);
error = ex.Message;
capturedEx = ex;
}
finally
// Unity APIs must stay on main thread
try { EditorPrefs.SetBool(key, true); } catch { }
// Ensure prefs cleanup happens on main thread
try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { }
try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { }
if (!string.IsNullOrEmpty(error))
{
EditorPrefs.SetBool(key, true);
Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}");
// Alternatively: Debug.LogException(capturedEx);
}
};
}

View File

@ -35,10 +35,10 @@ namespace MCPForUnity.Editor.Helpers
/// <summary>
/// Creates a standardized error response object.
/// </summary>
/// <param name="errorMessage">A message describing the error.</param>
/// <param name="errorCodeOrMessage">A message describing the error.</param>
/// <param name="data">Optional additional data (e.g., error details) to include.</param>
/// <returns>An object representing the error response.</returns>
public static object Error(string errorMessage, object data = null)
public static object Error(string errorCodeOrMessage, object data = null)
{
if (data != null)
{
@ -46,13 +46,16 @@ namespace MCPForUnity.Editor.Helpers
return new
{
success = false,
error = errorMessage,
// Preserve original behavior while adding a machine-parsable code field.
// If callers pass a code string, it will be echoed in both code and error.
code = errorCodeOrMessage,
error = errorCodeOrMessage,
data = data,
};
}
else
{
return new { success = false, error = errorMessage };
return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage };
}
}
}

View File

@ -3,6 +3,7 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
@ -569,6 +570,31 @@ namespace MCPForUnity.Editor.Helpers
}
catch { }
// Windows Store (PythonSoftwareFoundation) install location probe
// Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe
try
{
string pkgsRoot = Path.Combine(localAppData, "Packages");
if (Directory.Exists(pkgsRoot))
{
var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly)
.OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase);
foreach (var pkg in pythonPkgs)
{
string localCache = Path.Combine(pkg, "LocalCache", "local-packages");
if (!Directory.Exists(localCache)) continue;
var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly)
.OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase);
foreach (var pyRoot in pyRoots)
{
string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe");
if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe;
}
}
}
}
catch { }
candidates = new[]
{
// Preferred: WinGet Links shims (stable entrypoints)

View File

@ -35,6 +35,8 @@ namespace MCPForUnity.Editor
> commandQueue = new();
private static int currentUnityPort = 6400; // Dynamic port, starts with default
private static bool isAutoConnectMode = false;
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
// Debug helpers
private static bool IsDebugEnabled()
@ -46,7 +48,7 @@ namespace MCPForUnity.Editor
{
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: [{stage}]");
McpLog.Info($"[{stage}]", always: false);
}
}
@ -102,8 +104,9 @@ namespace MCPForUnity.Editor
static MCPForUnityBridge()
{
// Skip bridge in headless/batch environments (CI/builds)
if (Application.isBatchMode)
// Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
// CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{
return;
}
@ -233,7 +236,10 @@ namespace MCPForUnity.Editor
// Don't restart if already running on a working port
if (isRunning && listener != null)
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
if (IsDebugEnabled())
{
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge already running on port {currentUnityPort}");
}
return;
}
@ -347,11 +353,11 @@ namespace MCPForUnity.Editor
// Mark as stopping early to avoid accept logging during disposal
isRunning = false;
// Mark heartbeat one last time before stopping
WriteHeartbeat(false);
WriteHeartbeat(false, "stopped");
listener?.Stop();
listener = null;
EditorApplication.update -= ProcessCommands;
Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
}
catch (Exception ex)
{
@ -392,7 +398,7 @@ namespace MCPForUnity.Editor
{
if (isRunning)
{
Debug.LogError($"Listener error: {ex.Message}");
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
}
}
}
@ -403,22 +409,56 @@ namespace MCPForUnity.Editor
using (client)
using (NetworkStream stream = client.GetStream())
{
byte[] buffer = new byte[8192];
// Framed I/O only; legacy mode removed
try
{
if (IsDebugEnabled())
{
var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown";
Debug.Log($"<b><color=#2EA3FF>UNITY-MCP</color></b>: Client connected {ep}");
}
}
catch { }
// Strict framing: always require FRAMING=1 and frame all I/O
try
{
client.NoDelay = true;
}
catch { }
try
{
string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n";
byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake);
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
#if NETSTANDARD2_1 || NET6_0_OR_GREATER
await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false);
#else
await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false);
#endif
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false);
}
catch (Exception ex)
{
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}");
return; // abort this client
}
while (isRunning)
{
try
{
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
{
break; // Client disconnected
}
// Strict framed mode only: enforced framed I/O for this connection
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs);
string commandText = System.Text.Encoding.UTF8.GetString(
buffer,
0,
bytesRead
);
try
{
if (IsDebugEnabled())
{
var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText;
MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false);
}
}
catch { }
string commandId = Guid.NewGuid().ToString();
TaskCompletionSource<string> tcs = new();
@ -430,7 +470,7 @@ namespace MCPForUnity.Editor
/*lang=json,strict*/
"{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}"
);
await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length);
await WriteFrameAsync(stream, pingResponseBytes);
continue;
}
@ -441,131 +481,264 @@ namespace MCPForUnity.Editor
string response = await tcs.Task;
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
await stream.WriteAsync(responseBytes, 0, responseBytes.Length);
await WriteFrameAsync(stream, responseBytes);
}
catch (Exception ex)
{
Debug.LogError($"Client handler error: {ex.Message}");
// Treat common disconnects/timeouts as benign; only surface hard errors
string msg = ex.Message ?? string.Empty;
bool isBenign =
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
|| ex is System.IO.IOException;
if (isBenign)
{
if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false);
}
else
{
MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}");
}
break;
}
}
}
}
private static void ProcessCommands()
// Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks
private static async System.Threading.Tasks.Task<byte[]> ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default)
{
List<string> processedIds = new();
lock (lockObj)
byte[] buffer = new byte[count];
int offset = 0;
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
while (offset < count)
{
// Periodic heartbeat while editor is idle/processing
double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt)
int remaining = count - offset;
int remainingTimeout = timeoutMs <= 0
? Timeout.Infinite
: timeoutMs - (int)stopwatch.ElapsedMilliseconds;
// If a finite timeout is configured and already elapsed, fail immediately
if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0)
{
WriteHeartbeat(false);
nextHeartbeatAt = now + 0.5f;
throw new System.IO.IOException("Read timed out");
}
foreach (
KeyValuePair<
string,
(string commandJson, TaskCompletionSource<string> tcs)
> kvp in commandQueue.ToList()
)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel);
if (remainingTimeout != Timeout.Infinite)
{
string id = kvp.Key;
string commandText = kvp.Value.commandJson;
TaskCompletionSource<string> tcs = kvp.Value.tcs;
cts.CancelAfter(remainingTimeout);
}
try
try
{
#if NETSTANDARD2_1 || NET6_0_OR_GREATER
int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false);
#else
int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false);
#endif
if (read == 0)
{
// Special case handling
if (string.IsNullOrEmpty(commandText))
{
var emptyResponse = new
{
status = "error",
error = "Empty command received",
};
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
processedIds.Add(id);
continue;
}
// Trim the command text to remove any whitespace
commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping")
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
processedIds.Add(id);
continue;
}
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50
? commandText[..50] + "..."
: commandText,
};
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
processedIds.Add(id);
continue;
}
// Normal JSON command processing
Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
string responseJson = ExecuteCommand(command);
tcs.SetResult(responseJson);
}
throw new System.IO.IOException("Connection closed before reading expected bytes");
}
catch (Exception ex)
{
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
offset += read;
}
catch (OperationCanceledException) when (!cancel.IsCancellationRequested)
{
throw new System.IO.IOException("Read timed out");
}
}
var response = new
return buffer;
}
private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload)
{
using var cts = new CancellationTokenSource(FrameIOTimeoutMs);
await WriteFrameAsync(stream, payload, cts.Token);
}
private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel)
{
if (payload == null)
{
throw new System.ArgumentNullException(nameof(payload));
}
if ((ulong)payload.LongLength > MaxFrameBytes)
{
throw new System.IO.IOException($"Frame too large: {payload.LongLength}");
}
byte[] header = new byte[8];
WriteUInt64BigEndian(header, (ulong)payload.LongLength);
#if NETSTANDARD2_1 || NET6_0_OR_GREATER
await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false);
await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false);
#else
await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false);
await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false);
#endif
}
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs)
{
byte[] header = await ReadExactAsync(stream, 8, timeoutMs);
ulong payloadLen = ReadUInt64BigEndian(header);
if (payloadLen > MaxFrameBytes)
{
throw new System.IO.IOException($"Invalid framed length: {payloadLen}");
}
if (payloadLen == 0UL)
throw new System.IO.IOException("Zero-length frames are not allowed");
if (payloadLen > int.MaxValue)
{
throw new System.IO.IOException("Frame too large for buffer");
}
int count = (int)payloadLen;
byte[] payload = await ReadExactAsync(stream, count, timeoutMs);
return System.Text.Encoding.UTF8.GetString(payload);
}
private static ulong ReadUInt64BigEndian(byte[] buffer)
{
if (buffer == null || buffer.Length < 8) return 0UL;
return ((ulong)buffer[0] << 56)
| ((ulong)buffer[1] << 48)
| ((ulong)buffer[2] << 40)
| ((ulong)buffer[3] << 32)
| ((ulong)buffer[4] << 24)
| ((ulong)buffer[5] << 16)
| ((ulong)buffer[6] << 8)
| buffer[7];
}
private static void WriteUInt64BigEndian(byte[] dest, ulong value)
{
if (dest == null || dest.Length < 8)
{
throw new System.ArgumentException("Destination buffer too small for UInt64");
}
dest[0] = (byte)(value >> 56);
dest[1] = (byte)(value >> 48);
dest[2] = (byte)(value >> 40);
dest[3] = (byte)(value >> 32);
dest[4] = (byte)(value >> 24);
dest[5] = (byte)(value >> 16);
dest[6] = (byte)(value >> 8);
dest[7] = (byte)(value);
}
private static void ProcessCommands()
{
// Heartbeat without holding the queue lock
double now = EditorApplication.timeSinceStartup;
if (now >= nextHeartbeatAt)
{
WriteHeartbeat(false);
nextHeartbeatAt = now + 0.5f;
}
// Snapshot under lock, then process outside to reduce contention
List<(string id, string text, TaskCompletionSource<string> tcs)> work;
lock (lockObj)
{
work = commandQueue
.Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs))
.ToList();
}
foreach (var item in work)
{
string id = item.id;
string commandText = item.text;
TaskCompletionSource<string> tcs = item.tcs;
try
{
// Special case handling
if (string.IsNullOrEmpty(commandText))
{
var emptyResponse = new
{
status = "error",
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50
error = "Empty command received",
};
tcs.SetResult(JsonConvert.SerializeObject(emptyResponse));
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Trim the command text to remove any whitespace
commandText = commandText.Trim();
// Non-JSON direct commands handling (like ping)
if (commandText == "ping")
{
var pingResponse = new
{
status = "success",
result = new { message = "pong" },
};
tcs.SetResult(JsonConvert.SerializeObject(pingResponse));
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
// Check if the command is valid JSON before attempting to deserialize
if (!IsValidJson(commandText))
{
var invalidJsonResponse = new
{
status = "error",
error = "Invalid JSON format",
receivedText = commandText.Length > 50
? commandText[..50] + "..."
: commandText,
};
string responseJson = JsonConvert.SerializeObject(response);
tcs.SetResult(responseJson);
tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse));
lock (lockObj) { commandQueue.Remove(id); }
continue;
}
processedIds.Add(id);
// Normal JSON command processing
Command command = JsonConvert.DeserializeObject<Command>(commandText);
if (command == null)
{
var nullCommandResponse = new
{
status = "error",
error = "Command deserialized to null",
details = "The command was valid JSON but could not be deserialized to a Command object",
};
tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse));
}
else
{
string responseJson = ExecuteCommand(command);
tcs.SetResult(responseJson);
}
}
catch (Exception ex)
{
Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}");
var response = new
{
status = "error",
error = ex.Message,
commandType = "Unknown (error during processing)",
receivedText = commandText?.Length > 50
? commandText[..50] + "..."
: commandText,
};
string responseJson = JsonConvert.SerializeObject(response);
tcs.SetResult(responseJson);
}
foreach (string id in processedIds)
{
commandQueue.Remove(id);
}
// Remove quickly under lock
lock (lockObj) { commandQueue.Remove(id); }
}
}
@ -715,7 +888,12 @@ namespace MCPForUnity.Editor
{
try
{
string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
// Allow override of status directory (useful in CI/containers)
string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR");
if (string.IsNullOrWhiteSpace(dir))
{
dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp");
}
Directory.CreateDirectory(dir);
string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json");
var payload = new

View File

@ -4,8 +4,8 @@ namespace MCPForUnity.Editor.Models
{
public string name;
public string windowsConfigPath;
public string macConfigPath;
public string linuxConfigPath;
public string macConfigPath; // optional macOS-specific config path
public McpTypes mcpType;
public string configStatus;
public McpStatus status = McpStatus.NotConfigured;

View File

@ -68,6 +68,8 @@ namespace MCPForUnity.Editor.Tools
{
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// Optional future param retained for API compatibility; not used in synchronous mode
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
@ -94,42 +96,29 @@ namespace MCPForUnity.Editor.Tools
try
{
// Attempt to execute the menu item on the main thread using delayCall for safety.
EditorApplication.delayCall += () =>
{
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
// Log potential failure inside the delayed call.
if (!executed)
{
Debug.LogError(
$"[ExecuteMenuItem] Failed to find or execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."
);
}
}
catch (Exception delayEx)
{
Debug.LogError(
$"[ExecuteMenuItem] Exception during delayed execution of '{menuPath}': {delayEx}"
);
}
};
// Trace incoming execute requests
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
// Report attempt immediately, as execution is delayed.
return Response.Success(
$"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed)
{
Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'");
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
return Response.Error(
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
new { executed = false, menuPath }
);
}
catch (Exception e)
{
// Catch errors during setup phase.
Debug.LogError(
$"[ExecuteMenuItem] Failed to setup execution for '{menuPath}': {e}"
);
return Response.Error(
$"Error setting up execution for menu item '{menuPath}': {e.Message}"
);
Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
}
}

View File

@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class
using static MCPForUnity.Editor.Tools.ManageGameObject;
#if UNITY_6000_0_OR_NEWER
using PhysicsMaterialType = UnityEngine.PhysicsMaterial;
@ -178,8 +179,18 @@ namespace MCPForUnity.Editor.Tools
}
else if (lowerAssetType == "material")
{
Material mat = new Material(Shader.Find("Standard")); // Default shader
// TODO: Apply properties from JObject (e.g., shader name, color, texture assignments)
// Prefer provided shader; fall back to common pipelines
var requested = properties?["shader"]?.ToString();
Shader shader =
(!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null)
?? Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("HDRP/Lit")
?? Shader.Find("Standard")
?? Shader.Find("Unlit/Color");
if (shader == null)
return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}').");
var mat = new Material(shader);
if (properties != null)
ApplyMaterialProperties(mat, properties);
AssetDatabase.CreateAsset(mat, fullPath);
@ -201,15 +212,16 @@ namespace MCPForUnity.Editor.Tools
"'scriptClass' property required when creating ScriptableObject asset."
);
Type scriptType = FindType(scriptClassName);
Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null;
if (
scriptType == null
|| !typeof(ScriptableObject).IsAssignableFrom(scriptType)
)
{
return Response.Error(
$"Script class '{scriptClassName}' not found or does not inherit from ScriptableObject."
);
var reason = scriptType == null
? (string.IsNullOrEmpty(error) ? "Type not found." : error)
: "Type found but does not inherit from ScriptableObject.";
return Response.Error($"Script class '{scriptClassName}' invalid: {reason}");
}
ScriptableObject so = ScriptableObject.CreateInstance(scriptType);
@ -353,10 +365,21 @@ namespace MCPForUnity.Editor.Tools
&& componentProperties.HasValues
) // e.g., {"bobSpeed": 2.0}
{
// Find the component on the GameObject using the name from the JSON key
// Using GetComponent(string) is convenient but might require exact type name or be ambiguous.
// Consider using FindType helper if needed for more complex scenarios.
Component targetComponent = gameObject.GetComponent(componentName);
// Resolve component type via ComponentResolver, then fetch by Type
Component targetComponent = null;
bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError);
if (resolved)
{
targetComponent = gameObject.GetComponent(compType);
}
// Only warn about resolution failure if component also not found
if (targetComponent == null && !resolved)
{
Debug.LogWarning(
$"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}"
);
}
if (targetComponent != null)
{
@ -937,8 +960,8 @@ namespace MCPForUnity.Editor.Tools
{
string propName = floatProps["name"]?.ToString();
if (
!string.IsNullOrEmpty(propName) && floatProps["value"]?.Type == JTokenType.Float
|| floatProps["value"]?.Type == JTokenType.Integer
!string.IsNullOrEmpty(propName) &&
(floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer)
)
{
try
@ -1220,46 +1243,6 @@ namespace MCPForUnity.Editor.Tools
}
}
/// <summary>
/// Helper to find a Type by name, searching relevant assemblies.
/// Needed for creating ScriptableObjects or finding component types by name.
/// </summary>
private static Type FindType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return null;
// Try direct lookup first (common Unity types often don't need assembly qualified name)
var type =
Type.GetType(typeName)
?? Type.GetType($"UnityEngine.{typeName}, UnityEngine.CoreModule")
?? Type.GetType($"UnityEngine.UI.{typeName}, UnityEngine.UI")
?? Type.GetType($"UnityEditor.{typeName}, UnityEditor.CoreModule");
if (type != null)
return type;
// If not found, search loaded assemblies (slower but more robust for user scripts)
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
// Look for non-namespaced first
type = assembly.GetType(typeName, false, true); // throwOnError=false, ignoreCase=true
if (type != null)
return type;
// Check common namespaces if simple name given
type = assembly.GetType("UnityEngine." + typeName, false, true);
if (type != null)
return type;
type = assembly.GetType("UnityEditor." + typeName, false, true);
if (type != null)
return type;
// Add other likely namespaces if needed (e.g., specific plugins)
}
Debug.LogWarning($"[FindType] Type '{typeName}' not found in any loaded assembly.");
return null; // Not found
}
// --- Data Serialization ---
@ -1288,24 +1271,32 @@ namespace MCPForUnity.Editor.Tools
{
// Ensure texture is readable for EncodeToPNG
// Creating a temporary readable copy is safer
RenderTexture rt = RenderTexture.GetTemporary(
preview.width,
preview.height
);
Graphics.Blit(preview, rt);
RenderTexture rt = null;
Texture2D readablePreview = null;
RenderTexture previous = RenderTexture.active;
RenderTexture.active = rt;
Texture2D readablePreview = new Texture2D(preview.width, preview.height);
readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
readablePreview.Apply();
RenderTexture.active = previous;
RenderTexture.ReleaseTemporary(rt);
try
{
rt = RenderTexture.GetTemporary(preview.width, preview.height);
Graphics.Blit(preview, rt);
RenderTexture.active = rt;
readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false);
readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
readablePreview.Apply();
byte[] pngData = readablePreview.EncodeToPNG();
previewBase64 = Convert.ToBase64String(pngData);
previewWidth = readablePreview.width;
previewHeight = readablePreview.height;
UnityEngine.Object.DestroyImmediate(readablePreview); // Clean up temp texture
var pngData = readablePreview.EncodeToPNG();
if (pngData != null && pngData.Length > 0)
{
previewBase64 = Convert.ToBase64String(pngData);
previewWidth = readablePreview.width;
previewHeight = readablePreview.height;
}
}
finally
{
RenderTexture.active = previous;
if (rt != null) RenderTexture.ReleaseTemporary(rt);
if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview);
}
}
catch (Exception ex)
{

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
@ -89,6 +90,8 @@ namespace MCPForUnity.Editor.Tools
// Editor State/Info
case "get_state":
return GetEditorState();
case "get_project_root":
return GetProjectRoot();
case "get_windows":
return GetEditorWindows();
case "get_active_tool":
@ -137,7 +140,7 @@ namespace MCPForUnity.Editor.Tools
default:
return Response.Error(
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
);
}
}
@ -165,6 +168,25 @@ namespace MCPForUnity.Editor.Tools
}
}
private static object GetProjectRoot()
{
try
{
// Application.dataPath points to <Project>/Assets
string assetsPath = Application.dataPath.Replace('\\', '/');
string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/');
if (string.IsNullOrEmpty(projectRoot))
{
return Response.Error("Could not determine project root from Application.dataPath");
}
return Response.Success("Project root resolved.", new { projectRoot });
}
catch (Exception e)
{
return Response.Error($"Error getting project root: {e.Message}");
}
}
private static object GetEditorWindows()
{
try

View File

@ -1,3 +1,4 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
@ -5,6 +6,7 @@ using System.Reflection;
using Newtonsoft.Json; // Added for JsonSerializationException
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.Compilation; // For CompilationPipeline
using UnityEditor.SceneManagement;
using UnityEditorInternal;
using UnityEngine;
@ -19,10 +21,29 @@ namespace MCPForUnity.Editor.Tools
/// </summary>
public static class ManageGameObject
{
// Shared JsonSerializer to avoid per-call allocation overhead
private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
new Vector3Converter(),
new Vector2Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new UnityEngineObjectConverter()
}
});
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return Response.Error("Parameters cannot be null.");
}
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
@ -283,11 +304,16 @@ namespace MCPForUnity.Editor.Tools
newGo = GameObject.CreatePrimitive(type);
// Set name *after* creation for primitives
if (!string.IsNullOrEmpty(name))
{
newGo.name = name;
}
else
{
UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak
return Response.Error(
"'name' parameter is required when creating a primitive."
); // Name is essential
);
}
createdNewObject = true;
}
catch (ArgumentException)
@ -759,6 +785,7 @@ namespace MCPForUnity.Editor.Tools
}
// Set Component Properties
var componentErrors = new List<object>();
if (@params["componentProperties"] is JObject componentPropertiesObj)
{
foreach (var prop in componentPropertiesObj.Properties())
@ -773,12 +800,26 @@ namespace MCPForUnity.Editor.Tools
propertiesToSet
);
if (setResult != null)
return setResult;
modified = true;
{
componentErrors.Add(setResult);
}
else
{
modified = true;
}
}
}
}
// Return component errors if any occurred (after processing all components)
if (componentErrors.Count > 0)
{
return Response.Error(
$"One or more component property operations failed on '{targetGo.name}'.",
new { componentErrors = componentErrors }
);
}
if (!modified)
{
// Use the new serializer helper
@ -1097,6 +1138,29 @@ namespace MCPForUnity.Editor.Tools
// --- Internal Helpers ---
/// <summary>
/// Parses a JArray like [x, y, z] into a Vector3.
/// </summary>
private static Vector3? ParseVector3(JArray array)
{
if (array != null && array.Count == 3)
{
try
{
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}");
}
}
return null;
}
/// <summary>
/// Finds a single GameObject based on token (ID, name, path) and search method.
/// </summary>
@ -1464,7 +1528,18 @@ namespace MCPForUnity.Editor.Tools
Component targetComponentInstance = null
)
{
Component targetComponent = targetComponentInstance ?? targetGo.GetComponent(compName);
Component targetComponent = targetComponentInstance;
if (targetComponent == null)
{
if (ComponentResolver.TryResolve(compName, out var compType, out var compError))
{
targetComponent = targetGo.GetComponent(compType);
}
else
{
targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup
}
}
if (targetComponent == null)
{
return Response.Error(
@ -1474,6 +1549,7 @@ namespace MCPForUnity.Editor.Tools
Undo.RecordObject(targetComponent, "Set Component Properties");
var failures = new List<string>();
foreach (var prop in propertiesToSet.Properties())
{
string propName = prop.Name;
@ -1481,14 +1557,16 @@ namespace MCPForUnity.Editor.Tools
try
{
if (!SetProperty(targetComponent, propName, propValue))
bool setResult = SetProperty(targetComponent, propName, propValue);
if (!setResult)
{
// Log warning if property could not be set
Debug.LogWarning(
$"[ManageGameObject] Could not set property '{propName}' on component '{compName}' ('{targetComponent.GetType().Name}'). Property might not exist, be read-only, or type mismatch."
);
// Optionally return an error here instead of just logging
// return Response.Error($"Could not set property '{propName}' on component '{compName}'.");
var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType());
var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties);
var msg = suggestions.Any()
? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]"
: $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]";
Debug.LogWarning($"[ManageGameObject] {msg}");
failures.Add(msg);
}
}
catch (Exception e)
@ -1496,12 +1574,13 @@ namespace MCPForUnity.Editor.Tools
Debug.LogError(
$"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}"
);
// Optionally return an error here
// return Response.Error($"Error setting property '{propName}' on '{compName}': {e.Message}");
failures.Add($"Error setting '{propName}': {e.Message}");
}
}
EditorUtility.SetDirty(targetComponent);
return null; // Success (or partial success if warnings were logged)
return failures.Count == 0
? null
: Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures });
}
/// <summary>
@ -1513,25 +1592,8 @@ namespace MCPForUnity.Editor.Tools
BindingFlags flags =
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
// --- Use a dedicated serializer for input conversion ---
// Define this somewhere accessible, maybe static readonly field
JsonSerializerSettings inputSerializerSettings = new JsonSerializerSettings
{
Converters = new List<JsonConverter>
{
// Add specific converters needed for INPUT deserialization if different from output
new Vector3Converter(),
new Vector2Converter(),
new QuaternionConverter(),
new ColorConverter(),
new RectConverter(),
new BoundsConverter(),
new UnityEngineObjectConverter() // Crucial for finding references from instructions
}
// No ReferenceLoopHandling needed typically for input
};
JsonSerializer inputSerializer = JsonSerializer.Create(inputSerializerSettings);
// --- End Serializer Setup ---
// Use shared serializer to avoid per-call allocation
var inputSerializer = InputSerializer;
try
{
@ -1573,6 +1635,20 @@ namespace MCPForUnity.Editor.Tools
Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}");
}
}
else
{
// Try NonPublic [SerializeField] fields
var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (npField != null && npField.GetCustomAttribute<SerializeField>() != null)
{
object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer);
if (convertedValue != null || value.Type == JTokenType.Null)
{
npField.SetValue(target, convertedValue);
return true;
}
}
}
}
}
catch (Exception ex)
@ -2070,84 +2146,288 @@ namespace MCPForUnity.Editor.Tools
/// <summary>
/// Helper to find a Type by name, searching relevant assemblies.
/// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs.
/// Searches already-loaded assemblies, prioritizing runtime script assemblies.
/// </summary>
private static Type FindType(string typeName)
{
if (string.IsNullOrEmpty(typeName))
return null;
// Handle fully qualified names first
Type type = Type.GetType(typeName);
if (type != null) return type;
// Handle common namespaces implicitly (add more as needed)
string[] namespaces = { "UnityEngine", "UnityEngine.UI", "UnityEngine.AI", "UnityEngine.Animations", "UnityEngine.Audio", "UnityEngine.EventSystems", "UnityEngine.InputSystem", "UnityEngine.Networking", "UnityEngine.Rendering", "UnityEngine.SceneManagement", "UnityEngine.Tilemaps", "UnityEngine.U2D", "UnityEngine.Video", "UnityEditor", "UnityEditor.AI", "UnityEditor.Animations", "UnityEditor.Experimental.GraphView", "UnityEditor.IMGUI.Controls", "UnityEditor.PackageManager.UI", "UnityEditor.SceneManagement", "UnityEditor.UI", "UnityEditor.U2D", "UnityEditor.VersionControl" }; // Add more relevant namespaces
foreach (string ns in namespaces) {
type = Type.GetType($"{ns}.{typeName}, {ns.Split('.')[0]}.CoreModule") ?? // Heuristic: Check CoreModule first for UnityEngine/UnityEditor
Type.GetType($"{ns}.{typeName}, {ns.Split('.')[0]}"); // Try assembly matching namespace root
if (type != null) return type;
}
// If not found, search all loaded assemblies (slower, last resort)
// Prioritize assemblies likely to contain game/editor types
Assembly[] priorityAssemblies = {
Assembly.Load("Assembly-CSharp"), // Main game scripts
Assembly.Load("Assembly-CSharp-Editor"), // Main editor scripts
// Add other important project assemblies if known
};
foreach (var assembly in priorityAssemblies.Where(a => a != null))
{
type = assembly.GetType(typeName) ?? assembly.GetType("UnityEngine." + typeName) ?? assembly.GetType("UnityEditor." + typeName);
if (type != null) return type;
}
// Search remaining assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Except(priorityAssemblies))
if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error))
{
try { // Protect against assembly loading errors
type = assembly.GetType(typeName);
if (type != null) return type;
// Also check with common namespaces if simple name given
foreach (string ns in namespaces) {
type = assembly.GetType($"{ns}.{typeName}");
if (type != null) return type;
}
} catch (Exception ex) {
Debug.LogWarning($"[FindType] Error searching assembly {assembly.FullName}: {ex.Message}");
}
return resolvedType;
}
// Log the resolver error if type wasn't found
if (!string.IsNullOrEmpty(error))
{
Debug.LogWarning($"[FindType] {error}");
}
return null;
}
}
/// <summary>
/// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions.
/// Prioritizes runtime (Player) assemblies over Editor assemblies.
/// </summary>
internal static class ComponentResolver
{
private static readonly Dictionary<string, Type> CacheByFqn = new(StringComparer.Ordinal);
private static readonly Dictionary<string, Type> CacheByName = new(StringComparer.Ordinal);
/// <summary>
/// Resolve a Component/MonoBehaviour type by short or fully-qualified name.
/// Prefers runtime (Player) script assemblies; falls back to Editor assemblies.
/// Never uses Assembly.LoadFrom.
/// </summary>
public static bool TryResolve(string nameOrFullName, out Type type, out string error)
{
error = string.Empty;
type = null!;
// Handle null/empty input
if (string.IsNullOrWhiteSpace(nameOrFullName))
{
error = "Component name cannot be null or empty";
return false;
}
Debug.LogWarning($"[FindType] Type not found after extensive search: '{typeName}'");
return null; // Not found
// 1) Exact cache hits
if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true;
if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true;
type = Type.GetType(nameOrFullName, throwOnError: false);
if (IsValidComponent(type)) { Cache(type); return true; }
// 2) Search loaded assemblies (prefer Player assemblies)
var candidates = FindCandidates(nameOrFullName);
if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; }
if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; }
#if UNITY_EDITOR
// 3) Last resort: Editor-only TypeCache (fast index)
var tc = TypeCache.GetTypesDerivedFrom<Component>()
.Where(t => NamesMatch(t, nameOrFullName));
candidates = PreferPlayer(tc).ToList();
if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; }
if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; }
#endif
error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " +
"Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled.";
type = null!;
return false;
}
private static bool NamesMatch(Type t, string q) =>
t.Name.Equals(q, StringComparison.Ordinal) ||
(t.FullName?.Equals(q, StringComparison.Ordinal) ?? false);
private static bool IsValidComponent(Type t) =>
t != null && typeof(Component).IsAssignableFrom(t);
private static void Cache(Type t)
{
if (t.FullName != null) CacheByFqn[t.FullName] = t;
CacheByName[t.Name] = t;
}
private static List<Type> FindCandidates(string query)
{
bool isShort = !query.Contains('.');
var loaded = AppDomain.CurrentDomain.GetAssemblies();
#if UNITY_EDITOR
// Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp)
var playerAsmNames = new HashSet<string>(
UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name),
StringComparer.Ordinal);
IEnumerable<System.Reflection.Assembly> playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name));
IEnumerable<System.Reflection.Assembly> editorAsms = loaded.Except(playerAsms);
#else
IEnumerable<System.Reflection.Assembly> playerAsms = loaded;
IEnumerable<System.Reflection.Assembly> editorAsms = Array.Empty<System.Reflection.Assembly>();
#endif
static IEnumerable<Type> SafeGetTypes(System.Reflection.Assembly a)
{
try { return a.GetTypes(); }
catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; }
}
Func<Type, bool> match = isShort
? (t => t.Name.Equals(query, StringComparison.Ordinal))
: (t => t.FullName!.Equals(query, StringComparison.Ordinal));
var fromPlayer = playerAsms.SelectMany(SafeGetTypes)
.Where(IsValidComponent)
.Where(match);
var fromEditor = editorAsms.SelectMany(SafeGetTypes)
.Where(IsValidComponent)
.Where(match);
var list = new List<Type>(fromPlayer);
if (list.Count == 0) list.AddRange(fromEditor);
return list;
}
#if UNITY_EDITOR
private static IEnumerable<Type> PreferPlayer(IEnumerable<Type> seq)
{
var player = new HashSet<string>(
UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name),
StringComparer.Ordinal);
return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1);
}
#endif
private static string Ambiguity(string query, IEnumerable<Type> cands)
{
var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})");
return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) +
"\nProvide a fully qualified type name to disambiguate.";
}
/// <summary>
/// Parses a JArray like [x, y, z] into a Vector3.
/// Gets all accessible property and field names from a component type.
/// </summary>
private static Vector3? ParseVector3(JArray array)
public static List<string> GetAllComponentProperties(Type componentType)
{
if (array != null && array.Count == 3)
if (componentType == null) return new List<string>();
var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite)
.Select(p => p.Name);
var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => !f.IsInitOnly && !f.IsLiteral)
.Select(f => f.Name);
// Also include SerializeField private fields (common in Unity)
var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.Where(f => f.GetCustomAttribute<SerializeField>() != null)
.Select(f => f.Name);
return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList();
}
/// <summary>
/// Uses AI to suggest the most likely property matches for a user's input.
/// </summary>
public static List<string> GetAIPropertySuggestions(string userInput, List<string> availableProperties)
{
if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any())
return new List<string>();
// Simple caching to avoid repeated AI calls for the same input
var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}";
if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached))
return cached;
try
{
try
var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" +
$"User requested: \"{userInput}\"\n" +
$"Available properties: [{string.Join(", ", availableProperties)}]\n\n" +
$"Find 1-3 most likely matches considering:\n" +
$"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" +
$"- camelCase vs PascalCase vs spaces\n" +
$"- Similar meaning/semantics\n" +
$"- Common Unity naming patterns\n\n" +
$"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" +
$"If confidence is low (<70%), return empty string.\n\n" +
$"Examples:\n" +
$"- \"Max Reach Distance\" → \"maxReachDistance\"\n" +
$"- \"Health Points\" → \"healthPoints, hp\"\n" +
$"- \"Move Speed\" → \"moveSpeed, movementSpeed\"";
// For now, we'll use a simple rule-based approach that mimics AI behavior
// This can be replaced with actual AI calls later
var suggestions = GetRuleBasedSuggestions(userInput, availableProperties);
PropertySuggestionCache[cacheKey] = suggestions;
return suggestions;
}
catch (Exception ex)
{
Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}");
return new List<string>();
}
}
private static readonly Dictionary<string, List<string>> PropertySuggestionCache = new();
/// <summary>
/// Rule-based suggestions that mimic AI behavior for property matching.
/// This provides immediate value while we could add real AI integration later.
/// </summary>
private static List<string> GetRuleBasedSuggestions(string userInput, List<string> availableProperties)
{
var suggestions = new List<string>();
var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
foreach (var property in availableProperties)
{
var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
// Exact match after cleaning
if (cleanedProperty == cleanedInput)
{
// Use ToObject for potentially better handling than direct indexing
return new Vector3(
array[0].ToObject<float>(),
array[1].ToObject<float>(),
array[2].ToObject<float>()
);
suggestions.Add(property);
continue;
}
catch (Exception ex)
// Check if property contains all words from input
var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant())))
{
Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}");
suggestions.Add(property);
continue;
}
// Levenshtein distance for close matches
if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4))
{
suggestions.Add(property);
}
}
return null;
// Prioritize exact matches, then by similarity
return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", "")))
.Take(3)
.ToList();
}
/// <summary>
/// Calculates Levenshtein distance between two strings for similarity matching.
/// </summary>
private static int LevenshteinDistance(string s1, string s2)
{
if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0;
if (string.IsNullOrEmpty(s2)) return s1.Length;
var matrix = new int[s1.Length + 1, s2.Length + 1];
for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i;
for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j;
for (int i = 1; i <= s1.Length; i++)
{
for (int j = 1; j <= s2.Length; j++)
{
int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1;
matrix[i, j] = Math.Min(Math.Min(
matrix[i - 1, j] + 1, // deletion
matrix[i, j - 1] + 1), // insertion
matrix[i - 1, j - 1] + cost); // substitution
}
}
return matrix[s1.Length, s2.Length];
}
// Removed duplicate ParseVector3 - using the one at line 1114
// Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup.
// They are now in Helpers.GameObjectSerializer
}

File diff suppressed because it is too large Load Diff

View File

@ -723,9 +723,8 @@ namespace MCPForUnity.Editor.Windows
string na = System.IO.Path.GetFullPath(a.Trim());
string nb = System.IO.Path.GetFullPath(b.Trim());
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
{
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
// Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed
return string.Equals(na, nb, StringComparison.Ordinal);
}
catch { return false; }
@ -758,22 +757,112 @@ namespace MCPForUnity.Editor.Windows
private static bool VerifyBridgePing(int port)
{
// Use strict framed protocol to match bridge (FRAMING=1)
const int ConnectTimeoutMs = 1000;
const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout
try
{
using TcpClient c = new TcpClient();
var task = c.ConnectAsync(IPAddress.Loopback, port);
if (!task.Wait(500)) return false;
using NetworkStream s = c.GetStream();
byte[] ping = Encoding.UTF8.GetBytes("ping");
s.Write(ping, 0, ping.Length);
s.ReadTimeout = 1000;
byte[] buf = new byte[256];
int n = s.Read(buf, 0, buf.Length);
if (n <= 0) return false;
string resp = Encoding.UTF8.GetString(buf, 0, n);
return resp.Contains("pong", StringComparison.OrdinalIgnoreCase);
using TcpClient client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
if (!connectTask.Wait(ConnectTimeoutMs)) return false;
using NetworkStream stream = client.GetStream();
try { client.NoDelay = true; } catch { }
// 1) Read handshake line (ASCII, newline-terminated)
string handshake = ReadLineAscii(stream, 2000);
if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0)
{
UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1");
return false;
}
// 2) Send framed "ping"
byte[] payload = Encoding.UTF8.GetBytes("ping");
WriteFrame(stream, payload, FrameTimeoutMs);
// 3) Read framed response and check for pong
string response = ReadFrameUtf8(stream, FrameTimeoutMs);
bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0;
if (!ok)
{
UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'");
}
return ok;
}
catch { return false; }
catch (Exception ex)
{
UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}");
return false;
}
}
// Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts
private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs)
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed");
byte[] header = new byte[8];
ulong len = (ulong)payload.LongLength;
header[0] = (byte)(len >> 56);
header[1] = (byte)(len >> 48);
header[2] = (byte)(len >> 40);
header[3] = (byte)(len >> 32);
header[4] = (byte)(len >> 24);
header[5] = (byte)(len >> 16);
header[6] = (byte)(len >> 8);
header[7] = (byte)(len);
stream.WriteTimeout = timeoutMs;
stream.Write(header, 0, header.Length);
stream.Write(payload, 0, payload.Length);
}
private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs)
{
byte[] header = ReadExact(stream, 8, timeoutMs);
ulong len = ((ulong)header[0] << 56)
| ((ulong)header[1] << 48)
| ((ulong)header[2] << 40)
| ((ulong)header[3] << 32)
| ((ulong)header[4] << 24)
| ((ulong)header[5] << 16)
| ((ulong)header[6] << 8)
| header[7];
if (len == 0UL) throw new IOException("Zero-length frames are not allowed");
if (len > int.MaxValue) throw new IOException("Frame too large");
byte[] payload = ReadExact(stream, (int)len, timeoutMs);
return Encoding.UTF8.GetString(payload);
}
private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs)
{
byte[] buffer = new byte[count];
int offset = 0;
stream.ReadTimeout = timeoutMs;
while (offset < count)
{
int read = stream.Read(buffer, offset, count - offset);
if (read <= 0) throw new IOException("Connection closed before reading expected bytes");
offset += read;
}
return buffer;
}
private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512)
{
stream.ReadTimeout = timeoutMs;
using var ms = new MemoryStream();
byte[] one = new byte[1];
while (ms.Length < maxLen)
{
int n = stream.Read(one, 0, 1);
if (n <= 0) break;
if (one[0] == (byte)'\n') break;
ms.WriteByte(one[0]);
}
return Encoding.ASCII.GetString(ms.ToArray());
}
private void DrawClientConfigurationCompact(McpClient mcpClient)
@ -1134,10 +1223,19 @@ namespace MCPForUnity.Editor.Windows
}
catch { }
// 1) Start from existing, only fill gaps
string uvPath = (ValidateUvBinarySafe(existingCommand) ? existingCommand : FindUvPath());
// 1) Start from existing, only fill gaps (prefer trusted resolver)
string uvPath = ServerInstaller.FindUvPath();
// Optionally trust existingCommand if it looks like uv/uv.exe
try
{
var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
{
uvPath = existingCommand;
}
}
catch { }
if (uvPath == null) return "UV package manager not found. Please install UV first.";
string serverSrc = ExtractDirectoryArg(existingArgs);
bool serverValid = !string.IsNullOrEmpty(serverSrc)
&& System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py"));
@ -1203,51 +1301,61 @@ namespace MCPForUnity.Editor.Windows
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
// Use a more robust atomic write pattern
// Robust atomic write without redundant backup or race on existence
string tmp = configPath + ".tmp";
string backup = configPath + ".backup";
bool writeDone = false;
try
{
// Write to temp file first
// Write to temp file first (in same directory for atomicity)
System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false));
// Create backup of existing file if it exists
if (System.IO.File.Exists(configPath))
try
{
System.IO.File.Copy(configPath, backup, true);
// Try atomic replace; creates 'backup' only on success (platform-dependent)
System.IO.File.Replace(tmp, configPath, backup);
writeDone = true;
}
// Atomic move operation (more reliable than Replace on macOS)
if (System.IO.File.Exists(configPath))
catch (System.IO.FileNotFoundException)
{
System.IO.File.Delete(configPath);
// Destination didn't exist; fall back to move
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
System.IO.File.Move(tmp, configPath);
// Clean up backup
if (System.IO.File.Exists(backup))
catch (System.PlatformNotSupportedException)
{
System.IO.File.Delete(backup);
// Fallback: rename existing to backup, then move tmp into place
if (System.IO.File.Exists(configPath))
{
try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
System.IO.File.Move(configPath, backup);
}
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
}
catch (Exception ex)
{
// Clean up temp file
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
// Restore backup if it exists
try {
if (System.IO.File.Exists(backup))
// If write did not complete, attempt restore from backup without deleting current file first
try
{
if (!writeDone && System.IO.File.Exists(backup))
{
if (System.IO.File.Exists(configPath))
{
System.IO.File.Delete(configPath);
}
System.IO.File.Move(backup, configPath);
try { System.IO.File.Copy(backup, configPath, true); } catch { }
}
} catch { }
}
catch { }
throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex);
}
finally
{
// Best-effort cleanup of temp
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
// Only remove backup after a confirmed successful write
try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
}
try
{
if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
@ -1663,7 +1771,7 @@ namespace MCPForUnity.Editor.Windows
{
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"MCP for Unity: Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}");
MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false);
}
mcpClient.SetStatus(McpStatus.Configured);
}
@ -1835,283 +1943,12 @@ namespace MCPForUnity.Editor.Windows
private string FindUvPath()
{
string uvPath = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
uvPath = FindWindowsUvPath();
}
else
{
// macOS/Linux paths
string[] possiblePaths = {
"/Library/Frameworks/Python.framework/Versions/3.13/bin/uv",
"/usr/local/bin/uv",
"/opt/homebrew/bin/uv",
"/usr/bin/uv"
};
foreach (string path in possiblePaths)
{
if (File.Exists(path) && IsValidUvInstallation(path))
{
uvPath = path;
break;
}
}
// If not found in common locations, try to find via which command
if (uvPath == null)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "which",
Arguments = "uv",
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
};
using var process = Process.Start(psi);
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (!string.IsNullOrEmpty(output) && File.Exists(output) && IsValidUvInstallation(output))
{
uvPath = output;
}
}
catch
{
// Ignore errors
}
}
}
// If no specific path found, fall back to using 'uv' from PATH
if (uvPath == null)
{
// Test if 'uv' is available in PATH by trying to run it
string uvCommand = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.exe" : "uv";
if (IsValidUvInstallation(uvCommand))
{
uvPath = uvCommand;
}
}
if (uvPath == null)
{
UnityEngine.Debug.LogError("UV package manager not found! Please install UV first:\n" +
"• macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n" +
"• Windows: pip install uv\n" +
"• Or visit: https://docs.astral.sh/uv/getting-started/installation");
return null;
}
return uvPath;
try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; }
}
private bool IsValidUvInstallation(string uvPath)
{
try
{
var psi = new ProcessStartInfo
{
FileName = uvPath,
Arguments = "--version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(psi);
process.WaitForExit(5000); // 5 second timeout
if (process.ExitCode == 0)
{
string output = process.StandardOutput.ReadToEnd().Trim();
// Basic validation - just check if it responds with version info
// UV typically outputs "uv 0.x.x" format
if (output.StartsWith("uv ") && output.Contains("."))
{
return true;
}
}
return false;
}
catch
{
return false;
}
}
// Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath()
private string FindWindowsUvPath()
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Dynamic Python version detection - check what's actually installed
List<string> pythonVersions = new List<string>();
// Add common versions but also scan for any Python* directories
string[] commonVersions = { "Python313", "Python312", "Python311", "Python310", "Python39", "Python38", "Python37" };
pythonVersions.AddRange(commonVersions);
// Scan for additional Python installations
string[] pythonBasePaths = {
Path.Combine(appData, "Python"),
Path.Combine(localAppData, "Programs", "Python"),
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) + "\\Python",
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) + "\\Python"
};
foreach (string basePath in pythonBasePaths)
{
if (Directory.Exists(basePath))
{
try
{
foreach (string dir in Directory.GetDirectories(basePath, "Python*"))
{
string versionName = Path.GetFileName(dir);
if (!pythonVersions.Contains(versionName))
{
pythonVersions.Add(versionName);
}
}
}
catch
{
// Ignore directory access errors
}
}
}
// Check Python installations for UV
foreach (string version in pythonVersions)
{
string[] pythonPaths = {
Path.Combine(appData, "Python", version, "Scripts", "uv.exe"),
Path.Combine(localAppData, "Programs", "Python", version, "Scripts", "uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Python", version, "Scripts", "uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Python", version, "Scripts", "uv.exe")
};
foreach (string uvPath in pythonPaths)
{
if (File.Exists(uvPath) && IsValidUvInstallation(uvPath))
{
return uvPath;
}
}
}
// Check package manager installations
string[] packageManagerPaths = {
// Chocolatey
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "chocolatey", "lib", "uv", "tools", "uv.exe"),
Path.Combine("C:", "ProgramData", "chocolatey", "lib", "uv", "tools", "uv.exe"),
// Scoop
Path.Combine(userProfile, "scoop", "apps", "uv", "current", "uv.exe"),
Path.Combine(userProfile, "scoop", "shims", "uv.exe"),
// Winget/msstore
Path.Combine(localAppData, "Microsoft", "WinGet", "Packages", "astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe", "uv.exe"),
// Common standalone installations
Path.Combine(localAppData, "uv", "uv.exe"),
Path.Combine(appData, "uv", "uv.exe"),
Path.Combine(userProfile, ".local", "bin", "uv.exe"),
Path.Combine(userProfile, "bin", "uv.exe"),
// Cargo/Rust installations
Path.Combine(userProfile, ".cargo", "bin", "uv.exe"),
// Manual installations in common locations
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "uv", "uv.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "uv", "uv.exe")
};
foreach (string uvPath in packageManagerPaths)
{
if (File.Exists(uvPath) && IsValidUvInstallation(uvPath))
{
return uvPath;
}
}
// Try to find uv via where command (Windows equivalent of which)
// Use where.exe explicitly to avoid PowerShell alias conflicts
try
{
var psi = new ProcessStartInfo
{
FileName = "where.exe",
Arguments = "uv",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(psi);
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output))
{
string[] lines = output.Split('\n');
foreach (string line in lines)
{
string cleanPath = line.Trim();
if (File.Exists(cleanPath) && IsValidUvInstallation(cleanPath))
{
return cleanPath;
}
}
}
}
catch
{
// If where.exe fails, try PowerShell's Get-Command as fallback
try
{
var psi = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = "-Command \"(Get-Command uv -ErrorAction SilentlyContinue).Source\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var process = Process.Start(psi);
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output))
{
if (IsValidUvInstallation(output))
{
return output;
}
}
}
catch
{
// Ignore PowerShell errors too
}
}
return null; // Will fallback to using 'uv' from PATH
}
// Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead
// Removed unused FindClaudeCommand
@ -2123,14 +1960,18 @@ namespace MCPForUnity.Editor.Windows
string unityProjectDir = Application.dataPath;
string projectDir = Path.GetDirectoryName(unityProjectDir);
// Read the global Claude config file
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? mcpClient.windowsConfigPath
: mcpClient.linuxConfigPath;
// Read the global Claude config file (honor macConfigPath on macOS)
string configPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
configPath = mcpClient.windowsConfigPath;
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath;
else
configPath = mcpClient.linuxConfigPath;
if (debugLogsEnabled)
{
UnityEngine.Debug.Log($"Checking Claude config at: {configPath}");
MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false);
}
if (!File.Exists(configPath))

View File

@ -119,7 +119,9 @@ namespace MCPForUnity.Editor.Windows
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath)
? mcpClient.linuxConfigPath
? configPath
: mcpClient.macConfigPath;
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))

View File

@ -17,6 +17,9 @@ class ServerConfig:
# Connection settings
connection_timeout: float = 60.0 # default steady-state timeout; retries use shorter timeouts
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Framed receive behavior
framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only
max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up
# Logging settings
log_level: str = "INFO"

View File

@ -1,6 +1,6 @@
[project]
name = "MCPForUnityServer"
version = "3.0.2"
version = "3.3.1"
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
requires-python = ">=3.10"

View File

@ -0,0 +1,11 @@
{
"typeCheckingMode": "basic",
"reportMissingImports": "none",
"pythonVersion": "3.11",
"executionEnvironments": [
{
"root": ".",
"pythonVersion": "3.11"
}
]
}

View File

@ -0,0 +1,8 @@
"""
Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim.
All functions are no-ops to prevent accidental external writes.
"""
def flip_reload_sentinel(*args, **kwargs) -> str:
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"

View File

@ -1 +1 @@
3.0.1
3.3.0

View File

@ -1,3 +1,5 @@
import logging
from .manage_script_edits import register_manage_script_edits_tools
from .manage_script import register_manage_script_tools
from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools
@ -6,10 +8,15 @@ from .manage_asset import register_manage_asset_tools
from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools
from .execute_menu_item import register_execute_menu_item_tools
from .resource_tools import register_resource_tools
logger = logging.getLogger("mcp-for-unity-server")
def register_all_tools(mcp):
"""Register all refactored tools with the MCP server."""
print("Registering MCP for Unity Server refactored tools...")
# Prefer the surgical edits tool so LLMs discover it first
logger.info("Registering MCP for Unity Server refactored tools...")
register_manage_script_edits_tools(mcp)
register_manage_script_tools(mcp)
register_manage_scene_tools(mcp)
register_manage_editor_tools(mcp)
@ -18,4 +25,6 @@ def register_all_tools(mcp):
register_manage_shader_tools(mcp)
register_read_console_tools(mcp)
register_execute_menu_item_tools(mcp)
print("MCP for Unity Server tool registration complete.")
# Expose resource wrappers as normal tools so IDEs without resources primitive can use them
register_resource_tools(mcp)
logger.info("MCP for Unity Server tool registration complete.")

View File

@ -76,4 +76,4 @@ def register_manage_asset_tools(mcp: FastMCP):
# Use centralized async retry helper to avoid blocking the event loop
result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

View File

@ -1,10 +1,10 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
import os
from typing import Dict, Any, List
from unity_connection import send_command_with_retry
import base64
import os
from urllib.parse import urlparse, unquote
try:
from telemetry_decorator import telemetry_tool
from telemetry import record_milestone, MilestoneType
@ -19,22 +19,441 @@ except ImportError:
def register_manage_script_tools(mcp: FastMCP):
"""Register all script management tools with the MCP server."""
@mcp.tool()
def _split_uri(uri: str) -> tuple[str, str]:
"""Split an incoming URI or path into (name, directory) suitable for Unity.
Rules:
- unity://path/Assets/... keep as Assets-relative (after decode/normalize)
- file://... percent-decode, normalize, strip host and leading slashes,
then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
Otherwise, fall back to original name/dir behavior.
- plain paths decode/normalize separators; if they contain an 'Assets' segment,
return relative to 'Assets'.
"""
raw_path: str
if uri.startswith("unity://path/"):
raw_path = uri[len("unity://path/") :]
elif uri.startswith("file://"):
parsed = urlparse(uri)
host = (parsed.netloc or "").strip()
p = parsed.path or ""
# UNC: file://server/share/... -> //server/share/...
if host and host.lower() != "localhost":
p = f"//{host}{p}"
# Use percent-decoded path, preserving leading slashes
raw_path = unquote(p)
else:
raw_path = uri
# Percent-decode any residual encodings and normalize separators
raw_path = unquote(raw_path).replace("\\", "/")
# Strip leading slash only for Windows drive-letter forms like "/C:/..."
if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
raw_path = raw_path[1:]
# Normalize path (collapse ../, ./)
norm = os.path.normpath(raw_path).replace("\\", "/")
# If an 'Assets' segment exists, compute path relative to it (case-insensitive)
parts = [p for p in norm.split("/") if p not in ("", ".")]
idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None)
assets_rel = "/".join(parts[idx:]) if idx is not None else None
effective_path = assets_rel if assets_rel else norm
# For POSIX absolute paths outside Assets, drop the leading '/'
# to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
if effective_path.startswith("/"):
effective_path = effective_path[1:]
name = os.path.splitext(os.path.basename(effective_path))[0]
directory = os.path.dirname(effective_path)
return name, directory
@mcp.tool(description=(
"Apply small text edits to a C# script identified by URI.\n\n"
"⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n"
"Common mistakes:\n"
"- Assuming what's on a line without checking\n"
"- Using wrong line numbers (they're 1-indexed)\n"
"- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n"
"RECOMMENDED WORKFLOW:\n"
"1) First call resources/read with start_line/line_count to verify exact content\n"
"2) Count columns carefully (or use find_in_file to locate patterns)\n"
"3) Apply your edit with precise coordinates\n"
"4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n"
"Args:\n"
"- uri: unity://path/Assets/... or file://... or Assets/...\n"
"- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n"
"- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n"
"Notes:\n"
"- Path must resolve under Assets/\n"
"- For method/class operations, use script_apply_edits (safer, structured edits)\n"
"- For pattern-based replacements, consider anchor operations in script_apply_edits\n"
))
@telemetry_tool("apply_text_edits")
def apply_text_edits(
ctx: Context,
uri: str,
edits: List[Dict[str, Any]],
precondition_sha256: str | None = None,
strict: bool | None = None,
options: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""Apply small text edits to a C# script identified by URI."""
name, directory = _split_uri(uri)
# Normalize common aliases/misuses for resilience:
# - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
# - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text}
# If normalization is required, read current contents to map indices -> 1-based line/col.
def _needs_normalization(arr: List[Dict[str, Any]]) -> bool:
for e in arr or []:
if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e):
return True
return False
normalized_edits: List[Dict[str, Any]] = []
warnings: List[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": directory,
})
if not (isinstance(read_resp, dict) and read_resp.get("success")):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data", {})
contents = data.get("contents")
if not contents and data.get("contentsEncoded"):
try:
contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace")
except Exception:
contents = contents or ""
# Helper to map 0-based character index to 1-based line/col
def line_col_from_index(idx: int) -> tuple[int, int]:
if idx <= 0:
return 1, 1
# Count lines up to idx and position within line
nl_count = contents.count("\n", 0, idx)
line = nl_count + 1
last_nl = contents.rfind("\n", 0, idx)
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
return line, col
for e in edits or []:
e2 = dict(e)
# Map text->newText if needed
if "newText" not in e2 and "text" in e2:
e2["newText"] = e2.pop("text")
if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2:
# Guard: explicit fields must be 1-based.
zero_based = False
for k in ("startLine","startCol","endLine","endCol"):
try:
if int(e2.get(k, 1)) < 1:
zero_based = True
except Exception:
pass
if zero_based:
if strict:
return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}}
# Normalize by clamping to 1 and warn
for k in ("startLine","startCol","endLine","endCol"):
try:
if int(e2.get(k, 1)) < 1:
e2[k] = 1
except Exception:
pass
warnings.append("zero_based_explicit_fields_normalized")
normalized_edits.append(e2)
continue
rng = e2.get("range")
if isinstance(rng, dict):
# LSP style: 0-based
s = rng.get("start", {})
t = rng.get("end", {})
e2["startLine"] = int(s.get("line", 0)) + 1
e2["startCol"] = int(s.get("character", 0)) + 1
e2["endLine"] = int(t.get("line", 0)) + 1
e2["endCol"] = int(t.get("character", 0)) + 1
e2.pop("range", None)
normalized_edits.append(e2)
continue
if isinstance(rng, (list, tuple)) and len(rng) == 2:
try:
a = int(rng[0])
b = int(rng[1])
if b < a:
a, b = b, a
sl, sc = line_col_from_index(a)
el, ec = line_col_from_index(b)
e2["startLine"] = sl
e2["startCol"] = sc
e2["endLine"] = el
e2["endCol"] = ec
e2.pop("range", None)
normalized_edits.append(e2)
continue
except Exception:
pass
# Could not normalize this edit
return {
"success": False,
"code": "missing_field",
"message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'",
"data": {"expected": ["startLine","startCol","endLine","endCol","newText"], "got": e}
}
else:
# Even when edits appear already in explicit form, validate 1-based coordinates.
normalized_edits = []
for e in edits or []:
e2 = dict(e)
has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol"))
if has_all:
zero_based = False
for k in ("startLine","startCol","endLine","endCol"):
try:
if int(e2.get(k, 1)) < 1:
zero_based = True
except Exception:
pass
if zero_based:
if strict:
return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}}
for k in ("startLine","startCol","endLine","endCol"):
try:
if int(e2.get(k, 1)) < 1:
e2[k] = 1
except Exception:
pass
if "zero_based_explicit_fields_normalized" not in warnings:
warnings.append("zero_based_explicit_fields_normalized")
normalized_edits.append(e2)
# Preflight: detect overlapping ranges among normalized line/col spans
def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]:
return (
int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)),
int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)),
)
def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1])
# Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap.
spans = []
for e in normalized_edits or []:
try:
s = _pos_tuple(e, True)
t = _pos_tuple(e, False)
if s != t:
spans.append((s, t))
except Exception:
# If coordinates missing or invalid, let the server validate later
pass
if spans:
spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1]))
for i in range(1, len(spans_sorted)):
prev_end = spans_sorted[i-1][1]
curr_start = spans_sorted[i][0]
# Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start
if not _le(prev_end, curr_start):
conflicts = [{
"startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]},
"endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]},
"startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]},
"endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]},
}]
return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}}
# Note: Do not auto-compute precondition if missing; callers should supply it
# via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and
# preserves existing call-count expectations in clients/tests.
# Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance
opts: Dict[str, Any] = dict(options or {})
try:
if len(normalized_edits) > 1 and "applyMode" not in opts:
opts["applyMode"] = "atomic"
except Exception:
pass
# Support optional debug preview for span-by-span simulation without write
if opts.get("debug_preview"):
try:
import difflib
# Apply locally to preview final result
lines = []
# Build an indexable original from a read if we normalized from read; otherwise skip
prev = ""
# We cannot guarantee file contents here without a read; return normalized spans only
return {
"success": True,
"message": "Preview only (no write)",
"data": {
"normalizedEdits": normalized_edits,
"preview": True
}
}
except Exception as e:
return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}}
params = {
"action": "apply_text_edits",
"name": name,
"path": directory,
"edits": normalized_edits,
"precondition_sha256": precondition_sha256,
"options": opts,
}
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict):
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", normalized_edits)
if warnings:
data.setdefault("warnings", warnings)
if resp.get("success") and (options or {}).get("force_sentinel_reload"):
# Optional: flip sentinel via menu if explicitly requested
try:
import threading, time, json, glob, os
def _latest_status() -> dict | None:
try:
files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
if not files:
return None
with open(files[0], "r") as f:
return json.loads(f.read())
except Exception:
return None
def _flip_async():
try:
time.sleep(0.1)
st = _latest_status()
if st and st.get("reloading"):
return
send_command_with_retry(
"execute_menu_item",
{"menuPath": "MCP/Flip Reload Sentinel"},
max_retries=0,
retry_ms=0,
)
except Exception:
pass
threading.Thread(target=_flip_async, daemon=True).start()
except Exception:
pass
return resp
return resp
return {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Create a new C# script at the given project path.\n\n"
"Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n"
"Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n"
))
@telemetry_tool("create_script")
def create_script(
ctx: Context,
path: str,
contents: str = "",
script_type: str | None = None,
namespace: str | None = None,
) -> Dict[str, Any]:
"""Create a new C# script at the given path."""
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
# Local validation to avoid round-trips on obviously bad input
norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/")
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."}
if ".." in norm_path.split("/") or norm_path.startswith("/"):
return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."}
if not name:
return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
if not norm_path.lower().endswith(".cs"):
return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
params: Dict[str, Any] = {
"action": "create",
"name": name,
"path": directory,
"namespace": namespace,
"scriptType": script_type,
}
if contents:
params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Delete a C# script by URI or Assets-relative path.\n\n"
"Args: uri (unity://path/... or file://... or Assets/...).\n"
"Rules: Target must resolve under Assets/.\n"
))
def delete_script(ctx: Context, uri: str) -> Dict[str, Any]:
"""Delete a C# script by URI."""
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
params = {"action": "delete", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Validate a C# script and return diagnostics.\n\n"
"Args: uri, level=('basic'|'standard').\n"
"- basic: quick syntax checks.\n"
"- standard: deeper checks (performance hints, common pitfalls).\n"
))
def validate_script(
ctx: Context, uri: str, level: str = "basic"
) -> Dict[str, Any]:
"""Validate a C# script and return diagnostics."""
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
if level not in ("basic", "standard"):
return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."}
params = {
"action": "validate",
"name": name,
"path": directory,
"level": level,
}
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Compatibility router for legacy script operations.\n\n"
"Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n"
"Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n"
"Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n"
))
@telemetry_tool("manage_script")
def manage_script(
ctx: Context,
action: str,
name: str,
path: str,
contents: str,
script_type: str,
namespace: str
contents: str = "",
script_type: str | None = None,
namespace: str | None = None,
) -> Dict[str, Any]:
"""Manages C# scripts in Unity (create, read, update, delete).
Make reference variables public for easier access in the Unity Editor.
"""Compatibility router for legacy script operations.
IMPORTANT:
- Direct file reads should use resources/read.
- Edits should use apply_text_edits.
Args:
action: Operation ('create', 'read', 'update', 'delete').
action: Operation ('create', 'read', 'delete').
name: Script name (no .cs extension).
path: Asset path (default: "Assets/").
contents: C# code for 'create'/'update'.
@ -45,42 +464,143 @@ def register_manage_script_tools(mcp: FastMCP):
Dictionary with results ('success', 'message', 'data').
"""
try:
# Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace)
if action == 'update':
try:
# 1) Read current contents to compute end range and precondition
read_resp = send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": path,
})
if not (isinstance(read_resp, dict) and read_resp.get("success")):
return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."}
data = read_resp.get("data", {})
current = data.get("contents")
if not current and data.get("contentsEncoded"):
current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace")
if current is None:
return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."}
# 2) Compute whole-file range (1-based, end exclusive) and SHA
import hashlib as _hashlib
old_lines = current.splitlines(keepends=True)
end_line = len(old_lines) + 1
sha = _hashlib.sha256(current.encode("utf-8")).hexdigest()
# 3) Apply single whole-file text edit with provided 'contents'
edits = [{
"startLine": 1,
"startCol": 1,
"endLine": end_line,
"endCol": 1,
"newText": contents or "",
}]
route_params = {
"action": "apply_text_edits",
"name": name,
"path": path,
"edits": edits,
"precondition_sha256": sha,
"options": {"refresh": "debounced", "validate": "standard"},
}
# Preflight size vs. default cap (256 KiB) to avoid opaque server errors
try:
import json as _json
payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8"))
if payload_bytes > 256 * 1024:
return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."}
except Exception:
pass
routed = send_command_with_retry("manage_script", route_params)
if isinstance(routed, dict):
routed.setdefault("message", "Routed legacy update to apply_text_edits")
return routed
return {"success": False, "message": str(routed)}
except Exception as e:
return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"}
# Prepare parameters for Unity
params = {
"action": action,
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type
"scriptType": script_type,
}
# Base64 encode the contents if they exist to avoid JSON escaping issues
if contents is not None:
if action in ['create', 'update']:
# Encode content for safer transmission
if contents:
if action == 'create':
params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8')
params["contentsEncoded"] = True
else:
params["contents"] = contents
# Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None}
# Send command via centralized retry helper
response = send_command_with_retry("manage_script", params)
# Process response from Unity
if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
if isinstance(response, dict):
if response.get("success"):
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"]
return {
"success": True,
"message": response.get("message", "Operation successful."),
"data": response.get("data"),
}
return response
return {"success": False, "message": str(response)}
except Exception as e:
# Handle Python-side errors (e.g., connection issues)
return {"success": False, "message": f"Python error managing script: {str(e)}"}
return {
"success": False,
"message": f"Python error managing script: {str(e)}",
}
@mcp.tool(description=(
"Get manage_script capabilities (supported ops, limits, and guards).\n\n"
"Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n"
))
def manage_script_capabilities(ctx: Context) -> Dict[str, Any]:
try:
# Keep in sync with server/Editor ManageScript implementation
ops = [
"replace_class","delete_class","replace_method","delete_method",
"insert_method","anchor_insert","anchor_delete","anchor_replace"
]
text_ops = ["replace_range","regex_replace","prepend","append"]
# Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback
max_edit_payload_bytes = 256 * 1024
guards = {"using_guard": True}
extras = {"get_sha": True}
return {"success": True, "data": {
"ops": ops,
"text_ops": text_ops,
"max_edit_payload_bytes": max_edit_payload_bytes,
"guards": guards,
"extras": extras,
}}
except Exception as e:
return {"success": False, "error": f"capabilities error: {e}"}
@mcp.tool(description=(
"Get SHA256 and metadata for a Unity C# script without returning file contents.\n\n"
"Args: uri (unity://path/Assets/... or file://... or Assets/...).\n"
"Returns: {sha256, lengthBytes, lastModifiedUtc, uri, path}."
))
def get_sha(ctx: Context, uri: str) -> Dict[str, Any]:
"""Return SHA256 and basic metadata for a script."""
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
except Exception as e:
return {"success": False, "message": f"get_sha error: {e}"}

View File

@ -0,0 +1,902 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List, Tuple
import base64
import re
import os
from unity_connection import send_command_with_retry
def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str:
text = original_text
for edit in edits or []:
op = (
(edit.get("op")
or edit.get("operation")
or edit.get("type")
or edit.get("mode")
or "")
.strip()
.lower()
)
if not op:
allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
raise RuntimeError(
f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)."
)
if op == "prepend":
prepend_text = edit.get("text", "")
text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text
elif op == "append":
append_text = edit.get("text", "")
if not text.endswith("\n"):
text += "\n"
text += append_text
if not text.endswith("\n"):
text += "\n"
elif op == "anchor_insert":
anchor = edit.get("anchor", "")
position = (edit.get("position") or "before").lower()
insert_text = edit.get("text", "")
flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0)
# Find the best match using improved heuristics
match = _find_best_anchor_match(anchor, text, flags, bool(edit.get("prefer_last", True)))
if not match:
if edit.get("allow_noop", True):
continue
raise RuntimeError(f"anchor not found: {anchor}")
idx = match.start() if position == "before" else match.end()
text = text[:idx] + insert_text + text[idx:]
elif op == "replace_range":
start_line = int(edit.get("startLine", 1))
start_col = int(edit.get("startCol", 1))
end_line = int(edit.get("endLine", start_line))
end_col = int(edit.get("endCol", 1))
replacement = edit.get("text", "")
lines = text.splitlines(keepends=True)
max_line = len(lines) + 1 # 1-based, exclusive end
if (start_line < 1 or end_line < start_line or end_line > max_line
or start_col < 1 or end_col < 1):
raise RuntimeError("replace_range out of bounds")
def index_of(line: int, col: int) -> int:
if line <= len(lines):
return sum(len(l) for l in lines[: line - 1]) + (col - 1)
return sum(len(l) for l in lines)
a = index_of(start_line, start_col)
b = index_of(end_line, end_col)
text = text[:a] + replacement + text[b:]
elif op == "regex_replace":
pattern = edit.get("pattern", "")
repl = edit.get("replacement", "")
# Translate $n backrefs (our input) to Python \g<n>
repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
count = int(edit.get("count", 0)) # 0 = replace all
flags = re.MULTILINE
if edit.get("ignore_case"):
flags |= re.IGNORECASE
text = re.sub(pattern, repl_py, text, count=count, flags=flags)
else:
allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
return text
def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
"""
Find the best anchor match using improved heuristics.
For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
this function uses heuristics to choose the most semantically appropriate match:
1. If prefer_last=True, prefer the last match (common for class-end insertions)
2. Use indentation levels to distinguish class vs method braces
3. Consider context to avoid matches inside strings/comments
Args:
pattern: Regex pattern to search for
text: Text to search in
flags: Regex flags
prefer_last: If True, prefer the last match over the first
Returns:
Match object of the best match, or None if no match found
"""
import re
# Find all matches
matches = list(re.finditer(pattern, text, flags))
if not matches:
return None
# If only one match, return it
if len(matches) == 1:
return matches[0]
# For patterns that look like they're trying to match closing braces at end of lines
is_closing_brace_pattern = '}' in pattern and ('$' in pattern or pattern.endswith(r'\s*'))
if is_closing_brace_pattern and prefer_last:
# Use heuristics to find the best closing brace match
return _find_best_closing_brace_match(matches, text)
# Default behavior: use last match if prefer_last, otherwise first match
return matches[-1] if prefer_last else matches[0]
def _find_best_closing_brace_match(matches, text: str):
"""
Find the best closing brace match using C# structure heuristics.
Enhanced heuristics for scope-aware matching:
1. Prefer matches with lower indentation (likely class-level)
2. Prefer matches closer to end of file
3. Avoid matches that seem to be inside method bodies
4. For #endregion patterns, ensure class-level context
5. Validate insertion point is at appropriate scope
Args:
matches: List of regex match objects
text: The full text being searched
Returns:
The best match object
"""
if not matches:
return None
scored_matches = []
lines = text.splitlines()
for match in matches:
score = 0
start_pos = match.start()
# Find which line this match is on
lines_before = text[:start_pos].count('\n')
line_num = lines_before
if line_num < len(lines):
line_content = lines[line_num]
# Calculate indentation level (lower is better for class braces)
indentation = len(line_content) - len(line_content.lstrip())
# Prefer lower indentation (class braces are typically less indented than method braces)
score += max(0, 20 - indentation) # Max 20 points for indentation=0
# Prefer matches closer to end of file (class closing braces are typically at the end)
distance_from_end = len(lines) - line_num
score += max(0, 10 - distance_from_end) # More points for being closer to end
# Look at surrounding context to avoid method braces
context_start = max(0, line_num - 3)
context_end = min(len(lines), line_num + 2)
context_lines = lines[context_start:context_end]
# Penalize if this looks like it's inside a method (has method-like patterns above)
for context_line in context_lines:
if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
score -= 5 # Penalty for being near method signatures
# Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
if indentation <= 4 and distance_from_end <= 3:
score += 15 # Bonus for likely class-ending brace
scored_matches.append((score, match))
# Return the match with the highest score
scored_matches.sort(key=lambda x: x[0], reverse=True)
best_match = scored_matches[0][1]
return best_match
def _infer_class_name(script_name: str) -> str:
# Default to script name as class name (common Unity pattern)
return (script_name or "").strip()
def _extract_code_after(keyword: str, request: str) -> str:
# Deprecated with NL removal; retained as no-op for compatibility
idx = request.lower().find(keyword)
if idx >= 0:
return request[idx + len(keyword):].strip()
return ""
# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]:
"""Best-effort normalization of script "name" and "path".
Accepts any of:
- name = "SmartReach", path = "Assets/Scripts/Interaction"
- name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
- name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
- path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
- name or path using uri prefixes: unity://path/..., file://...
- accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
Returns (name_without_extension, directory_path_under_Assets).
"""
n = (name or "").strip()
p = (path or "").strip()
def strip_prefix(s: str) -> str:
if s.startswith("unity://path/"):
return s[len("unity://path/"):]
if s.startswith("file://"):
return s[len("file://"):]
return s
def collapse_duplicate_tail(s: str) -> str:
# Collapse trailing "/X.cs/X.cs" to "/X.cs"
parts = s.split("/")
if len(parts) >= 2 and parts[-1] == parts[-2]:
parts = parts[:-1]
return "/".join(parts)
# Prefer a full path if provided in either field
candidate = ""
for v in (n, p):
v2 = strip_prefix(v)
if v2.endswith(".cs") or v2.startswith("Assets/"):
candidate = v2
break
if candidate:
candidate = collapse_duplicate_tail(candidate)
# If a directory was passed in path and file in name, join them
if not candidate.endswith(".cs") and n.endswith(".cs"):
v2 = strip_prefix(n)
candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1])
if candidate.endswith(".cs"):
parts = candidate.split("/")
file_name = parts[-1]
dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name
return base, dir_path
# Fall back: remove extension from name if present and return given path
base_name = n[:-3] if n.lower().endswith(".cs") else n
return base_name, (p or "Assets")
def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any:
if not isinstance(resp, dict):
return resp
data = resp.setdefault("data", {})
data.setdefault("normalizedEdits", edits)
if routing:
data["routing"] = routing
return resp
def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None,
normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {"success": False, "code": code, "message": message}
data: Dict[str, Any] = {}
if expected:
data["expected"] = expected
if rewrite:
data["rewrite_suggestion"] = rewrite
if normalized is not None:
data["normalizedEdits"] = normalized
if routing:
data["routing"] = routing
if extra:
data.update(extra)
if data:
payload["data"] = data
return payload
# Natural-language parsing removed; clients should send structured edits.
def register_manage_script_edits_tools(mcp: FastMCP):
@mcp.tool(description=(
"Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n"
"Best practices:\n"
"- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n"
"- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n"
"- Avoid whole-file regex deletes; validators will guard unbalanced braces\n"
"- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n"
"- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n"
"Canonical fields (use these exact keys):\n"
"- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n"
"- className: string (defaults to 'name' if omitted on method/class ops)\n"
"- methodName: string (required for replace_method, delete_method)\n"
"- replacement: string (required for replace_method, insert_method)\n"
"- position: start | end | after | before (insert_method only)\n"
"- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n"
"- anchor: regex string (for anchor_* ops)\n"
"- text: string (for anchor_insert/anchor_replace)\n\n"
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n"
"Examples:\n"
"1) Replace a method:\n"
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
" { 'op':'replace_method','className':'SmartReach','methodName':'HasTarget',\n"
" 'replacement':'public bool HasTarget(){ return currentTarget!=null; }' }\n"
"], 'options':{'validate':'standard','refresh':'immediate'} }\n\n"
"2) Insert a method after another:\n"
"{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n"
" { 'op':'insert_method','className':'SmartReach','replacement':'public void PrintSeries(){ Debug.Log(seriesName); }',\n"
" 'position':'after','afterMethodName':'GetCurrentTarget' }\n"
"] }\n"
))
def script_apply_edits(
ctx: Context,
name: str,
path: str,
edits: List[Dict[str, Any]],
options: Dict[str, Any] | None = None,
script_type: str = "MonoBehaviour",
namespace: str = "",
) -> Dict[str, Any]:
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
# No NL path: clients must provide structured edits in 'edits'.
# Normalize unsupported or aliased ops to known structured/text paths
def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]:
# Unwrap single-key wrappers like {"replace_method": {...}}
for wrapper_key in (
"replace_method","insert_method","delete_method",
"replace_class","delete_class",
"anchor_insert","anchor_replace","anchor_delete",
):
if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
inner = dict(edit[wrapper_key])
inner["op"] = wrapper_key
edit = inner
break
e = dict(edit)
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
if op:
e["op"] = op
# Common field aliases
if "class_name" in e and "className" not in e:
e["className"] = e.pop("class_name")
if "class" in e and "className" not in e:
e["className"] = e.pop("class")
if "method_name" in e and "methodName" not in e:
e["methodName"] = e.pop("method_name")
# Some clients use a generic 'target' for method name
if "target" in e and "methodName" not in e:
e["methodName"] = e.pop("target")
if "method" in e and "methodName" not in e:
e["methodName"] = e.pop("method")
if "new_content" in e and "replacement" not in e:
e["replacement"] = e.pop("new_content")
if "newMethod" in e and "replacement" not in e:
e["replacement"] = e.pop("newMethod")
if "new_method" in e and "replacement" not in e:
e["replacement"] = e.pop("new_method")
if "content" in e and "replacement" not in e:
e["replacement"] = e.pop("content")
if "after" in e and "afterMethodName" not in e:
e["afterMethodName"] = e.pop("after")
if "after_method" in e and "afterMethodName" not in e:
e["afterMethodName"] = e.pop("after_method")
if "before" in e and "beforeMethodName" not in e:
e["beforeMethodName"] = e.pop("before")
if "before_method" in e and "beforeMethodName" not in e:
e["beforeMethodName"] = e.pop("before_method")
# anchor_method → before/after based on position (default after)
if "anchor_method" in e:
anchor = e.pop("anchor_method")
pos = (e.get("position") or "after").strip().lower()
if pos == "before" and "beforeMethodName" not in e:
e["beforeMethodName"] = anchor
elif "afterMethodName" not in e:
e["afterMethodName"] = anchor
if "anchorText" in e and "anchor" not in e:
e["anchor"] = e.pop("anchorText")
if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"):
e["anchor"] = e.pop("pattern")
if "newText" in e and "text" not in e:
e["text"] = e.pop("newText")
# CI compatibility (TA/TE):
# Accept method-anchored anchor_insert and upgrade to insert_method
# Example incoming shape:
# {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."}
if (
e.get("op") == "anchor_insert"
and not e.get("anchor")
and (e.get("afterMethodName") or e.get("beforeMethodName"))
):
e["op"] = "insert_method"
if "replacement" not in e:
e["replacement"] = e.get("text", "")
# LSP-like range edit -> replace_range
if "range" in e and isinstance(e["range"], dict):
rng = e.pop("range")
start = rng.get("start", {})
end = rng.get("end", {})
# Convert 0-based to 1-based line/col
e["op"] = "replace_range"
e["startLine"] = int(start.get("line", 0)) + 1
e["startCol"] = int(start.get("character", 0)) + 1
e["endLine"] = int(end.get("line", 0)) + 1
e["endCol"] = int(end.get("character", 0)) + 1
if "newText" in edit and "text" not in e:
e["text"] = edit.get("newText", "")
return e
normalized_edits: List[Dict[str, Any]] = []
for raw in edits or []:
e = _unwrap_and_alias(raw)
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
# Default className to script name if missing on structured method/class ops
if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"):
e["className"] = name
# Map common aliases for text ops
if op in ("text_replace",):
e["op"] = "replace_range"
normalized_edits.append(e)
continue
if op in ("regex_delete",):
e["op"] = "regex_replace"
e.setdefault("text", "")
normalized_edits.append(e)
continue
if op == "regex_replace" and ("replacement" not in e):
if "text" in e:
e["replacement"] = e.get("text", "")
elif "insert" in e or "content" in e:
e["replacement"] = e.get("insert") or e.get("content") or ""
if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
e["op"] = "anchor_delete"
normalized_edits.append(e)
continue
normalized_edits.append(e)
edits = normalized_edits
normalized_for_echo = edits
# Validate required fields and produce machine-parsable hints
def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]:
return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
for e in edits or []:
op = e.get("op", "")
if op == "replace_method":
if not e.get("methodName"):
return error_with_hint(
"replace_method requires 'methodName'.",
{"op": "replace_method", "required": ["className", "methodName", "replacement"]},
{"edits[0].methodName": "HasTarget"}
)
if not (e.get("replacement") or e.get("text")):
return error_with_hint(
"replace_method requires 'replacement' (inline or base64).",
{"op": "replace_method", "required": ["className", "methodName", "replacement"]},
{"edits[0].replacement": "public bool X(){ return true; }"}
)
elif op == "insert_method":
if not (e.get("replacement") or e.get("text")):
return error_with_hint(
"insert_method requires a non-empty 'replacement'.",
{"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
{"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
)
pos = (e.get("position") or "").lower()
if pos == "after" and not e.get("afterMethodName"):
return error_with_hint(
"insert_method with position='after' requires 'afterMethodName'.",
{"op": "insert_method", "position": {"after_requires": "afterMethodName"}},
{"edits[0].afterMethodName": "GetCurrentTarget"}
)
if pos == "before" and not e.get("beforeMethodName"):
return error_with_hint(
"insert_method with position='before' requires 'beforeMethodName'.",
{"op": "insert_method", "position": {"before_requires": "beforeMethodName"}},
{"edits[0].beforeMethodName": "GetCurrentTarget"}
)
elif op == "delete_method":
if not e.get("methodName"):
return error_with_hint(
"delete_method requires 'methodName'.",
{"op": "delete_method", "required": ["className", "methodName"]},
{"edits[0].methodName": "PrintSeries"}
)
elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
if not e.get("anchor"):
return error_with_hint(
f"{op} requires 'anchor' (regex).",
{"op": op, "required": ["anchor"]},
{"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("}
)
if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")):
return error_with_hint(
f"{op} requires 'text'.",
{"op": op, "required": ["anchor", "text"]},
{"edits[0].text": "/* comment */\n"}
)
# Decide routing: structured vs text vs mixed
STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"}
TEXT = {"prepend","append","replace_range","regex_replace"}
ops_set = { (e.get("op") or "").lower() for e in edits or [] }
all_struct = ops_set.issubset(STRUCT)
all_text = ops_set.issubset(TEXT)
mixed = not (all_struct or all_text)
# If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
if all_struct:
opts2 = dict(options or {})
# For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
opts2.setdefault("refresh", "immediate")
params_struct: Dict[str, Any] = {
"action": "edit",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
"edits": edits,
"options": opts2,
}
resp_struct = send_command_with_retry("manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
# 1) read from Unity
read_resp = send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
})
if not isinstance(read_resp, dict) or not read_resp.get("success"):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {}
contents = data.get("contents")
if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
contents = base64.b64decode(data["encodedContents"]).decode("utf-8")
if contents is None:
return {"success": False, "message": "No contents returned from Unity read."}
# Optional preview/dry-run: apply locally and return diff without writing
preview = bool((options or {}).get("preview"))
# If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
if mixed:
text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT]
struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT]
try:
base_text = contents
def line_col_from_index(idx: int) -> Tuple[int, int]:
line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx)
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
return line, col
at_edits: List[Dict[str, Any]] = []
import re as _re
for e in text_edits:
opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or ""
if opx == "anchor_insert":
anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower()
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
try:
# Use improved anchor matching logic
m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
if not m:
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
idx = m.start() if position == "before" else m.end()
# Normalize insertion to avoid jammed methods
text_field_norm = text_field
if not text_field_norm.startswith("\n"):
text_field_norm = "\n" + text_field_norm
if not text_field_norm.endswith("\n"):
text_field_norm = text_field_norm + "\n"
sl, sc = line_col_from_index(idx)
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
# do not mutate base_text when building atomic spans
elif opx == "replace_range":
if all(k in e for k in ("startLine","startCol","endLine","endCol")):
at_edits.append({
"startLine": int(e.get("startLine", 1)),
"startCol": int(e.get("startCol", 1)),
"endLine": int(e.get("endLine", 1)),
"endCol": int(e.get("endCol", 1)),
"newText": text_field
})
else:
return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
elif opx == "regex_replace":
pattern = e.get("pattern") or ""
try:
regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0))
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
m = regex_obj.search(base_text)
if not m:
continue
# Expand $1, $2... in replacement using this match
def _expand_dollars(rep: str, _m=m) -> str:
return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
repl = _expand_dollars(text_field)
sl, sc = line_col_from_index(m.start())
el, ec = line_col_from_index(m.end())
at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
# do not mutate base_text when building atomic spans
elif opx in ("prepend","append"):
if opx == "prepend":
sl, sc = 1, 1
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
# prepend can be applied atomically without local mutation
else:
# Insert at true EOF position (handles both \n and \r\n correctly)
eof_idx = len(base_text)
sl, sc = line_col_from_index(eof_idx)
new_text = ("\n" if not base_text.endswith("\n") else "") + text_field
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
# do not mutate base_text when building atomic spans
else:
return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
import hashlib
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
if at_edits:
params_text: Dict[str, Any] = {
"action": "apply_text_edits",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
"edits": at_edits,
"precondition_sha256": sha,
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
}
resp_text = send_command_with_retry("manage_script", params_text)
if not (isinstance(resp_text, dict) and resp_text.get("success")):
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
# Optional sentinel reload removed (deprecated)
except Exception as e:
return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
if struct_edits:
opts2 = dict(options or {})
# Prefer debounced background refresh unless explicitly overridden
opts2.setdefault("refresh", "debounced")
params_struct: Dict[str, Any] = {
"action": "edit",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
"edits": struct_edits,
"options": opts2
}
resp_struct = send_command_with_retry("manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
# If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
# so header guards and validation run on the C# side.
# Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) }
structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"}
if not text_ops.issubset(structured_kinds):
# Convert to apply_text_edits payload
try:
base_text = contents
def line_col_from_index(idx: int) -> Tuple[int, int]:
# 1-based line/col against base buffer
line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx)
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
return line, col
at_edits: List[Dict[str, Any]] = []
import re as _re
for e in edits or []:
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
# aliasing for text field
text_field = e.get("text") or e.get("insert") or e.get("content") or ""
if op == "anchor_insert":
anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower()
# Use improved anchor matching logic with helpful errors, honoring ignore_case
try:
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
if not m:
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
idx = m.start() if position == "before" else m.end()
# Normalize insertion newlines
if text_field and not text_field.startswith("\n"):
text_field = "\n" + text_field
if text_field and not text_field.endswith("\n"):
text_field = text_field + "\n"
sl, sc = line_col_from_index(idx)
at_edits.append({
"startLine": sl,
"startCol": sc,
"endLine": sl,
"endCol": sc,
"newText": text_field or ""
})
# Do not mutate base buffer when building an atomic batch
elif op == "replace_range":
# Directly forward if already in line/col form
if "startLine" in e:
at_edits.append({
"startLine": int(e.get("startLine", 1)),
"startCol": int(e.get("startCol", 1)),
"endLine": int(e.get("endLine", 1)),
"endCol": int(e.get("endCol", 1)),
"newText": text_field
})
else:
# If only indices provided, skip (we don't support index-based here)
return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
elif op == "regex_replace":
pattern = e.get("pattern") or ""
repl = text_field
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
# Early compile for clearer error messages
try:
regex_obj = _re.compile(pattern, flags)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
# Use smart anchor matching for consistent behavior with anchor_insert
m = _find_best_anchor_match(pattern, base_text, flags, prefer_last=True)
if not m:
continue
# Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
def _expand_dollars(rep: str, _m=m) -> str:
return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
repl_expanded = _expand_dollars(repl)
# Let C# side handle validation using Unity's built-in compiler services
sl, sc = line_col_from_index(m.start())
el, ec = line_col_from_index(m.end())
at_edits.append({
"startLine": sl,
"startCol": sc,
"endLine": el,
"endCol": ec,
"newText": repl_expanded
})
# Do not mutate base buffer when building an atomic batch
else:
return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
if not at_edits:
return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
# Send to Unity with precondition SHA to enforce guards and immediate refresh
import hashlib
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
params: Dict[str, Any] = {
"action": "apply_text_edits",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
"edits": at_edits,
"precondition_sha256": sha,
"options": {
"refresh": (options or {}).get("refresh", "debounced"),
"validate": (options or {}).get("validate", "standard"),
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
}
}
resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
resp if isinstance(resp, dict) else {"success": False, "message": str(resp)},
normalized_for_echo,
routing="text"
)
except Exception as e:
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
# For regex_replace, honor preview consistently: if preview=true, always return diff without writing.
# If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply.
if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")):
try:
preview_text = _apply_edits_locally(contents, edits)
import difflib
diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
if len(diff) > 800:
diff = diff[:800] + ["... (diff truncated) ..."]
if preview:
return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text")
except Exception as e:
return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text")
# 2) apply edits locally (only if not text-ops)
try:
new_contents = _apply_edits_locally(contents, edits)
except Exception as e:
return {"success": False, "message": f"Edit application failed: {e}"}
# Short-circuit no-op edits to avoid false "applied" reports downstream
if new_contents == contents:
return _with_norm({
"success": True,
"message": "No-op: contents unchanged",
"data": {"no_op": True, "evidence": {"reason": "identical_content"}}
}, normalized_for_echo, routing="text")
if preview:
# Produce a compact unified diff limited to small context
import difflib
a = contents.splitlines()
b = new_contents.splitlines()
diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3))
# Limit diff size to keep responses small
if len(diff) > 2000:
diff = diff[:2000] + ["... (diff truncated) ..."]
return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
# 3) update to Unity
# Default refresh/validate for natural usage on text path as well
options = dict(options or {})
options.setdefault("validate", "standard")
options.setdefault("refresh", "debounced")
import hashlib
# Compute the SHA of the current file contents for the precondition
old_lines = contents.splitlines(keepends=True)
end_line = len(old_lines) + 1 # 1-based exclusive end
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
# Apply a whole-file text edit rather than the deprecated 'update' action
params = {
"action": "apply_text_edits",
"name": name,
"path": path,
"namespace": namespace,
"scriptType": script_type,
"edits": [
{
"startLine": 1,
"startCol": 1,
"endLine": end_line,
"endCol": 1,
"newText": new_contents,
}
],
"precondition_sha256": sha,
"options": options or {"validate": "standard", "refresh": "debounced"},
}
write_resp = send_command_with_retry("manage_script", params)
if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
write_resp if isinstance(write_resp, dict)
else {"success": False, "message": str(write_resp)},
normalized_for_echo,
routing="text",
)
# safe_script_edit removed to simplify API; clients should call script_apply_edits directly

View File

@ -0,0 +1,357 @@
"""
Resource wrapper tools so clients that do not expose MCP resources primitives
can still list and read files via normal tools. These call into the same
safe path logic (re-implemented here to avoid importing server.py).
"""
from __future__ import annotations
from typing import Dict, Any, List
import re
from pathlib import Path
from urllib.parse import urlparse, unquote
import fnmatch
import hashlib
import os
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import send_command_with_retry
def _resolve_project_root(override: str | None) -> Path:
# 1) Explicit override
if override:
pr = Path(override).expanduser().resolve()
if (pr / "Assets").exists():
return pr
# 2) Environment
env = os.environ.get("UNITY_PROJECT_ROOT")
if env:
env_path = Path(env).expanduser()
# If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir
pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve()
if (pr / "Assets").exists():
return pr
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry("manage_editor", {"action": "get_project_root"})
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve()
if pr and (pr / "Assets").exists():
return pr
except Exception:
pass
# 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings)
cur = Path.cwd().resolve()
for _ in range(6):
if (cur / "Assets").exists() and (cur / "ProjectSettings").exists():
return cur
if cur.parent == cur:
break
cur = cur.parent
# 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings
try:
import os as _os
root = Path.cwd().resolve()
max_depth = 3
for dirpath, dirnames, _ in _os.walk(root):
rel = Path(dirpath).resolve()
try:
depth = len(rel.relative_to(root).parts)
except Exception:
# Unrelated mount/permission edge; skip deeper traversal
dirnames[:] = []
continue
if depth > max_depth:
# Prune deeper traversal
dirnames[:] = []
continue
if (rel / "Assets").exists() and (rel / "ProjectSettings").exists():
return rel
except Exception:
pass
# 6) Fallback: CWD
return Path.cwd().resolve()
def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None:
raw: str | None = None
if uri.startswith("unity://path/"):
raw = uri[len("unity://path/"):]
elif uri.startswith("file://"):
parsed = urlparse(uri)
raw = unquote(parsed.path or "")
# On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters.
try:
import os as _os
if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw):
raw = raw[1:]
# UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share
if _os.name == "nt" and parsed.netloc:
raw = f"//{parsed.netloc}{raw}"
except Exception:
pass
elif uri.startswith("Assets/"):
raw = uri
if raw is None:
return None
# Normalize separators early
raw = raw.replace("\\", "/")
p = (project / raw).resolve()
try:
p.relative_to(project)
except ValueError:
return None
return p
def register_resource_tools(mcp: FastMCP) -> None:
"""Registers list_resources and read_resource wrapper tools."""
@mcp.tool(description=(
"List project URIs (unity://path/...) under a folder (default: Assets).\n\n"
"Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n"
"Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n"
"Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n"
))
async def list_resources(
ctx: Context | None = None,
pattern: str | None = "*.cs",
under: str = "Assets",
limit: int = 200,
project_root: str | None = None,
) -> Dict[str, Any]:
"""
Lists project URIs (unity://path/...) under a folder (default: Assets).
- pattern: glob like *.cs or *.shader (None to list all files)
- under: relative folder under project root
- limit: max results
"""
try:
project = _resolve_project_root(project_root)
base = (project / under).resolve()
try:
base.relative_to(project)
except ValueError:
return {"success": False, "error": "Base path must be under project root"}
# Enforce listing only under Assets
try:
base.relative_to(project / "Assets")
except ValueError:
return {"success": False, "error": "Listing is restricted to Assets/"}
matches: List[str] = []
for p in base.rglob("*"):
if not p.is_file():
continue
# Resolve symlinks and ensure the real path stays under project/Assets
try:
rp = p.resolve()
rp.relative_to(project / "Assets")
except Exception:
continue
# Enforce .cs extension regardless of provided pattern
if p.suffix.lower() != ".cs":
continue
if pattern and not fnmatch.fnmatch(p.name, pattern):
continue
rel = p.relative_to(project).as_posix()
matches.append(f"unity://path/{rel}")
if len(matches) >= max(1, limit):
break
# Always include the canonical spec resource so NL clients can discover it
if "unity://spec/script-edits" not in matches:
matches.append("unity://spec/script-edits")
return {"success": True, "data": {"uris": matches, "count": len(matches)}}
except Exception as e:
return {"success": False, "error": str(e)}
@mcp.tool(description=(
"Read a resource by unity://path/... URI with optional slicing.\n\n"
"Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n"
"Security: uri must resolve under Assets/.\n"
"Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n"
))
async def read_resource(
uri: str,
ctx: Context | None = None,
start_line: int | None = None,
line_count: int | None = None,
head_bytes: int | None = None,
tail_lines: int | None = None,
project_root: str | None = None,
request: str | None = None,
) -> Dict[str, Any]:
"""
Reads a resource by unity://path/... URI with optional slicing.
One of line window (start_line/line_count) or head_bytes can be used to limit size.
"""
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"):
spec_json = (
'{\n'
' "name": "Unity MCP — Script Edits v1",\n'
' "target_tool": "script_apply_edits",\n'
' "canonical_rules": {\n'
' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n'
' "never_use": ["new_method","anchor_method","content","newText"],\n'
' "defaults": {\n'
' "className": "\u2190 server will default to \'name\' when omitted",\n'
' "position": "end"\n'
' }\n'
' },\n'
' "ops": [\n'
' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n'
' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n'
' {"op":"delete_method","required":["className","methodName"]},\n'
' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n'
' ],\n'
' "apply_text_edits_recipe": {\n'
' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n'
' "step2_apply": {\n'
' "tool": "manage_script",\n'
' "args": {\n'
' "action": "apply_text_edits",\n'
' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n'
' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n'
' "precondition_sha256": "<sha-from-step1>",\n'
' "options": {"refresh": "immediate", "validate": "standard"}\n'
' }\n'
' },\n'
' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n'
' },\n'
' "examples": [\n'
' {\n'
' "title": "Replace a method",\n'
' "args": {\n'
' "name": "SmartReach",\n'
' "path": "Assets/Scripts/Interaction",\n'
' "edits": [\n'
' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n'
' ],\n'
' "options": { "validate": "standard", "refresh": "immediate" }\n'
' }\n'
' },\n'
' {\n'
' "title": "Insert a method after another",\n'
' "args": {\n'
' "name": "SmartReach",\n'
' "path": "Assets/Scripts/Interaction",\n'
' "edits": [\n'
' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n'
' ]\n'
' }\n'
' }\n'
' ]\n'
'}\n'
)
sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest()
return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}}
project = _resolve_project_root(project_root)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
try:
p.relative_to(project / "Assets")
except ValueError:
return {"success": False, "error": "Read restricted to Assets/"}
# Natural-language convenience: request like "last 120 lines", "first 200 lines",
# "show 40 lines around MethodName", etc.
if request:
req = request.strip().lower()
m = re.search(r"last\s+(\d+)\s+lines", req)
if m:
tail_lines = int(m.group(1))
m = re.search(r"first\s+(\d+)\s+lines", req)
if m:
start_line = 1
line_count = int(m.group(1))
m = re.search(r"first\s+(\d+)\s*bytes", req)
if m:
head_bytes = int(m.group(1))
m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req)
if m:
window = int(m.group(1))
method = m.group(2)
# naive search for method header to get a line number
text_all = p.read_text(encoding="utf-8")
lines_all = text_all.splitlines()
pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE)
hit_line = None
for i, line in enumerate(lines_all, start=1):
if pat.search(line):
hit_line = i
break
if hit_line:
half = max(1, window // 2)
start_line = max(1, hit_line - half)
line_count = window
# Mutually exclusive windowing options precedence:
# 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text
if head_bytes and head_bytes > 0:
raw = p.read_bytes()[: head_bytes]
text = raw.decode("utf-8", errors="replace")
else:
text = p.read_text(encoding="utf-8")
if tail_lines is not None and tail_lines > 0:
lines = text.splitlines()
n = max(0, tail_lines)
text = "\n".join(lines[-n:])
elif start_line is not None and line_count is not None and line_count >= 0:
lines = text.splitlines()
s = max(0, start_line - 1)
e = min(len(lines), s + line_count)
text = "\n".join(lines[s:e])
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
return {"success": True, "data": {"text": text, "metadata": {"sha256": sha}}}
except Exception as e:
return {"success": False, "error": str(e)}
@mcp.tool()
async def find_in_file(
uri: str,
pattern: str,
ctx: Context | None = None,
ignore_case: bool | None = True,
project_root: str | None = None,
max_results: int | None = 200,
) -> Dict[str, Any]:
"""
Searches a file with a regex pattern and returns line numbers and excerpts.
- uri: unity://path/Assets/... or file path form supported by read_resource
- pattern: regular expression (Python re)
- ignore_case: case-insensitive by default
- max_results: cap results to avoid huge payloads
"""
# re is already imported at module level
try:
project = _resolve_project_root(project_root)
p = _resolve_safe_path_from_uri(uri, project)
if not p or not p.exists() or not p.is_file():
return {"success": False, "error": f"Resource not found: {uri}"}
text = p.read_text(encoding="utf-8")
flags = re.MULTILINE
if ignore_case:
flags |= re.IGNORECASE
rx = re.compile(pattern, flags)
results = []
lines = text.splitlines()
for i, line in enumerate(lines, start=1):
if rx.search(line):
results.append({"line": i, "text": line})
if max_results and len(results) >= max_results:
break
return {"success": True, "data": {"matches": results, "count": len(results)}}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@ -1,12 +1,15 @@
import socket
import contextlib
import errno
import json
import logging
import random
import socket
import struct
import threading
import time
from dataclasses import dataclass
from pathlib import Path
import time
import random
import errno
from typing import Dict, Any
from typing import Any, Dict
from config import config
from port_discovery import PortDiscovery
@ -17,31 +20,86 @@ logging.basicConfig(
)
logger = logging.getLogger("mcp-for-unity-server")
# Module-level lock to guard global connection initialization
_connection_lock = threading.Lock()
# Maximum allowed framed payload size (64 MiB)
FRAMED_MAX = 64 * 1024 * 1024
@dataclass
class UnityConnection:
"""Manages the socket connection to the Unity Editor."""
host: str = config.unity_host
port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication
use_framing: bool = False # Negotiated per-connection
def __post_init__(self):
"""Set port from discovery if not explicitly provided"""
if self.port is None:
self.port = PortDiscovery.discover_unity_port()
self._io_lock = threading.Lock()
self._conn_lock = threading.Lock()
def connect(self) -> bool:
"""Establish a connection to the Unity Editor."""
if self.sock:
return True
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
logger.info(f"Connected to Unity at {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Unity: {str(e)}")
self.sock = None
return False
with self._conn_lock:
if self.sock:
return True
try:
# Bounded connect to avoid indefinite blocking
connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0)))
self.sock = socket.create_connection((self.host, self.port), connect_timeout)
# Disable Nagle's algorithm to reduce small RPC latency
with contextlib.suppress(Exception):
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
logger.debug(f"Connected to Unity at {self.host}:{self.port}")
# Strict handshake: require FRAMING=1
try:
require_framing = getattr(config, "require_framing", True)
timeout = float(getattr(config, "handshake_timeout", 1.0))
self.sock.settimeout(timeout)
buf = bytearray()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and len(buf) < 512:
try:
chunk = self.sock.recv(256)
if not chunk:
break
buf.extend(chunk)
if b"\n" in buf:
break
except socket.timeout:
break
text = bytes(buf).decode('ascii', errors='ignore').strip()
if 'FRAMING=1' in text:
self.use_framing = True
logger.debug('Unity MCP handshake received: FRAMING=1 (strict)')
else:
if require_framing:
# Best-effort plain-text advisory for legacy peers
with contextlib.suppress(Exception):
self.sock.sendall(b'Unity MCP requires FRAMING=1\n')
raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}')
else:
self.use_framing = False
logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration')
finally:
self.sock.settimeout(config.connection_timeout)
return True
except Exception as e:
logger.error(f"Failed to connect to Unity: {str(e)}")
try:
if self.sock:
self.sock.close()
except Exception:
pass
self.sock = None
return False
def disconnect(self):
"""Close the connection to the Unity Editor."""
@ -53,10 +111,48 @@ class UnityConnection:
finally:
self.sock = None
def _read_exact(self, sock: socket.socket, count: int) -> bytes:
data = bytearray()
while len(data) < count:
chunk = sock.recv(count - len(data))
if not chunk:
raise ConnectionError("Connection closed before reading expected bytes")
data.extend(chunk)
return bytes(data)
def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes:
"""Receive a complete response from Unity, handling chunked data."""
if self.use_framing:
try:
# Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive
heartbeat_count = 0
deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0)
while True:
header = self._read_exact(sock, 8)
payload_len = struct.unpack('>Q', header)[0]
if payload_len == 0:
# Heartbeat/no-op frame: consume and continue waiting for a data frame
logger.debug("Received heartbeat frame (length=0)")
heartbeat_count += 1
if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline:
# Treat as empty successful response to match C# server behavior
logger.debug("Heartbeat threshold reached; returning empty response")
return b""
continue
if payload_len > FRAMED_MAX:
raise ValueError(f"Invalid framed length: {payload_len}")
payload = self._read_exact(sock, payload_len)
logger.debug(f"Received framed response ({len(payload)} bytes)")
return payload
except socket.timeout as e:
logger.warning("Socket timeout during framed receive")
raise TimeoutError("Timeout receiving Unity response") from e
except Exception as e:
logger.error(f"Error during framed receive: {str(e)}")
raise
chunks = []
sock.settimeout(config.connection_timeout) # Use timeout from config
# Respect the socket's currently configured timeout
try:
while True:
chunk = sock.recv(buffer_size)
@ -148,15 +244,9 @@ class UnityConnection:
for attempt in range(attempts + 1):
try:
# Ensure connected
if not self.sock:
# During retries use short connect timeout
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(1.0)
self.sock.connect((self.host, self.port))
# restore steady-state timeout for receive
self.sock.settimeout(config.connection_timeout)
logger.info(f"Connected to Unity at {self.host}:{self.port}")
# Ensure connected (handshake occurs within connect())
if not self.sock and not self.connect():
raise Exception("Could not connect to Unity")
# Build payload
if command_type == 'ping':
@ -165,18 +255,36 @@ class UnityConnection:
command = {"type": command_type, "params": params or {}}
payload = json.dumps(command, ensure_ascii=False).encode('utf-8')
# Send
self.sock.sendall(payload)
# Send/receive are serialized to protect the shared socket
with self._io_lock:
mode = 'framed' if self.use_framing else 'legacy'
with contextlib.suppress(Exception):
logger.debug(
"send %d bytes; mode=%s; head=%s",
len(payload),
mode,
(payload[:32]).decode('utf-8', 'ignore'),
)
if self.use_framing:
header = struct.pack('>Q', len(payload))
self.sock.sendall(header)
self.sock.sendall(payload)
else:
self.sock.sendall(payload)
# During retry bursts use a short receive timeout
if attempt > 0 and last_short_timeout is None:
last_short_timeout = self.sock.gettimeout()
self.sock.settimeout(1.0)
response_data = self.receive_full_response(self.sock)
# restore steady-state timeout if changed
if last_short_timeout is not None:
self.sock.settimeout(config.connection_timeout)
last_short_timeout = None
# During retry bursts use a short receive timeout and ensure restoration
restore_timeout = None
if attempt > 0 and last_short_timeout is None:
restore_timeout = self.sock.gettimeout()
self.sock.settimeout(1.0)
try:
response_data = self.receive_full_response(self.sock)
with contextlib.suppress(Exception):
logger.debug("recv %d bytes; mode=%s", len(response_data), mode)
finally:
if restore_timeout is not None:
self.sock.settimeout(restore_timeout)
last_short_timeout = None
# Parse
if command_type == 'ping':
@ -241,43 +349,26 @@ class UnityConnection:
_unity_connection = None
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection."""
"""Retrieve or establish a persistent Unity connection.
Note: Do NOT ping on every retrieval to avoid connection storms. Rely on
send_command() exceptions to detect broken sockets and reconnect there.
"""
global _unity_connection
if _unity_connection is not None:
try:
# Try to ping with a short timeout to verify connection
result = _unity_connection.send_command("ping")
# If we get here, the connection is still valid
logger.debug("Reusing existing Unity connection")
return _unity_connection
except Exception as e:
logger.warning(f"Existing connection failed: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
# Create a new connection
logger.info("Creating new Unity connection")
_unity_connection = UnityConnection()
if not _unity_connection.connect():
_unity_connection = None
raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
try:
# Verify the new connection works
_unity_connection.send_command("ping")
logger.info("Successfully established new Unity connection")
return _unity_connection
except Exception as e:
logger.error(f"Could not verify new connection: {str(e)}")
try:
_unity_connection.disconnect()
except:
pass
_unity_connection = None
raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}")
# Double-checked locking to avoid concurrent socket creation
with _connection_lock:
if _unity_connection is not None:
return _unity_connection
logger.info("Creating new Unity connection")
_unity_connection = UnityConnection()
if not _unity_connection.connect():
_unity_connection = None
raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
logger.info("Connected to Unity on startup")
return _unity_connection
# -----------------------------

View File

@ -160,6 +160,21 @@ cli = [
{ name = "typer" },
]
[[package]]
name = "mcpforunityserver"
version = "3.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27.2" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.4.1" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -370,21 +385,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
[[package]]
name = "mcpforunityserver"
version = "2.1.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27.2" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.4.1" },
]
[[package]]
name = "uvicorn"
version = "0.34.0"

View File

@ -1,6 +1,6 @@
{
"name": "com.coplaydev.unity-mcp",
"version": "3.0.2",
"version": "3.3.1",
"displayName": "MCP for Unity",
"description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
"unity": "2021.3",

View File

@ -1,51 +0,0 @@
### macOS: Claude CLI fails to start (dyld ICU library not loaded)
- Symptoms
- MCP for Unity error: “Failed to start Claude CLI. dyld: Library not loaded: /usr/local/opt/icu4c/lib/libicui18n.71.dylib …”
- Running `claude` in Terminal fails with missing `libicui18n.xx.dylib`.
- Cause
- Homebrew Node (or the `claude` binary) was linked against an ICU version thats no longer installed; dyld cant find that dylib.
- Fix options (pick one)
- Reinstall Homebrew Node (relinks to current ICU), then reinstall CLI:
```bash
brew update
brew reinstall node
npm uninstall -g @anthropic-ai/claude-code
npm install -g @anthropic-ai/claude-code
```
- Use NVM Node (avoids Homebrew ICU churn):
```bash
nvm install --lts
nvm use --lts
npm install -g @anthropic-ai/claude-code
# MCP for Unity → Claude Code → Choose Claude Location → ~/.nvm/versions/node/<ver>/bin/claude
```
- Use the native installer (puts claude in a stable path):
```bash
# macOS/Linux
curl -fsSL https://claude.ai/install.sh | bash
# MCP for Unity → Claude Code → Choose Claude Location → /opt/homebrew/bin/claude or ~/.local/bin/claude
```
- After fixing
- In MCP for Unity (Claude Code), click “Choose Claude Location” and select the working `claude` binary, then Register again.
- More details
- See: Troubleshooting MCP for Unity and Claude Code
---
### FAQ (Claude Code)
- Q: Unity cant find `claude` even though Terminal can.
- A: macOS apps launched from Finder/Hub dont inherit your shell PATH. In the MCP for Unity window, click “Choose Claude Location” and select the absolute path (e.g., `/opt/homebrew/bin/claude` or `~/.nvm/versions/node/<ver>/bin/claude`).
- Q: I installed via NVM; where is `claude`?
- A: Typically `~/.nvm/versions/node/<ver>/bin/claude`. Our UI also scans NVM versions and you can browse to it via “Choose Claude Location”.
- Q: The Register button says “Claude Not Found”.
- A: Install the CLI or set the path. Click the orange “[HELP]” link in the MCP for Unity window for stepbystep install instructions, then choose the binary location.

View File

@ -165,4 +165,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
import socket, struct, json, sys
HOST = "127.0.0.1"
PORT = 6400
try:
SIZE_MB = int(sys.argv[1])
except (IndexError, ValueError):
SIZE_MB = 5 # e.g., 5 or 10
FILL = "R"
MAX_FRAME = 64 * 1024 * 1024
def recv_exact(sock, n):
buf = bytearray(n)
view = memoryview(buf)
off = 0
while off < n:
r = sock.recv_into(view[off:])
if r == 0:
raise RuntimeError("socket closed")
off += r
return bytes(buf)
def is_valid_json(b):
try:
json.loads(b.decode("utf-8"))
return True
except Exception:
return False
def recv_legacy_json(sock, timeout=60):
sock.settimeout(timeout)
chunks = []
while True:
chunk = sock.recv(65536)
if not chunk:
data = b"".join(chunks)
if not data:
raise RuntimeError("no data, socket closed")
return data
chunks.append(chunk)
data = b"".join(chunks)
if data.strip() == b"ping":
return data
if is_valid_json(data):
return data
def main():
# Cap filler to stay within framing limit (reserve small overhead for JSON)
safe_max = max(1, MAX_FRAME - 4096)
filler_len = min(SIZE_MB * 1024 * 1024, safe_max)
body = {
"type": "read_console",
"params": {
"action": "get",
"types": ["all"],
"count": 1000,
"format": "detailed",
"includeStacktrace": True,
"filterText": FILL * filler_len
}
}
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
with socket.create_connection((HOST, PORT), timeout=5) as s:
s.settimeout(2)
# Read optional greeting
try:
greeting = s.recv(256)
except Exception:
greeting = b""
greeting_text = greeting.decode("ascii", errors="ignore").strip()
print(f"Greeting: {greeting_text or '(none)'}")
framing = "FRAMING=1" in greeting_text
print(f"Using framing? {framing}")
s.settimeout(120)
if framing:
header = struct.pack(">Q", len(body_bytes))
s.sendall(header + body_bytes)
resp_len = struct.unpack(">Q", recv_exact(s, 8))[0]
print(f"Response framed length: {resp_len}")
MAX_RESP = MAX_FRAME
if resp_len <= 0 or resp_len > MAX_RESP:
raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})")
resp = recv_exact(s, resp_len)
else:
s.sendall(body_bytes)
resp = recv_legacy_json(s)
print(f"Response bytes: {len(resp)}")
print(f"Response head: {resp[:120].decode('utf-8','ignore')}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,116 @@
import sys
import pathlib
import importlib.util
import types
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# stub mcp.server.fastmcp
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2")
manage_script_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2")
class DummyMCP:
def __init__(self): self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
return mcp.tools
def test_normalizes_lsp_and_index_ranges(monkeypatch):
tools = setup_tools()
apply = tools["apply_text_edits"]
calls = []
def fake_send(cmd, params):
calls.append(params)
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# LSP-style
edits = [{
"range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}},
"newText": "// lsp\n"
}]
apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x")
p = calls[-1]
e = p["edits"][0]
assert e["startLine"] == 11 and e["startCol"] == 3
# Index pair
calls.clear()
edits = [{"range": [0, 0], "text": "// idx\n"}]
# fake read to provide contents length
def fake_read(cmd, params):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "hello\n"}}
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read)
apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x")
# last call is apply_text_edits
def test_noop_evidence_shape(monkeypatch):
tools = setup_tools()
apply = tools["apply_text_edits"]
# Route response from Unity indicating no-op
def fake_send(cmd, params):
return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":""}], precondition_sha256="x")
assert resp["success"] is True
assert resp.get("data", {}).get("no_op") is True
def test_atomic_multi_span_and_relaxed(monkeypatch):
tools_text = setup_tools()
apply_text = tools_text["apply_text_edits"]
tools_struct = DummyMCP(); manage_script_edits.register_manage_script_edits_tools(tools_struct)
# Fake send for read and write; verify atomic applyMode and validate=relaxed passes through
sent = {}
def fake_send(cmd, params):
if params.get("action") == "read":
return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}}
sent.setdefault("calls", []).append(params)
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
edits = [
{"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"},
{"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}
]
resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"})
assert resp["success"] is True
# Last manage_script call should include options with applyMode atomic and validate relaxed
last = sent["calls"][-1]
assert last.get("options", {}).get("applyMode") == "atomic"
assert last.get("options", {}).get("validate") == "relaxed"

View File

@ -0,0 +1,84 @@
import sys
import pathlib
import importlib.util
import types
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# stub mcp.server.fastmcp
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod3")
class DummyMCP:
def __init__(self): self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn): self.tools[fn.__name__] = fn; return fn
return deco
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
return mcp.tools
def test_explicit_zero_based_normalized_warning(monkeypatch):
tools = setup_tools()
apply_edits = tools["apply_text_edits"]
def fake_send(cmd, params):
# Simulate Unity path returning minimal success
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
# Explicit fields given as 0-based (invalid); SDK should normalize and warn
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha")
assert resp["success"] is True
data = resp.get("data", {})
assert "normalizedEdits" in data
assert any(w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", []))
ne = data["normalizedEdits"][0]
assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1
def test_strict_zero_based_error(monkeypatch):
tools = setup_tools()
apply_edits = tools["apply_text_edits"]
def fake_send(cmd, params):
return {"success": True}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}]
resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True)
assert resp["success"] is False
assert resp.get("code") == "zero_based_explicit_fields"

74
tests/test_get_sha.py Normal file
View File

@ -0,0 +1,74 @@
import sys
import pathlib
import importlib.util
import types
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# stub mcp.server.fastmcp to satisfy imports without full dependency
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def _load_module(path: pathlib.Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod")
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs):
def deco(fn):
self.tools[fn.__name__] = fn
return fn
return deco
def setup_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp)
return mcp.tools
def test_get_sha_param_shape_and_routing(monkeypatch):
tools = setup_tools()
get_sha = tools["get_sha"]
captured = {}
def fake_send(cmd, params):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
resp = get_sha(None, uri="unity://path/Assets/Scripts/A.cs")
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "get_sha"
assert captured["params"]["name"] == "A"
assert captured["params"]["path"].endswith("Assets/Scripts")
assert resp["success"] is True

View File

@ -0,0 +1,170 @@
"""
Test the improved anchor matching logic.
"""
import sys
import pathlib
import importlib.util
import types
# add server src to path and load modules
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# stub mcp.server.fastmcp
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def load_module(path, name):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
manage_script_edits_module = load_module(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module")
def test_improved_anchor_matching():
"""Test that our improved anchor matching finds the right closing brace."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
// Update logic
}
}'''
import re
# Test the problematic anchor pattern
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Test our improved function
best_match = manage_script_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
assert best_match is not None, "anchor pattern not found"
match_pos = best_match.start()
line_num = test_code[:match_pos].count('\n') + 1
total_lines = test_code.count('\n') + 1
assert line_num >= total_lines - 2, f"expected match near end (>= {total_lines-2}), got line {line_num}"
def test_old_vs_new_matching():
"""Compare old vs new matching behavior."""
test_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
void Start()
{
Debug.Log("test");
}
void Update()
{
if (condition)
{
DoSomething();
}
}
void LateUpdate()
{
// More logic
}
}'''
import re
anchor_pattern = r"\s*}\s*$"
flags = re.MULTILINE
# Old behavior (first match)
old_match = re.search(anchor_pattern, test_code, flags)
old_line = test_code[:old_match.start()].count('\n') + 1 if old_match else None
# New behavior (improved matching)
new_match = manage_script_edits_module._find_best_anchor_match(
anchor_pattern, test_code, flags, prefer_last=True
)
new_line = test_code[:new_match.start()].count('\n') + 1 if new_match else None
assert old_line is not None and new_line is not None, "failed to locate anchors"
assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})"
total_lines = test_code.count('\n') + 1
assert new_line >= total_lines - 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}"
def test_apply_edits_with_improved_matching():
"""Test that _apply_edits_locally uses improved matching."""
original_code = '''using UnityEngine;
public class TestClass : MonoBehaviour
{
public string message = "Hello World";
void Start()
{
Debug.Log(message);
}
}'''
# Test anchor_insert with the problematic pattern
edits = [{
"op": "anchor_insert",
"anchor": r"\s*}\s*$", # This should now find the class end
"position": "before",
"text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n"
}]
result = manage_script_edits_module._apply_edits_locally(original_code, edits)
lines = result.split('\n')
try:
idx = next(i for i, line in enumerate(lines) if "NewMethod" in line)
except StopIteration:
assert False, "NewMethod not found in result"
total_lines = len(lines)
assert idx >= total_lines - 5, f"method inserted too early (idx={idx}, total_lines={total_lines})"
if __name__ == "__main__":
print("Testing improved anchor matching...")
print("="*60)
success1 = test_improved_anchor_matching()
print("\n" + "="*60)
print("Comparing old vs new behavior...")
success2 = test_old_vs_new_matching()
print("\n" + "="*60)
print("Testing _apply_edits_locally with improved matching...")
success3 = test_apply_edits_with_improved_matching()
print("\n" + "="*60)
if success1 and success2 and success3:
print("🎉 ALL TESTS PASSED! Improved anchor matching is working!")
else:
print("💥 Some tests failed. Need more work on anchor matching.")

View File

@ -0,0 +1,68 @@
import ast
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"Unity MCP server source not found. Tried:\n" + searched,
allow_module_level=True,
)
@pytest.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file")
def test_no_stdout_output_from_tools():
pass
def test_no_print_statements_in_codebase():
"""Ensure no stray print/sys.stdout writes remain in server source."""
offenders = []
syntax_errors = []
for py_file in SRC.rglob("*.py"):
# Skip virtual envs and third-party packages if they exist under SRC
parts = set(py_file.parts)
if ".venv" in parts or "site-packages" in parts:
continue
try:
text = py_file.read_text(encoding="utf-8", errors="strict")
except UnicodeDecodeError:
# Be tolerant of encoding edge cases in source tree without silently dropping bytes
text = py_file.read_text(encoding="utf-8", errors="replace")
try:
tree = ast.parse(text, filename=str(py_file))
except SyntaxError:
syntax_errors.append(py_file.relative_to(SRC))
continue
class StdoutVisitor(ast.NodeVisitor):
def __init__(self):
self.hit = False
def visit_Call(self, node: ast.Call):
# print(...)
if isinstance(node.func, ast.Name) and node.func.id == "print":
self.hit = True
# sys.stdout.write(...)
if isinstance(node.func, ast.Attribute) and node.func.attr == "write":
val = node.func.value
if isinstance(val, ast.Attribute) and val.attr == "stdout":
if isinstance(val.value, ast.Name) and val.value.id == "sys":
self.hit = True
self.generic_visit(node)
v = StdoutVisitor()
v.visit(tree)
if v.hit:
offenders.append(py_file.relative_to(SRC))
assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors)
assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders)

View File

@ -0,0 +1,126 @@
import sys
import types
from pathlib import Path
import pytest
# Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests)
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"Unity MCP server source not found. Tried:\n" + searched,
allow_module_level=True,
)
sys.path.insert(0, str(SRC))
# Stub mcp.server.fastmcp to satisfy imports without full package
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy: pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
# Import target module after path injection
import tools.manage_script as manage_script # type: ignore
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs): # ignore decorator kwargs like description
def _decorator(fn):
self.tools[fn.__name__] = fn
return fn
return _decorator
class DummyCtx: # FastMCP Context placeholder
pass
def _register_tools():
mcp = DummyMCP()
manage_script.register_manage_script_tools(mcp) # populates mcp.tools
return mcp.tools
def test_split_uri_unity_path(monkeypatch):
tools = _register_tools()
captured = {}
def fake_send(cmd, params): # capture params and return success
captured['cmd'] = cmd
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
fn = tools['apply_text_edits']
uri = "unity://path/Assets/Scripts/MyScript.cs"
fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None)
assert captured['cmd'] == 'manage_script'
assert captured['params']['name'] == 'MyScript'
assert captured['params']['path'] == 'Assets/Scripts'
@pytest.mark.parametrize(
"uri, expected_name, expected_path",
[
("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", "Foo Bar", "Assets/Scripts"),
("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"),
("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"),
("file:///tmp/Other.cs", "Other", "tmp"), # outside Assets → fall back to normalized dir
],
)
def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path):
tools = _register_tools()
captured = {}
def fake_send(cmd, params):
captured['cmd'] = cmd
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
fn = tools['apply_text_edits']
fn(DummyCtx(), uri=uri, edits=[], precondition_sha256=None)
assert captured['params']['name'] == expected_name
assert captured['params']['path'] == expected_path
def test_split_uri_plain_path(monkeypatch):
tools = _register_tools()
captured = {}
def fake_send(cmd, params):
captured['params'] = params
return {"success": True, "message": "ok"}
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
fn = tools['apply_text_edits']
fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None)
assert captured['params']['name'] == 'Thing'
assert captured['params']['path'] == 'Assets/Scripts'

View File

@ -0,0 +1,81 @@
import pytest
import sys
from pathlib import Path
import pytest
import types
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"Unity MCP server source not found. Tried:\n" + searched,
allow_module_level=True,
)
sys.path.insert(0, str(SRC))
from tools.resource_tools import register_resource_tools # type: ignore
class DummyMCP:
def __init__(self):
self._tools = {}
def tool(self, *args, **kwargs): # accept kwargs like description
def deco(fn):
self._tools[fn.__name__] = fn
return fn
return deco
@pytest.fixture()
def resource_tools():
mcp = DummyMCP()
register_resource_tools(mcp)
return mcp._tools
def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, monkeypatch):
# Create fake project structure
proj = tmp_path
assets = proj / "Assets" / "Scripts"
assets.mkdir(parents=True)
(assets / "A.cs").write_text("// a", encoding="utf-8")
(assets / "B.txt").write_text("b", encoding="utf-8")
outside = tmp_path / "Outside.cs"
outside.write_text("// outside", encoding="utf-8")
# Symlink attempting to escape
sneaky_link = assets / "link_out"
try:
sneaky_link.symlink_to(outside)
except Exception:
# Some platforms may not allow symlinks in tests; ignore
pass
list_resources = resource_tools["list_resources"]
# Only .cs under Assets should be listed
import asyncio
resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="Assets", limit=50, project_root=str(proj))
)
assert resp["success"] is True
uris = resp["data"]["uris"]
assert any(u.endswith("Assets/Scripts/A.cs") for u in uris)
assert not any(u.endswith("B.txt") for u in uris)
assert not any(u.endswith("Outside.cs") for u in uris)
def test_resource_list_rejects_outside_paths(resource_tools, tmp_path):
proj = tmp_path
# under points outside Assets
list_resources = resource_tools["list_resources"]
import asyncio
resp = asyncio.get_event_loop().run_until_complete(
list_resources(ctx=None, pattern="*.cs", under="..", limit=10, project_root=str(proj))
)
assert resp["success"] is False
assert "Assets" in resp.get("error", "") or "under project root" in resp.get("error", "")

View File

@ -0,0 +1,36 @@
import pytest
@pytest.mark.xfail(strict=False, reason="pending: create new script, validate, apply edits, build and compile scene")
def test_script_edit_happy_path():
pass
@pytest.mark.xfail(strict=False, reason="pending: multiple micro-edits debounce to single compilation")
def test_micro_edits_debounce():
pass
@pytest.mark.xfail(strict=False, reason="pending: line ending variations handled correctly")
def test_line_endings_and_columns():
pass
@pytest.mark.xfail(strict=False, reason="pending: regex_replace no-op with allow_noop honored")
def test_regex_replace_noop_allowed():
pass
@pytest.mark.xfail(strict=False, reason="pending: large edit size boundaries and overflow protection")
def test_large_edit_size_and_overflow():
pass
@pytest.mark.xfail(strict=False, reason="pending: symlink and junction protections on edits")
def test_symlink_and_junction_protection():
pass
@pytest.mark.xfail(strict=False, reason="pending: atomic write guarantees")
def test_atomic_write_guarantees():
pass

159
tests/test_script_tools.py Normal file
View File

@ -0,0 +1,159 @@
import sys
import pathlib
import importlib.util
import types
import pytest
import asyncio
# add server src to path and load modules without triggering package imports
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
sys.path.insert(0, str(SRC))
# stub mcp.server.fastmcp to satisfy imports without full dependency
mcp_pkg = types.ModuleType("mcp")
server_pkg = types.ModuleType("mcp.server")
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
class _Dummy:
pass
fastmcp_pkg.FastMCP = _Dummy
fastmcp_pkg.Context = _Dummy
server_pkg.fastmcp = fastmcp_pkg
mcp_pkg.server = server_pkg
sys.modules.setdefault("mcp", mcp_pkg)
sys.modules.setdefault("mcp.server", server_pkg)
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
def load_module(path, name):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
manage_script_module = load_module(SRC / "tools" / "manage_script.py", "manage_script_module")
manage_asset_module = load_module(SRC / "tools" / "manage_asset.py", "manage_asset_module")
class DummyMCP:
def __init__(self):
self.tools = {}
def tool(self, *args, **kwargs): # accept decorator kwargs like description
def decorator(func):
self.tools[func.__name__] = func
return func
return decorator
def setup_manage_script():
mcp = DummyMCP()
manage_script_module.register_manage_script_tools(mcp)
return mcp.tools
def setup_manage_asset():
mcp = DummyMCP()
manage_asset_module.register_manage_asset_tools(mcp)
return mcp.tools
def test_apply_text_edits_long_file(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send)
edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"}
resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit])
assert captured["cmd"] == "manage_script"
assert captured["params"]["action"] == "apply_text_edits"
assert captured["params"]["edits"][0]["startLine"] == 1005
assert resp["success"] is True
def test_sequential_edits_use_precondition(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
calls = []
def fake_send(cmd, params):
calls.append(params)
return {"success": True, "sha256": f"hash{len(calls)}"}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send)
edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"}
resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1])
edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"}
resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"])
assert calls[1]["precondition_sha256"] == resp1["sha256"]
assert resp2["sha256"] == "hash2"
def test_apply_text_edits_forwards_options(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send)
opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"}
apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":"x"}], options=opts)
assert captured["params"].get("options") == opts
def test_apply_text_edits_defaults_atomic_for_multi_span(monkeypatch):
tools = setup_manage_script()
apply_edits = tools["apply_text_edits"]
captured = {}
def fake_send(cmd, params):
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send)
edits = [
{"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"},
{"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"},
]
apply_edits(None, "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x")
opts = captured["params"].get("options", {})
assert opts.get("applyMode") == "atomic"
def test_manage_asset_prefab_modify_request(monkeypatch):
tools = setup_manage_asset()
manage_asset = tools["manage_asset"]
captured = {}
async def fake_async(cmd, params, loop=None):
captured["cmd"] = cmd
captured["params"] = params
return {"success": True}
monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async)
monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object())
async def run():
resp = await manage_asset(
None,
action="modify",
path="Assets/Prefabs/Player.prefab",
properties={"hp": 100},
)
assert captured["cmd"] == "manage_asset"
assert captured["params"]["action"] == "modify"
assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab"
assert captured["params"]["properties"] == {"hp": 100}
assert resp["success"] is True
asyncio.run(run())

View File

@ -0,0 +1,218 @@
import sys
import json
import struct
import socket
import threading
import time
import select
from pathlib import Path
import pytest
# locate server src dynamically to avoid hardcoded layout assumptions
ROOT = Path(__file__).resolve().parents[1]
candidates = [
ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src",
ROOT / "UnityMcpServer~" / "src",
]
SRC = next((p for p in candidates if p.exists()), None)
if SRC is None:
searched = "\n".join(str(p) for p in candidates)
pytest.skip(
"Unity MCP server source not found. Tried:\n" + searched,
allow_module_level=True,
)
sys.path.insert(0, str(SRC))
from unity_connection import UnityConnection
def start_dummy_server(greeting: bytes, respond_ping: bool = False):
"""Start a minimal TCP server for handshake tests."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
conn.settimeout(1.0)
if greeting:
conn.sendall(greeting)
if respond_ping:
try:
# Read exactly n bytes helper
def _read_exact(n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
break
buf += chunk
return buf
header = _read_exact(8)
if len(header) == 8:
length = struct.unpack(">Q", header)[0]
payload = _read_exact(length)
if payload == b'{"type":"ping"}':
resp = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(resp)) + resp)
except Exception:
pass
time.sleep(0.1)
try:
conn.close()
except Exception:
pass
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def start_handshake_enforcing_server():
"""Server that drops connection if client sends data before handshake."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
# If client sends any data before greeting, disconnect (poll briefly)
try:
conn.setblocking(False)
deadline = time.time() + 0.15 # short, reduces race with legitimate clients
while time.time() < deadline:
r, _, _ = select.select([conn], [], [], 0.01)
if r:
try:
peek = conn.recv(1, socket.MSG_PEEK)
except BlockingIOError:
peek = b""
except Exception:
peek = b"\x00"
if peek:
conn.close()
sock.close()
return
# No pre-handshake data observed; send greeting
conn.setblocking(True)
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.1)
finally:
try:
conn.close()
finally:
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
return port
def test_handshake_requires_framing():
port = start_dummy_server(b"MCP/0.1\n")
conn = UnityConnection(host="127.0.0.1", port=port)
assert conn.connect() is False
assert conn.sock is None
def test_small_frame_ping_pong():
port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True)
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
assert conn.use_framing is True
payload = b'{"type":"ping"}'
conn.sock.sendall(struct.pack(">Q", len(payload)) + payload)
resp = conn.receive_full_response(conn.sock)
assert json.loads(resp.decode("utf-8"))["type"] == "pong"
finally:
conn.disconnect()
def test_unframed_data_disconnect():
port = start_handshake_enforcing_server()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", port))
sock.settimeout(1.0)
sock.sendall(b"BAD")
time.sleep(0.4)
try:
data = sock.recv(1024)
assert data == b""
except (ConnectionResetError, ConnectionAbortedError):
# Some platforms raise instead of returning empty bytes when the
# server closes the connection after detecting pre-handshake data.
pass
finally:
sock.close()
def test_zero_length_payload_heartbeat():
# Server that sends handshake and a zero-length heartbeat frame followed by a pong payload
import socket, struct, threading, time
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sock.listen(1)
port = sock.getsockname()[1]
ready = threading.Event()
def _run():
ready.set()
conn, _ = sock.accept()
try:
conn.sendall(b"MCP/0.1 FRAMING=1\n")
time.sleep(0.02)
# Heartbeat frame (length=0)
conn.sendall(struct.pack(">Q", 0))
time.sleep(0.02)
# Real payload frame
payload = b'{"type":"pong"}'
conn.sendall(struct.pack(">Q", len(payload)) + payload)
time.sleep(0.02)
finally:
try: conn.close()
except Exception: pass
sock.close()
threading.Thread(target=_run, daemon=True).start()
ready.wait()
conn = UnityConnection(host="127.0.0.1", port=port)
try:
assert conn.connect() is True
# Receive should skip heartbeat and return the pong payload (or empty if only heartbeats seen)
resp = conn.receive_full_response(conn.sock)
assert resp in (b'{"type":"pong"}', b"")
finally:
conn.disconnect()
@pytest.mark.skip(reason="TODO: oversized payload should disconnect")
def test_oversized_payload_rejected():
pass
@pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect")
def test_partial_frame_timeout():
pass
@pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations")
def test_parallel_invocations_no_interleaving():
pass
@pytest.mark.skip(reason="TODO: reconnection after drop mid-command")
def test_reconnect_mid_command():
pass