Merge commit '3e83f993bfe632034bf7302d4319e3cd16353eb8' into feat/telemetry
* commit '3e83f993bfe632034bf7302d4319e3cd16353eb8': Improved ci prompt testing suite (#270) chore: bump version to 3.3.2 Fix: Unity Editor reload crash + debug-noise reduction (#266) Revise README for improved clarity and organization docs: install uv via official installer (curl/winget) Update README.md docs: fix Windows uv path to use WinGet shim, keep macOS AppSupport symlink path docs: update README.md with improved installation paths, documentation, and logo fix: Update README installation paths to match ServerInstaller.csmain
commit
81dcd69722
|
|
@ -1,45 +0,0 @@
|
|||
# Unity NL Editing Suite — Natural Mode
|
||||
|
||||
You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate end‑to‑end **natural‑language 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 3–6 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 end‑of‑class 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/no‑op check (re‑apply an edit and confirm nothing breaks).
|
||||
4) **Validate your edits.** Re‑read the modified regions and verify the changes exist, compile‑risk 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 sub‑test 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 follow‑ups you would try.
|
||||
6) **Be gentle and reversible.** Prefer targeted, minimal edits; avoid wide refactors or non‑deterministic changes.
|
||||
|
||||
## Assumptions & Hints (non‑prescriptive)
|
||||
- A Unity‑oriented MCP server is expected to be connected. If a server‑provided **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 isn’t 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 sub‑test; 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) → Re‑read; on `{status:"stale_file"}` retry once after re‑read.
|
||||
- Keep evidence to ±20–40 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 well‑scoped.
|
||||
- Don’t leak secrets or environment details beyond what’s needed in the reports.
|
||||
- Work without user interaction; do not prompt for approval mid‑flow.
|
||||
|
||||
> If capabilities discovery fails, still produce the two reports that clearly explain why you could not proceed and what evidence you gathered.
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
# 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 OS‑level helper (fast), not a full‑file 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 workspace‑relative:
|
||||
- `TestProjects/UnityMCPTests/Assets/...`
|
||||
|
||||
CI provides:
|
||||
- `$JUNIT_OUT=reports/junit-nl-suite.xml` (pre‑created; 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` (non‑overlapping ranges)
|
||||
- Multi‑span batches are computed from the same fresh read and sent atomically by default.
|
||||
- Prefer `options.applyMode:"atomic"` when passing options for multiple spans; for single‑span, 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): full‑file delimiter balance checks.
|
||||
- `relaxed`: scoped checks for interior, non‑structural text edits; do not use for header/signature/brace‑touching 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` + workspace‑relative `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 (NL‑3, T‑D)
|
||||
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` (op‑specific), 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 non‑overlapping range.
|
||||
|
||||
# Insert after GetCurrentTarget (T‑A/T‑E)
|
||||
- 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 (T‑A/T‑E)
|
||||
- Prefer structured delete:
|
||||
- Use `script_apply_edits` with `{ "op":"delete_method", "className":"LongUnityScriptClaudeTest", "methodName":"PrintSeries" }` (or `__TempHelper` for T‑A).
|
||||
- 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 whole‑file regex deletes.
|
||||
|
||||
# T‑B (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.
|
||||
> Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely.
|
||||
|
||||
Span formats for `apply_text_edits`:
|
||||
- Prefer LSP ranges (0‑based): `{ "range": { "start": {"line": L, "character": C}, "end": {…} }, "newText": "…" }`
|
||||
- Explicit fields are 1‑based: `{ "startLine": L1, "startCol": C1, "endLine": L2, "endCol": C2, "newText": "…" }`
|
||||
- SDK preflights overlap after normalization; overlapping non‑zero spans → `{status:"overlap"}` with conflicts and no file mutation.
|
||||
- Optional debug: pass `strict:true` to reject explicit 0‑based fields (else they are normalized and a warning is emitted).
|
||||
- Apply mode guidance: router defaults to atomic for multi‑span; 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 (±20–40 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 UTF‑8
|
||||
- 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, here‑docs, 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 NL‑0 (do not repeat it for later tests).
|
||||
|
||||
|
||||
### Fast Restore Strategy (OS‑level)
|
||||
|
||||
- Snapshot once at NL‑0, 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 full‑file 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 non‑JSON/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)
|
||||
|
||||
- NL‑0. Sanity reads — Tail ~120; ±40 around `Update()`. Then snapshot via helper.
|
||||
- NL‑1. Replace/insert/delete — `HasTarget → return currentTarget != null;`; insert `PrintSeries()` after `GetCurrentTarget` logging "1,2,3"; verify; delete `PrintSeries()`; restore.
|
||||
- NL‑2. Anchor comment — Insert `// Build marker OK` above `public void Update(...)`; restore.
|
||||
- NL‑3. End‑of‑class — Insert `// Tail test A/B/C` (3 lines) before final brace; restore.
|
||||
- NL‑4. Compile trigger — Record INFO only.
|
||||
|
||||
### T‑A. Anchor insert (text path) — Insert helper after `GetCurrentTarget`; verify; delete via `regex_replace`; restore.
|
||||
### T‑B. Replace body — Single `replace_range` inside `HasTarget`; restore.
|
||||
- Options: pass {"validate":"relaxed"} for interior one-line edits.
|
||||
### T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore.
|
||||
- Options: pass {"validate":"relaxed"} for interior one-line edits.
|
||||
### T‑D. End‑of‑class (anchor) — Insert helper before final brace; remove; restore.
|
||||
### T‑E. Lifecycle — Insert → update → delete via regex; restore.
|
||||
### T‑F. 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 **end‑of‑class insertion**: find the **index of the final `}`** for the class; create a zero‑width range `[idx, idx)` and set `replacement` to the 3‑line comment block.
|
||||
- Send all three ranges in **one call**, sorted **descending by start index** to avoid offset drift.
|
||||
- Expect all‑or‑nothing semantics; on `{status:"overlap"}` or `{status:"bad_request"}`, write the testcase fragment with `<failure>…</failure>`, **restore**, and continue.
|
||||
- Options: pass {"applyMode":"atomic"} to enforce all‑or‑nothing.
|
||||
- T‑G. 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 (0‑based) or explicit 1‑based 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.
|
||||
|
||||
### Per‑test error handling and recovery
|
||||
- For each test (NL‑0..T‑J), 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 NL‑4 completes, proceed directly to T‑A 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 non‑fatal; record and continue.
|
||||
- End each testcase `<system-out>` with `VERDICT: PASS` or `VERDICT: FAIL`.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Unity NL/T Editing Suite — Additive Test Design
|
||||
# Unity NL 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.
|
||||
|
||||
|
|
@ -10,10 +10,28 @@ AllowedTools: Write,mcp__unity__manage_editor,mcp__unity__list_resources,mcp__un
|
|||
## 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**.
|
||||
2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other.
|
||||
3) Validate each edit with `mcp__unity__validate_script(level:"standard")`.
|
||||
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
|
||||
|
||||
**CRITICAL XML FORMAT REQUIREMENTS:**
|
||||
- Each file must contain EXACTLY one `<testcase>` root element
|
||||
- NO prologue, epilogue, code fences, or extra characters
|
||||
- NO markdown formatting or explanations outside the XML
|
||||
- Use this exact format:
|
||||
|
||||
```xml
|
||||
<testcase name="NL-0 — Baseline State Capture" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
(evidence of what was accomplished)
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
- If test fails, include: `<failure message="reason"/>`
|
||||
- TESTID must be one of: NL-0, NL-1, NL-2, NL-3, NL-4
|
||||
5) **NO RESTORATION** - tests build additively on previous state.
|
||||
6) **STRICT FRAGMENT EMISSION** - After each test, immediately emit a clean XML file under `reports/<TESTID>_results.xml` with exactly one `<testcase>` whose `name` begins with the exact test id. No prologue/epilogue or fences. If the test fails, include a `<failure message="..."/>` and still emit.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -29,10 +47,26 @@ CI provides:
|
|||
|
||||
---
|
||||
|
||||
## Transcript Minimization Rules
|
||||
- Do not restate tool JSON; summarize in ≤ 2 short lines.
|
||||
- Never paste full file contents. For matches, include only the matched line and ±1 line.
|
||||
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
|
||||
- Per‑test `system-out` ≤ 400 chars: brief status only (no SHA).
|
||||
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
|
||||
- Avoid quoting multi‑line diffs; reference markers instead.
|
||||
— Console scans: perform two reads — last 10 `log/info` lines and up to 3 `error` entries (use `include_stacktrace:false`); include ≤ 3 lines total in the fragment; if no errors, state "no errors".
|
||||
|
||||
---
|
||||
|
||||
## Tool Mapping
|
||||
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
|
||||
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
|
||||
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
|
||||
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges)
|
||||
STRICT OP GUARDRAILS
|
||||
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
|
||||
|
||||
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__unity__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
|
||||
|
|
@ -49,7 +83,7 @@ CI provides:
|
|||
5. **Composability**: Tests demonstrate how operations work together in real workflows
|
||||
|
||||
**State Tracking:**
|
||||
- Track file SHA after each test to ensure operations succeeded
|
||||
- Track file SHA after each test (`mcp__unity__get_sha`) for potential preconditions in later passes. Do not include SHA values in report fragments.
|
||||
- Use content signatures (method names, comment markers) to verify expected state
|
||||
- Validate structural integrity after each major change
|
||||
|
||||
|
|
@ -85,7 +119,8 @@ CI provides:
|
|||
### 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)
|
||||
- Match the final class-closing brace by scanning from EOF (e.g., last `^\s*}\s*$`)
|
||||
or compute via `find_in_file` + ranges; insert immediately before it.
|
||||
- Insert three comment lines before final class brace:
|
||||
```
|
||||
// Tail test A
|
||||
|
|
@ -97,95 +132,11 @@ CI provides:
|
|||
### NL-4. Console State Verification (No State Change)
|
||||
**Goal**: Verify Unity console integration without file modification
|
||||
**Actions**:
|
||||
- Read Unity console messages (INFO level)
|
||||
- Read last 10 Unity console lines (log/info)
|
||||
- Perform a targeted scan for errors/exceptions (type: errors), up to 3 entries
|
||||
- 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
|
||||
|
||||
---
|
||||
- **IMMEDIATELY** write clean XML fragment to `reports/NL-4_results.xml` (no extra text). The `<testcase name>` must start with `NL-4`. Include at most 3 lines total across both reads, or simply state "no errors; console OK" (≤ 400 chars).
|
||||
|
||||
## Dynamic Targeting Examples
|
||||
|
||||
|
|
@ -219,7 +170,8 @@ find_in_file(pattern: "public bool HasTarget\\(\\)")
|
|||
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
|
||||
4. Emit a per‑test fragment to `reports/<TESTID>_results.xml` immediately. If the test failed, still write a single `<testcase>` with a `<failure message="..."/>` and evidence in `system-out`.
|
||||
5. Log cumulative changes in test evidence (keep concise per Transcript Minimization Rules; never paste raw tool JSON)
|
||||
|
||||
**Error Recovery:**
|
||||
- If test fails, log current state but continue (don't restore)
|
||||
|
|
@ -238,3 +190,11 @@ find_in_file(pattern: "public bool HasTarget\\(\\)")
|
|||
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.
|
||||
|
||||
---
|
||||
|
||||
BAN ON EXTRA TOOLS AND DIRS
|
||||
- Do not use any tools outside `AllowedTools`. Do not create directories; assume `reports/` exists.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Unity 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 T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state.
|
||||
3) Validate each edit with `mcp__unity__validate_script(level:"standard")`.
|
||||
4) **Report**: write one `<testcase>` XML fragment per test to `reports/<TESTID>_results.xml`. Do **not** read or edit `$JUNIT_OUT`.
|
||||
|
||||
**CRITICAL XML FORMAT REQUIREMENTS:**
|
||||
- Each file must contain EXACTLY one `<testcase>` root element
|
||||
- NO prologue, epilogue, code fences, or extra characters
|
||||
- NO markdown formatting or explanations outside the XML
|
||||
- Use this exact format:
|
||||
|
||||
```xml
|
||||
<testcase name="T-D — End-of-Class Helper" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
(evidence of what was accomplished)
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
- If test fails, include: `<failure message="reason"/>`
|
||||
- TESTID must be one of: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J
|
||||
5) **NO RESTORATION** - tests build additively on previous state.
|
||||
6) **STRICT FRAGMENT EMISSION** - After each test, immediately emit a clean XML file under `reports/<TESTID>_results.xml` with exactly one `<testcase>` whose `name` begins with the exact test id. No prologue/epilogue or fences. If the test fails, include a `<failure message="..."/>` and still emit.
|
||||
|
||||
---
|
||||
|
||||
## 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` (pre‑created; leave alone)
|
||||
- `$MD_OUT=reports/junit-nl-suite.md` (synthesized from JUnit)
|
||||
|
||||
---
|
||||
|
||||
## Transcript Minimization Rules
|
||||
- Do not restate tool JSON; summarize in ≤ 2 short lines.
|
||||
- Never paste full file contents. For matches, include only the matched line and ±1 line.
|
||||
- Prefer `mcp__unity__find_in_file` for targeting; avoid `mcp__unity__read_resource` unless strictly necessary. If needed, limit to `head_bytes ≤ 256` or `tail_lines ≤ 10`.
|
||||
- Per‑test `system-out` ≤ 400 chars: brief status only (no SHA).
|
||||
- Console evidence: fetch the last 10 lines with `include_stacktrace:false` and include ≤ 3 lines in the fragment.
|
||||
- Avoid quoting multi‑line diffs; reference markers instead.
|
||||
— Console scans: perform two reads — last 10 `log/info` lines and up to 3 `error` entries (use `include_stacktrace:false`); include ≤ 3 lines total in the fragment; if no errors, state "no errors".
|
||||
— Final check is folded into T‑J: perform an errors‑only scan (with `include_stacktrace:false`) and include a single "no errors" line or up to 3 error lines within the T‑J fragment.
|
||||
|
||||
---
|
||||
|
||||
## Tool Mapping
|
||||
- **Anchors/regex/structured**: `mcp__unity__script_apply_edits`
|
||||
- Allowed ops: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`
|
||||
- For `anchor_insert`, always set `"position": "before"` or `"after"`.
|
||||
- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges)
|
||||
STRICT OP GUARDRAILS
|
||||
- Do not use `anchor_replace`. Structured edits must be one of: `anchor_insert`, `replace_method`, `insert_method`, `delete_method`, `regex_replace`.
|
||||
- For multi‑spot textual tweaks in one operation, compute non‑overlapping ranges with `mcp__unity__find_in_file` and use `mcp__unity__apply_text_edits`.
|
||||
|
||||
- **Hash-only**: `mcp__unity__get_sha` — returns `{sha256,lengthBytes,lastModifiedUtc}` without file body
|
||||
- **Validation**: `mcp__unity__validate_script(level:"standard")`
|
||||
- **Dynamic targeting**: Use `mcp__unity__find_in_file` to locate current positions of methods/markers
|
||||
|
||||
---
|
||||
|
||||
## 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 (`mcp__unity__get_sha`) and use it as a precondition
|
||||
for `apply_text_edits` in T‑F/T‑G/T‑I to exercise `stale_file` semantics. Do not include SHA values in report fragments.
|
||||
- Use content signatures (method names, comment markers) to verify expected state
|
||||
- Validate structural integrity after each major change
|
||||
|
||||
---
|
||||
|
||||
### 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)
|
||||
|
||||
### Late-Test Editing Rule
|
||||
- When modifying a method body, use `mcp__unity__script_apply_edits`. If the method is expression-bodied (`=>`), convert it to a block or replace the whole method definition. After the edit, run `mcp__unity__validate_script` and rollback on error. Use `//` comments in inserted code.
|
||||
|
||||
### T-B. Method Body Interior Edit (Additive State D)
|
||||
**Goal**: Edit method interior without affecting structure, on modified file
|
||||
**Actions**:
|
||||
- Use `find_in_file` to locate current `HasTarget()` method (modified in NL-1)
|
||||
- Edit method body interior: change return statement to `return true; /* test modification */`
|
||||
- Validate with `mcp__unity__validate_script(level:"standard")` for consistency
|
||||
- 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 */ }`
|
||||
- Validate with `mcp__unity__validate_script(level:"standard")`
|
||||
- **IMMEDIATELY** write clean XML fragment to `reports/T-D_results.xml` (no extra text). The `<testcase name>` must start with `T-D`. Include brief evidence in `system-out`.
|
||||
- **Expected final state**: State E + TestHelper() method before class end
|
||||
|
||||
### T-E. Method Evolution Lifecycle (Additive State G)
|
||||
**Goal**: Insert → modify → finalize a field + companion method
|
||||
**Actions**:
|
||||
- Insert field: `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
|
||||
- After applying the atomic edits, run `validate_script(level:"standard")` and emit a clean fragment to `reports/T-F_results.xml` with a short summary.
|
||||
|
||||
### 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)
|
||||
- Emit `reports/T-G_results.xml` showing evidence of stale SHA handling.
|
||||
|
||||
### 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)
|
||||
- Emit `reports/T-H_results.xml` confirming validation OK.
|
||||
|
||||
### 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)
|
||||
- Emit `reports/T-I_results.xml` capturing error evidence; file must contain one `<testcase>`.
|
||||
|
||||
### T-J. Idempotency on Modified File (Additive State I)
|
||||
**Goal**: Verify operations behave predictably when repeated
|
||||
**Actions**:
|
||||
- **Insert (structured)**: `mcp__unity__script_apply_edits` with:
|
||||
`{"op":"anchor_insert","anchor":"// Tail test C","position":"after","text":"\n // idempotency test marker"}`
|
||||
- **Insert again** (same op) → expect `no_op: true`.
|
||||
- **Remove (structured)**: `{"op":"regex_replace","pattern":"(?m)^\\s*// idempotency test marker\\r?\\n?","text":""}`
|
||||
- **Remove again** (same `regex_replace`) → expect `no_op: true`.
|
||||
- `mcp__unity__validate_script(level:"standard")`
|
||||
- Perform a final console scan for errors/exceptions (errors only, up to 3); include "no errors" if none
|
||||
- **IMMEDIATELY** write clean XML fragment to `reports/T-J_results.xml` with evidence of both `no_op: true` outcomes and the console result. The `<testcase name>` must start with `T-J`.
|
||||
- **Expected final state**: State H + verified idempotent behavior
|
||||
|
||||
---
|
||||
|
||||
## 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. Emit a per‑test fragment to `reports/<TESTID>_results.xml` immediately. If the test failed, still write a single `<testcase>` with a `<failure message="..."/>` and evidence in `system-out`.
|
||||
5. Log cumulative changes in test evidence (keep concise per Transcript Minimization Rules; never paste raw tool JSON)
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
BAN ON EXTRA TOOLS AND DIRS
|
||||
- Do not use any tools outside `AllowedTools`. Do not create directories; assume `reports/` exists.
|
||||
|
||||
---
|
||||
|
||||
## XML Fragment Templates (T-F .. T-J)
|
||||
|
||||
Use these skeletons verbatim as a starting point. Replace the bracketed placeholders with your evidence. Ensure each file contains exactly one `<testcase>` element and that the `name` begins with the exact test id.
|
||||
|
||||
```xml
|
||||
<testcase name="T-F — Atomic Multi-Edit" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
Applied 3 non-overlapping edits in one atomic call:
|
||||
- HasTarget(): added "// validated access"
|
||||
- ApplyBlend(): added "// safe animation"
|
||||
- End-of-class: added "// end of test modifications"
|
||||
validate_script: OK
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
```xml
|
||||
<testcase name="T-G — Path Normalization Test" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
Edit via unity://path/... succeeded.
|
||||
Same edit via Assets/... returned stale_file, retried with updated hash: OK.
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
```xml
|
||||
<testcase name="T-H — Validation on Modified File" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
validate_script(level:"standard"): OK on the modified file.
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
```xml
|
||||
<testcase name="T-I — Failure Surface Testing" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
Overlapping edit: failed cleanly (error captured).
|
||||
Stale hash edit: failed cleanly (error captured).
|
||||
File unchanged.
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
```
|
||||
|
||||
```xml
|
||||
<testcase name="T-J — Idempotency on Modified File" classname="UnityMCP.NL-T">
|
||||
<system-out><![CDATA[
|
||||
Insert marker after "// Tail test C": OK.
|
||||
Insert same marker again: no_op: true.
|
||||
regex_remove marker: OK.
|
||||
regex_remove again: no_op: true.
|
||||
validate_script: OK.
|
||||
]]></system-out>
|
||||
</testcase>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__unity",
|
||||
"Edit(reports/**)",
|
||||
"MultiEdit(reports/**)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"NotebookEdit",
|
||||
"NotebookRead"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
name: Claude NL/T Full Suite (Unity live)
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
on: [workflow_dispatch]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -12,13 +11,10 @@ concurrency:
|
|||
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:
|
||||
|
|
@ -38,7 +34,7 @@ jobs:
|
|||
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
|
||||
if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; }; then
|
||||
echo "unity_ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "unity_ok=false" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -71,27 +67,119 @@ jobs:
|
|||
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
|
||||
# --- Licensing: allow both ULF and EBL when available ---
|
||||
- name: Decide license sources
|
||||
id: lic
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
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
|
||||
set -eu
|
||||
use_ulf=false; use_ebl=false
|
||||
[[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true
|
||||
[[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true
|
||||
echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT"
|
||||
echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT"
|
||||
echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stage Unity .ulf license (from secret)
|
||||
if: steps.lic.outputs.use_ulf == 'true'
|
||||
id: ulf
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity"
|
||||
f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf"
|
||||
if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then
|
||||
printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f"
|
||||
else
|
||||
printf "%s" "$UNITY_LICENSE" > "$f"
|
||||
fi
|
||||
chmod 600 "$f" || true
|
||||
# If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it:
|
||||
if head -c 100 "$f" | grep -qi '<\?xml'; then
|
||||
mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses"
|
||||
mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml"
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
elif grep -qi '<Signature>' "$f"; then
|
||||
# provide it in the standard local-share path too
|
||||
cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf"
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# --- Activate via EBL inside the same Unity image (writes host-side entitlement) ---
|
||||
- name: Activate Unity (EBL via container - host-mount)
|
||||
if: steps.lic.outputs.use_ebl == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
# host dirs to receive the full Unity config and local-share
|
||||
mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local"
|
||||
|
||||
# Try Pro first if serial is present, otherwise named-user EBL.
|
||||
docker run --rm --network host \
|
||||
-e HOME=/root \
|
||||
-e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
|
||||
"$UNITY_IMAGE" bash -lc '
|
||||
set -euxo pipefail
|
||||
if [[ -n "${UNITY_SERIAL:-}" ]]; then
|
||||
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true
|
||||
else
|
||||
/opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true
|
||||
fi
|
||||
ls -la /root/.config/unity3d/Unity/licenses || true
|
||||
'
|
||||
|
||||
# Verify entitlement written to host mount; allow ULF-only runs to proceed
|
||||
if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then
|
||||
if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then
|
||||
echo "EBL entitlement not found; proceeding with ULF-only (ok=true)."
|
||||
else
|
||||
echo "No entitlement produced and no valid ULF; cannot continue." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step
|
||||
|
||||
# ---------- Warm up project (import Library once) ----------
|
||||
- name: Warm up project (import Library once)
|
||||
if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
ULF_OK: ${{ steps.ulf.outputs.ok }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
manual_args=()
|
||||
if [[ "${ULF_OK:-false}" == "true" ]]; then
|
||||
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
|
||||
fi
|
||||
docker run --rm --network host \
|
||||
-e HOME=/root \
|
||||
-v "${{ github.workspace }}:/workspace" -w /workspace \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \
|
||||
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-projectPath /workspace/TestProjects/UnityMCPTests \
|
||||
"${manual_args[@]}" \
|
||||
-quit
|
||||
|
||||
# ---------- Clean old MCP status ----------
|
||||
- name: Clean old MCP status
|
||||
|
|
@ -102,80 +190,90 @@ jobs:
|
|||
|
||||
# ---------- Start headless Unity (persistent bridge) ----------
|
||||
- name: Start Unity (persistent bridge)
|
||||
if: steps.detect.outputs.unity_ok == 'true'
|
||||
if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
UNITY_IMAGE: ${{ env.UNITY_IMAGE }}
|
||||
ULF_OK: ${{ steps.ulf.outputs.ok }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then
|
||||
echo "Unity project not found; failing fast."
|
||||
exit 1
|
||||
set -euxo pipefail
|
||||
manual_args=()
|
||||
if [[ "${ULF_OK:-false}" == "true" ]]; then
|
||||
manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf")
|
||||
fi
|
||||
mkdir -p "$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")
|
||||
|
||||
mkdir -p "$RUNNER_TEMP/unity-status"
|
||||
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_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 - \
|
||||
-v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \
|
||||
-v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \
|
||||
-v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \
|
||||
"$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \
|
||||
-stackTraceLogType Full \
|
||||
-projectPath /workspace/TestProjects/UnityMCPTests \
|
||||
"${MANUAL_ARG[@]}" \
|
||||
"${EBL_ARGS[@]}" \
|
||||
"${manual_args[@]}" \
|
||||
-executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect
|
||||
|
||||
# ---------- Wait for Unity bridge ----------
|
||||
- name: Wait for Unity bridge (robust)
|
||||
if: steps.detect.outputs.unity_ok == 'true'
|
||||
shell: bash
|
||||
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
|
||||
}
|
||||
deadline=$((SECONDS+900)) # 15 min max
|
||||
fatal_after=$((SECONDS+120)) # give licensing 2 min to settle
|
||||
|
||||
# Fail fast only if container actually died
|
||||
st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)"
|
||||
case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac
|
||||
|
||||
# Patterns
|
||||
ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)'
|
||||
# Only truly fatal signals; allow transient "Licensing::..." chatter
|
||||
license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)'
|
||||
|
||||
while [ $SECONDS -lt $deadline ]; do
|
||||
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
|
||||
logs="$(docker logs unity-mcp 2>&1 || true)"
|
||||
|
||||
# 1) Primary: status JSON exposes TCP port
|
||||
port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)"
|
||||
if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then
|
||||
echo "Bridge ready on port $port"
|
||||
exit 0
|
||||
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
|
||||
|
||||
# 2) Secondary: log markers
|
||||
if echo "$logs" | grep -qiE "$ok_pat"; then
|
||||
echo "Bridge ready (log markers)"
|
||||
exit 0
|
||||
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
|
||||
|
||||
# Only treat license failures as fatal *after* warm-up
|
||||
if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then
|
||||
echo "::error::Fatal licensing signal detected after warm-up"
|
||||
echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If the container dies mid-wait, bail
|
||||
st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)"
|
||||
if [[ "$st" != "running" ]]; then
|
||||
echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
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
|
||||
|
||||
echo "::error::Bridge not ready before deadline"
|
||||
docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'
|
||||
exit 1
|
||||
|
||||
# (moved) — return license after Unity is stopped
|
||||
|
||||
# ---------- MCP client config ----------
|
||||
- name: Write MCP config (.claude/mcp.json)
|
||||
|
|
@ -192,19 +290,46 @@ jobs:
|
|||
"env": {
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MCP_LOG_LEVEL": "debug",
|
||||
"UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests"
|
||||
"UNITY_PROJECT_ROOT": "$GITHUB_WORKSPACE/TestProjects/UnityMCPTests",
|
||||
"UNITY_MCP_STATUS_DIR": "$RUNNER_TEMP/unity-status",
|
||||
"UNITY_MCP_HOST": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
- name: Pin Claude tool permissions (.claude/settings.json)
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p .claude
|
||||
cat > .claude/settings.json <<'JSON'
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__unity",
|
||||
"Edit(reports/**)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash",
|
||||
"MultiEdit",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"NotebookEdit",
|
||||
"NotebookRead"
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
# ---------- Reports & helper ----------
|
||||
- name: Prepare reports and dirs
|
||||
run: |
|
||||
set -eux
|
||||
rm -f reports/*.xml reports/*.md || true
|
||||
mkdir -p reports reports/_snapshots scripts
|
||||
mkdir -p reports reports/_snapshots reports/_staging
|
||||
|
||||
- name: Create report skeletons
|
||||
run: |
|
||||
|
|
@ -219,79 +344,299 @@ jobs:
|
|||
XML
|
||||
printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT"
|
||||
|
||||
- name: Write safe revert helper (scripts/nlt-revert.sh)
|
||||
shell: bash
|
||||
- name: Verify Unity bridge status/port
|
||||
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
|
||||
set -euxo pipefail
|
||||
ls -la "$RUNNER_TEMP/unity-status" || true
|
||||
jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true
|
||||
|
||||
shopt -s nullglob
|
||||
status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json)
|
||||
if ((${#status_files[@]})); then
|
||||
port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \
|
||||
| sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)"
|
||||
else
|
||||
port=""
|
||||
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"
|
||||
echo "unity_port=$port"
|
||||
if [[ -n "$port" ]]; then
|
||||
timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK"
|
||||
fi
|
||||
|
||||
# (removed) Revert helper and baseline snapshot are no longer used
|
||||
|
||||
|
||||
# ---------- Run suite ----------
|
||||
- name: Run Claude NL suite (single pass)
|
||||
# ---------- Run suite in two passes ----------
|
||||
- name: Run Claude NL 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
|
||||
prompt_file: .claude/prompts/nl-unity-suite-nl.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
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-3-7-sonnet-20250219
|
||||
append_system_prompt: |
|
||||
You are running the NL pass only.
|
||||
- Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4.
|
||||
- Write each to reports/${ID}_results.xml.
|
||||
- Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests.
|
||||
- Stop after NL-4_results.xml is written.
|
||||
timeout_minutes: "30"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
|
||||
- name: Run Claude T pass A-J
|
||||
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-t.md
|
||||
mcp_config: .claude/mcp.json
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-3-5-haiku-20241022
|
||||
append_system_prompt: |
|
||||
You are running the T pass (A–J) only.
|
||||
Output requirements:
|
||||
- Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J.
|
||||
- Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml).
|
||||
- Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch.
|
||||
- If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist.
|
||||
- Do not emit any NL-* fragments.
|
||||
Stop condition:
|
||||
- After T-J_results.xml is written, stop.
|
||||
timeout_minutes: "30"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# (moved) Assert T coverage after staged fragments are promoted
|
||||
|
||||
- name: Check T coverage incomplete (pre-retry)
|
||||
id: t_cov
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
|
||||
if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then
|
||||
missing+=("$id")
|
||||
fi
|
||||
done
|
||||
echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT"
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "list=${missing[*]}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Retry T pass (Sonnet) if incomplete
|
||||
if: steps.t_cov.outputs.missing != '0'
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
use_node_cache: false
|
||||
prompt_file: .claude/prompts/nl-unity-suite-t.md
|
||||
mcp_config: .claude/mcp.json
|
||||
settings: .claude/settings.json
|
||||
allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)"
|
||||
disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead"
|
||||
model: claude-3-7-sonnet-20250219
|
||||
fallback_model: claude-3-5-haiku-20241022
|
||||
append_system_prompt: |
|
||||
You are running the T pass only.
|
||||
Output requirements:
|
||||
- Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J.
|
||||
- Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml).
|
||||
- Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch.
|
||||
- If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist.
|
||||
- Do not emit any NL-* fragments.
|
||||
Stop condition:
|
||||
- After T-J_results.xml is written, stop.
|
||||
timeout_minutes: "30"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Re-assert T coverage (post-retry)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
|
||||
[[ -s "reports/${id}_results.xml" ]] || missing+=("$id")
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "::error::Still missing T fragments: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# (kept) Finalize staged report fragments (promote to reports/)
|
||||
|
||||
# (removed duplicate) Finalize staged report fragments
|
||||
|
||||
- name: Assert T coverage (after promotion)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do
|
||||
if [[ ! -s "reports/${id}_results.xml" ]]; then
|
||||
# Accept staged fragment as present
|
||||
[[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "::error::Missing T fragments: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Canonicalize testcase names (NL/T prefixes)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET, re, os
|
||||
|
||||
RULES = [
|
||||
("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"),
|
||||
("NL-1", r"\b(NL-1|Core\s*Method)\b"),
|
||||
("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"),
|
||||
("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"),
|
||||
("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"),
|
||||
("T-A", r"\b(T-?A|Temporary\s*Helper)\b"),
|
||||
("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"),
|
||||
("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"),
|
||||
("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"),
|
||||
("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"),
|
||||
("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"),
|
||||
("T-G", r"\b(T-?G|Path\s*Normalization)\b"),
|
||||
("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"),
|
||||
("T-I", r"\b(T-?I|Failure\s*Surface)\b"),
|
||||
("T-J", r"\b(T-?J|Idempotenc(y|e))\b"),
|
||||
]
|
||||
|
||||
def canon_name(name: str) -> str:
|
||||
n = name or ""
|
||||
for tid, pat in RULES:
|
||||
if re.search(pat, n, flags=re.I):
|
||||
# If it already starts with the correct format, leave it alone
|
||||
if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I):
|
||||
return n.strip()
|
||||
# If it has a different separator, extract title and reformat
|
||||
title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
return f"{tid} — {title}"
|
||||
# Otherwise, just return the canonical ID
|
||||
return tid
|
||||
return n
|
||||
|
||||
def id_from_filename(p: Path):
|
||||
n = p.name
|
||||
m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"NL-{int(m.group(1))}"
|
||||
m = re.match(r'T([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
return None
|
||||
|
||||
frags = list(sorted(Path("reports").glob("*_results.xml")))
|
||||
for frag in frags:
|
||||
try:
|
||||
tree = ET.parse(frag); root = tree.getroot()
|
||||
except Exception:
|
||||
continue
|
||||
if root.tag != "testcase":
|
||||
continue
|
||||
file_id = id_from_filename(frag)
|
||||
old = root.get("name") or ""
|
||||
# Prefer filename-derived ID; if name doesn't start with it, override
|
||||
if file_id:
|
||||
# Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns)
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip()
|
||||
new = f"{file_id} — {title}" if title else file_id
|
||||
else:
|
||||
new = canon_name(old)
|
||||
if new != old and new:
|
||||
root.set("name", new)
|
||||
tree.write(frag, encoding="utf-8", xml_declaration=False)
|
||||
print(f'canon: {frag.name}: "{old}" -> "{new}"')
|
||||
|
||||
# Note: Do not auto-relable fragments. We rely on per-test strict emission
|
||||
# and the backfill step to surface missing tests explicitly.
|
||||
PY
|
||||
|
||||
- name: Backfill missing NL/T tests (fail placeholders)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
|
||||
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"]
|
||||
seen = set()
|
||||
def id_from_filename(p: Path):
|
||||
n = p.name
|
||||
m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"NL-{int(m.group(1))}"
|
||||
m = re.match(r'T([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
return None
|
||||
|
||||
for p in Path("reports").glob("*_results.xml"):
|
||||
try:
|
||||
r = ET.parse(p).getroot()
|
||||
except Exception:
|
||||
continue
|
||||
# Count by filename id primarily; fall back to testcase name if needed
|
||||
fid = id_from_filename(p)
|
||||
if fid in DESIRED:
|
||||
seen.add(fid)
|
||||
continue
|
||||
if r.tag == "testcase":
|
||||
name = (r.get("name") or "").strip()
|
||||
for d in DESIRED:
|
||||
if name.startswith(d):
|
||||
seen.add(d)
|
||||
break
|
||||
|
||||
Path("reports").mkdir(parents=True, exist_ok=True)
|
||||
for d in DESIRED:
|
||||
if d in seen:
|
||||
continue
|
||||
frag = Path(f"reports/{d}_results.xml")
|
||||
tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d})
|
||||
fail = ET.SubElement(tc, "failure", {"message":"not produced"})
|
||||
fail.text = "The agent did not emit a fragment for this test."
|
||||
ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False)
|
||||
print(f"backfill: {d}")
|
||||
PY
|
||||
|
||||
- name: "Debug: list testcase names"
|
||||
if: always()
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
for p in sorted(Path('reports').glob('*_results.xml')):
|
||||
try:
|
||||
r = ET.parse(p).getroot()
|
||||
if r.tag == 'testcase':
|
||||
print(f"{p.name}: {(r.get('name') or '').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
|
||||
# ---------- Merge testcase fragments into JUnit ----------
|
||||
- name: Normalize/assemble JUnit in-place (single file)
|
||||
if: always()
|
||||
|
|
@ -301,44 +646,96 @@ jobs:
|
|||
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
|
||||
|
||||
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()
|
||||
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)
|
||||
if suite is None:
|
||||
raise SystemExit(0)
|
||||
|
||||
def id_from_filename(p: Path):
|
||||
n = p.name
|
||||
m = re.match(r'NL(\d+)_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"NL-{int(m.group(1))}"
|
||||
m = re.match(r'T([A-J])_results\.xml$', n, re.I)
|
||||
if m:
|
||||
return f"T-{m.group(1).upper()}"
|
||||
return None
|
||||
|
||||
def id_from_system_out(tc):
|
||||
so = tc.find('system-out')
|
||||
if so is not None and so.text:
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
fragments = sorted(Path('reports').glob('*_results.xml'))
|
||||
added = 0
|
||||
renamed = 0
|
||||
|
||||
for frag in fragments:
|
||||
tcs = []
|
||||
try:
|
||||
froot = ET.parse(frag).getroot()
|
||||
if localname(froot.tag) == 'testcase':
|
||||
suite.append(froot); added += 1
|
||||
tcs = [froot]
|
||||
else:
|
||||
for tc in froot.findall('.//testcase'):
|
||||
suite.append(tc); added += 1
|
||||
tcs = list(froot.findall('.//testcase'))
|
||||
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
|
||||
# Extract all testcase nodes from raw text
|
||||
nodes = re.findall(r'<testcase[\s\S]*?</testcase>', txt, flags=re.DOTALL)
|
||||
for m in nodes:
|
||||
try:
|
||||
tcs.append(ET.fromstring(m))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Guard: keep only the first testcase from each fragment
|
||||
if len(tcs) > 1:
|
||||
tcs = tcs[:1]
|
||||
|
||||
test_id = id_from_filename(frag)
|
||||
|
||||
for tc in tcs:
|
||||
current_name = tc.get('name') or ''
|
||||
tid = test_id or id_from_system_out(tc)
|
||||
# Enforce filename-derived ID as prefix; repair names if needed
|
||||
if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name):
|
||||
title = current_name.strip()
|
||||
new_name = f'{tid} — {title}' if title else tid
|
||||
tc.set('name', new_name)
|
||||
elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name):
|
||||
# Replace any wrong leading ID with the correct one
|
||||
title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip()
|
||||
new_name = f'{tid} — {title}' if title else tid
|
||||
tc.set('name', new_name)
|
||||
renamed += 1
|
||||
suite.append(tc)
|
||||
added += 1
|
||||
|
||||
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':
|
||||
if (tc.get('name') or '') == '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('tests', str(len(testcases)))
|
||||
suite.set('failures', str(failures_cnt))
|
||||
suite.set('errors', str(0))
|
||||
suite.set('skipped', str(0))
|
||||
suite.set('errors', '0')
|
||||
suite.set('skipped', '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}")
|
||||
print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.")
|
||||
PY
|
||||
|
||||
# ---------- Markdown summary from JUnit ----------
|
||||
|
|
@ -349,14 +746,13 @@ jobs:
|
|||
python3 - <<'PY'
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
import os, html
|
||||
import os, html, re
|
||||
|
||||
def localname(tag: str) -> str:
|
||||
return tag.rsplit('}', 1)[-1] if '}' in tag else tag
|
||||
|
||||
src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-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():
|
||||
|
|
@ -368,19 +764,33 @@ jobs:
|
|||
suite = root.find('./*') if localname(root.tag) == 'testsuites' else root
|
||||
cases = [] if suite is None else list(suite.findall('.//testcase'))
|
||||
|
||||
def id_from_case(tc):
|
||||
n = (tc.get('name') or '')
|
||||
m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n)
|
||||
if m:
|
||||
return m.group(1)
|
||||
so = tc.find('system-out')
|
||||
if so is not None and so.text:
|
||||
m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
id_status = {}
|
||||
name_map = {}
|
||||
for tc in cases:
|
||||
tid = id_from_case(tc)
|
||||
ok = (tc.find('failure') is None and tc.find('error') is None)
|
||||
if tid and tid not in id_status:
|
||||
id_status[tid] = ok
|
||||
name_map[tid] = (tc.get('name') or tid)
|
||||
|
||||
desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J']
|
||||
|
||||
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',
|
||||
|
|
@ -390,52 +800,59 @@ jobs:
|
|||
'## Test Checklist'
|
||||
]
|
||||
for p in desired:
|
||||
st = status_for(p)
|
||||
st = id_status.get(p, None)
|
||||
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():
|
||||
if n.startswith('NL-'):
|
||||
try:
|
||||
return (0, int(n.split('-')[1]))
|
||||
except:
|
||||
return (0, 999)
|
||||
if n.startswith('T-') and len(n) > 2:
|
||||
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}")
|
||||
seen = set()
|
||||
for tid in sorted(id_status.keys(), key=order_key):
|
||||
seen.add(tid)
|
||||
tc = next((c for c in cases if (id_from_case(c) == tid)), None)
|
||||
if not tc:
|
||||
continue
|
||||
title = name_map.get(tid, tid)
|
||||
status_badge = "PASS" if id_status[tid] else "FAIL"
|
||||
lines.append(f"### {title} — {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 =>)
|
||||
if text:
|
||||
text = html.unescape(text)
|
||||
text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n'))
|
||||
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)
|
||||
fence = '```' if '```' not in t else '````'
|
||||
lines += [fence, t, 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]}")
|
||||
if msg:
|
||||
lines.append(f"- Message: {msg}")
|
||||
if body:
|
||||
lines.append(f"- Detail: {body.splitlines()[0][:500]}")
|
||||
lines.append('')
|
||||
|
||||
for tc in cases:
|
||||
if id_from_case(tc) in seen:
|
||||
continue
|
||||
title = tc.get('name') or '(unnamed)'
|
||||
status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL"
|
||||
lines.append(f"### {title} — {status_badge}")
|
||||
lines.append('(unmapped test id)')
|
||||
lines.append('')
|
||||
|
||||
md_out.write_text('\n'.join(lines), encoding='utf-8')
|
||||
|
|
@ -478,7 +895,7 @@ jobs:
|
|||
p.write_text(s, encoding='utf-8', newline='\n')
|
||||
PY
|
||||
|
||||
- name: NL/T details → Job Summary
|
||||
- name: NL/T details -> Job Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY
|
||||
|
|
@ -538,6 +955,15 @@ jobs:
|
|||
- name: Stop Unity
|
||||
if: always()
|
||||
run: |
|
||||
docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
|
||||
docker logs --tail 400 unity-mcp | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true
|
||||
docker rm -f unity-mcp || true
|
||||
|
||||
- name: Return Pro license (if used)
|
||||
if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true'
|
||||
uses: game-ci/unity-return-license@v2
|
||||
continue-on-error: true
|
||||
env:
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
|
||||
|
||||
119
README-DEV.md
119
README-DEV.md
|
|
@ -16,6 +16,22 @@ Quick deployment and testing tools for MCP for Unity core changes.
|
|||
|
||||
---
|
||||
|
||||
## Switching MCP package sources quickly
|
||||
|
||||
Run this from the unity-mcp repo, not your game's roote directory. Use `mcp_source.py` to quickly switch between different MCP for Unity package sources:
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- **1** Upstream main (CoplayDev/unity-mcp)
|
||||
- **2** Remote current branch (origin + branch)
|
||||
- **3** Local workspace (file: UnityMcpBridge)
|
||||
|
||||
After switching, open Package Manager and Refresh to re-resolve packages.
|
||||
|
||||
## Development Deployment Scripts
|
||||
|
||||
These deployment scripts help you quickly test changes to MCP for Unity core code.
|
||||
|
|
@ -46,6 +62,18 @@ Restores original files from backup.
|
|||
2. Allows you to select which backup to restore
|
||||
3. Restores both Unity Bridge and Python Server files
|
||||
|
||||
### `prune_tool_results.py`
|
||||
Compacts large `tool_result` blobs in conversation JSON into concise one-line summaries.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 prune_tool_results.py < reports/claude-execution-output.json > reports/claude-execution-output.pruned.json
|
||||
```
|
||||
|
||||
The script reads a conversation from `stdin` and writes the pruned version to `stdout`, making logs much easier to inspect or archive.
|
||||
|
||||
These defaults dramatically cut token usage without affecting essential information.
|
||||
|
||||
## Finding Unity Package Cache Path
|
||||
|
||||
Unity stores Git packages under a version-or-hash folder. Expect something like:
|
||||
|
|
@ -66,24 +94,78 @@ 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.
|
||||
|
||||
## MCP Bridge Stress Test
|
||||
|
||||
An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering real script reloads via immediate script edits (no menu calls required).
|
||||
|
||||
### Script
|
||||
- `tools/stress_mcp.py`
|
||||
|
||||
### What it does
|
||||
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
|
||||
- Sends lightweight framed `ping` keepalives to maintain concurrency.
|
||||
- In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with:
|
||||
- `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and
|
||||
- `precondition_sha256` computed from the current file contents to avoid drift.
|
||||
- Uses EOF insertion to avoid header/`using`-guard edits.
|
||||
|
||||
### Usage (local)
|
||||
```bash
|
||||
# Recommended: use the included large script in the test project
|
||||
python3 tools/stress_mcp.py \
|
||||
--duration 60 \
|
||||
--clients 8 \
|
||||
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
|
||||
```
|
||||
|
||||
Flags:
|
||||
- `--project` Unity project path (auto-detected to the included test project by default)
|
||||
- `--unity-file` C# file to edit (defaults to the long test script)
|
||||
- `--clients` number of concurrent clients (default 10)
|
||||
- `--duration` seconds to run (default 60)
|
||||
|
||||
### Expected outcome
|
||||
- No Unity Editor crashes during reload churn
|
||||
- Immediate reloads after each applied edit (no `Assets/Refresh` menu calls)
|
||||
- Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues
|
||||
- JSON summary printed at the end, e.g.:
|
||||
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`
|
||||
|
||||
### Notes and troubleshooting
|
||||
- Immediate vs debounced:
|
||||
- The tool sets `options.refresh = "immediate"` so changes compile instantly. If you only need churn (not per-edit confirmation), switch to debounced to reduce mid-reload failures.
|
||||
- Precondition required:
|
||||
- `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA.
|
||||
- Edit location:
|
||||
- To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle.
|
||||
- Read API:
|
||||
- The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool.
|
||||
- Transient failures:
|
||||
- Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration.
|
||||
|
||||
### CI guidance
|
||||
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
|
||||
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.
|
||||
|
||||
## 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.
|
||||
We provide a CI job to run a Natural Language Editing suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge. To run from your fork, you need the following GitHub "secrets": an `ANTHROPIC_API_KEY` and Unity credentials (usually `UNITY_EMAIL` + `UNITY_PASSWORD` or `UNITY_LICENSE` / `UNITY_SERIAL`.) These are redacted in logs so never visible.
|
||||
|
||||
- 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/`.
|
||||
***To run it***
|
||||
- Trigger: In GitHun "Actions" for the repo, trigger `workflow dispatch` (`Claude NL/T Full Suite (Unity live)`).
|
||||
- Image: `UNITY_IMAGE` (UnityCI) pulled by tag; the job resolves a digest at runtime. Logs are sanitized.
|
||||
- Execution: single pass with immediate per‑test fragment emissions (strict single `<testcase>` per file). A placeholder guard fails fast if any fragment is a bare ID. Staging (`reports/_staging`) is promoted to `reports/` to reduce partial writes.
|
||||
- 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.
|
||||
### Adjust tests / prompts
|
||||
- Edit `.claude/prompts/nl-unity-suite-t.md` to modify the NL/T steps. Follow the conventions: emit one XML fragment per test under `reports/<TESTID>_results.xml`, each containing exactly one `<testcase>` with a `name` that begins with the test ID. No prologue/epilogue or code fences.
|
||||
- Keep edits minimal and reversible; include concise evidence.
|
||||
|
||||
### Run the suite
|
||||
1) Push your branch, then manually run the workflow from the Actions tab.
|
||||
|
|
@ -95,7 +177,6 @@ We provide a CI job to run a Natural Language Editing mini-suite against the Uni
|
|||
- 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
|
||||
|
|
@ -109,24 +190,6 @@ We provide a CI job to run a Natural Language Editing mini-suite against the Uni
|
|||
4. **Iterate** - repeat steps 1-3 as needed
|
||||
5. **Restore** original files when done using `restore-dev.bat`
|
||||
|
||||
|
||||
## Switching MCP package sources quickly
|
||||
|
||||
Use `mcp_source.py` to quickly switch between different MCP for Unity package sources:
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- **1** Upstream main (CoplayDev/unity-mcp)
|
||||
- **2** Remote current branch (origin + branch)
|
||||
- **3** Local workspace (file: UnityMcpBridge)
|
||||
|
||||
After switching, open Package Manager and Refresh to re-resolve packages.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Path not found" errors running the .bat file
|
||||
|
|
|
|||
154
README.md
154
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# MCP for Unity ✨
|
||||
<img width="676" height="380" alt="MCP for Unity" src="https://github.com/user-attachments/assets/b712e41d-273c-48b2-9041-82bd17ace267" />
|
||||
|
||||
#### 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)
|
||||
|
||||
|
|
@ -15,9 +15,9 @@
|
|||
|
||||
MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to interact directly with your Unity Editor via a local **MCP (Model Context Protocol) Client**. Give your LLM tools to manage assets, control scenes, edit scripts, and automate tasks within Unity.
|
||||
|
||||
## 💬 Join Our Community
|
||||
---
|
||||
|
||||
### [Discord](https://discord.gg/y4p8KfzrN4)
|
||||
### 💬 Join Our [Discord](https://discord.gg/y4p8KfzrN4)
|
||||
|
||||
**Get help, share ideas, and collaborate with other MCP for Unity developers!**
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
|
|||
|
||||
* `read_console`: Gets messages from or clears the console.
|
||||
* `manage_script`: Manages C# scripts (create, read, update, delete).
|
||||
* `manage_editor`: Controls and queries the editor\'s state and settings.
|
||||
* `manage_editor`: Controls and queries the editor's state and settings.
|
||||
* `manage_scene`: Manages scenes (load, save, create, get hierarchy, etc.).
|
||||
* `manage_asset`: Performs asset operations (import, create, modify, delete, etc.).
|
||||
* `manage_shader`: Performs shader CRUD operations (create, read, modify, delete).
|
||||
|
|
@ -50,37 +50,36 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to
|
|||
|
||||
---
|
||||
|
||||
## How It Works 🤔
|
||||
## How It Works
|
||||
|
||||
MCP for Unity connects your tools using two components:
|
||||
|
||||
1. **MCP for Unity Bridge:** A Unity package running inside the Editor. (Installed via Package Manager).
|
||||
2. **MCP for Unity Server:** A Python server that runs locally, communicating between the Unity Bridge and your MCP Client. (Installed automatically by the package on first run or via Auto-Setup; manual setup is available as a fallback).
|
||||
|
||||
**Flow:** `[Your LLM via MCP Client] <-> [MCP for Unity Server (Python)] <-> [MCP for Unity Bridge (Unity Editor)]`
|
||||
<img width="562" height="121" alt="image" src="https://github.com/user-attachments/assets/9abf9c66-70d1-4b82-9587-658e0d45dc3e" />
|
||||
|
||||
---
|
||||
|
||||
## Installation ⚙️
|
||||
|
||||
> **Note:** The setup is constantly improving as we update the package. Check back if you randomly start to run into issues.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* **Python:** Version 3.12 or newer. [Download Python](https://www.python.org/downloads/)
|
||||
* **Unity Hub & Editor:** Version 2021.3 LTS or newer. [Download Unity](https://unity.com/download)
|
||||
* **uv (Python package manager):**
|
||||
* **uv (Python toolchain manager):**
|
||||
```bash
|
||||
pip install uv
|
||||
# Or see: https://docs.astral.sh/uv/getting-started/installation/
|
||||
# macOS / Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
winget install Astral.Sh.Uv
|
||||
|
||||
# Docs: https://docs.astral.sh/uv/getting-started/installation/
|
||||
```
|
||||
* **An MCP Client:**
|
||||
* [Claude Desktop](https://claude.ai/download)
|
||||
* [Claude Code](https://github.com/anthropics/claude-code)
|
||||
* [Cursor](https://www.cursor.com/en/downloads)
|
||||
* [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview)
|
||||
* [Windsurf](https://windsurf.com)
|
||||
* *(Others may work with manual config)*
|
||||
|
||||
* **An MCP Client:** : [Claude Desktop](https://claude.ai/download) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://www.cursor.com/en/downloads) | [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview) | [Windsurf](https://windsurf.com) | Others work with manual config
|
||||
|
||||
* <details> <summary><strong>[Optional] Roslyn for Advanced Script Validation</strong></summary>
|
||||
|
||||
For **Strict** validation level that catches undefined namespaces, types, and methods:
|
||||
|
|
@ -102,7 +101,8 @@ MCP for Unity connects your tools using two components:
|
|||
|
||||
**Note:** Without Roslyn, script validation falls back to basic structural checks. Roslyn enables full C# compiler diagnostics with precise error reporting.</details>
|
||||
|
||||
### 🌟Step 1: Install the Unity Package🌟
|
||||
---
|
||||
### 🌟 Step 1: Install the Unity Package
|
||||
|
||||
#### To install via Git URL
|
||||
|
||||
|
|
@ -118,14 +118,13 @@ MCP for Unity connects your tools using two components:
|
|||
|
||||
#### To install via OpenUPM
|
||||
|
||||
1. Instal the [OpenUPM CLI](https://openupm.com/docs/getting-started-cli.html)
|
||||
1. Install the [OpenUPM CLI](https://openupm.com/docs/getting-started-cli.html)
|
||||
2. Open a terminal (PowerShell, Terminal, etc.) and navigate to your Unity project directory
|
||||
3. Run `openupm add com.coplaydev.unity-mcp`
|
||||
|
||||
**Note:** If you installed the MCP Server before Coplay's maintenance, you will need to uninstall the old package before re-installing the new one.
|
||||
|
||||
### Step 2: Configure Your MCP Client
|
||||
|
||||
### 🛠️ Step 2: Configure Your MCP Client
|
||||
Connect your MCP Client (Claude, Cursor, etc.) to the Python server set up in Step 1 (auto) or via Manual Configuration (below).
|
||||
|
||||
<img width="648" height="599" alt="MCPForUnity-Readme-Image" src="https://github.com/user-attachments/assets/b4a725da-5c43-4bd6-80d6-ee2e3cca9596" />
|
||||
|
|
@ -134,7 +133,7 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server set up in St
|
|||
|
||||
1. In Unity, go to `Window > MCP for Unity`.
|
||||
2. Click `Auto-Setup`.
|
||||
3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client\'s config file automatically).*
|
||||
3. Look for a green status indicator 🟢 and "Connected ✓". *(This attempts to modify the MCP Client's config file automatically).*
|
||||
|
||||
<details><summary><strong>Client-specific troubleshooting</strong></summary>
|
||||
|
||||
|
|
@ -147,7 +146,7 @@ Connect your MCP Client (Claude, Cursor, etc.) to the Python server set up in St
|
|||
|
||||
If Auto-Setup fails or you use a different client:
|
||||
|
||||
1. **Find your MCP Client\'s configuration file.** (Check client documentation).
|
||||
1. **Find your MCP Client's configuration file.** (Check client documentation).
|
||||
* *Claude Example (macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
* *Claude Example (Windows):* `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
2. **Edit the file** to add/update the `mcpServers` section, using the *exact* paths from Step 1.
|
||||
|
|
@ -155,6 +154,23 @@ If Auto-Setup fails or you use a different client:
|
|||
<details>
|
||||
<summary><strong>Click for Client-Specific JSON Configuration Snippets...</strong></summary>
|
||||
|
||||
---
|
||||
**Claude Code**
|
||||
|
||||
If you're using Claude Code, you can register the MCP server using the below commands:
|
||||
🚨**make sure to run these from your Unity project's home directory**🚨
|
||||
|
||||
**macOS:**
|
||||
|
||||
```bash
|
||||
claude mcp add UnityMCP -- uv --directory /Users/USERNAME/Library/AppSupport/UnityMCP/UnityMcpServer/src run server.py
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```bash
|
||||
claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microsoft/WinGet/Links/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/UnityMCP/UnityMcpServer/src" run server.py
|
||||
```
|
||||
**VSCode (all OS)**
|
||||
|
||||
```json
|
||||
|
|
@ -181,7 +197,7 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
|
|||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"C:\\Users\\YOUR_USERNAME\\AppData\\Local\\Programs\\UnityMCP\\UnityMcpServer\\src",
|
||||
"C:\\Users\\YOUR_USERNAME\\AppData\\Local\\UnityMCP\\UnityMcpServer\\src",
|
||||
"server.py"
|
||||
]
|
||||
}
|
||||
|
|
@ -202,7 +218,7 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
|
|||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"/usr/local/bin/UnityMCP/UnityMcpServer/src",
|
||||
"/Users/YOUR_USERNAME/Library/AppSupport/UnityMCP/UnityMcpServer/src",
|
||||
"server.py"
|
||||
]
|
||||
}
|
||||
|
|
@ -211,7 +227,7 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
|
|||
}
|
||||
```
|
||||
|
||||
(Replace YOUR_USERNAME if using ~/bin)
|
||||
(Replace YOUR_USERNAME. Note: AppSupport is a symlink to "Application Support" to avoid quoting issues)
|
||||
|
||||
**Linux:**
|
||||
|
||||
|
|
@ -223,7 +239,7 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
|
|||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"/home/YOUR_USERNAME/bin/UnityMCP/UnityMcpServer/src",
|
||||
"/home/YOUR_USERNAME/.local/share/UnityMCP/UnityMcpServer/src",
|
||||
"server.py"
|
||||
]
|
||||
}
|
||||
|
|
@ -234,21 +250,7 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L
|
|||
|
||||
(Replace YOUR_USERNAME)
|
||||
|
||||
**For Claude Code**
|
||||
|
||||
If you\'re using Claude Code, you can register the MCP server using these commands:
|
||||
|
||||
**macOS:**
|
||||
|
||||
```bash
|
||||
claude mcp add UnityMCP -- uv --directory /[PATH_TO]/UnityMCP/UnityMcpServer/src run server.py
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```bash
|
||||
claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/Scripts/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/Programs/UnityMCP/UnityMcpServer/src" run server.py
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
|
@ -261,58 +263,19 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Roaming/Python/Python313/S
|
|||
|
||||
3. **Interact!** Unity tools should now be available in your MCP Client.
|
||||
|
||||
Example Prompt: `Create a 3D player controller`, `Create a yellow and bridge sun`, `Create a cool shader and apply it on a cube`.
|
||||
Example Prompt: `Create a 3D player controller`, `Create a tic-tac-toe game in 3D`, `Create a cool shader and apply to a cube`.
|
||||
|
||||
---
|
||||
|
||||
## Future Dev Plans (Besides PR) 📝
|
||||
## Development & Contributing 🛠️
|
||||
|
||||
### 🔴 High Priority
|
||||
### For Developers
|
||||
|
||||
- [ ] **Asset Generation Improvements** - Enhanced server request handling and asset pipeline optimization
|
||||
- [ ] **Code Generation Enhancements** - Improved generated code quality and error handling
|
||||
- [ ] **Robust Error Handling** - Comprehensive error messages, recovery mechanisms, and graceful degradation
|
||||
- [ ] **Remote Connection Support** - Enable seamless remote connection between Unity host and MCP server
|
||||
- [ ] **Documentation Expansion** - Complete tutorials for custom tool creation and API reference
|
||||
|
||||
### 🟡 Medium Priority
|
||||
|
||||
- [ ] **Custom Tool Creation GUI** - Visual interface for users to create and configure their own MCP tools
|
||||
- [ ] **Advanced Logging System** - Logging with filtering, export, and debugging capabilities
|
||||
|
||||
### 🟢 Low Priority
|
||||
|
||||
- [ ] **Mobile Platform Support** - Extended toolset for mobile development workflows and platform-specific features
|
||||
- [ ] **Easier Tool Setup**
|
||||
- [ ] **Plugin Marketplace** - Community-driven tool sharing and distribution platform
|
||||
|
||||
<details open>
|
||||
<summary><strong>✅ Completed Features<strong></summary>
|
||||
|
||||
- [x] **Shader Generation** - Generate shaders using CGProgram template
|
||||
- [x] **Advanced Script Validation** - Multi-level validation with semantic analysis, namespace/type checking, and Unity best practices (Will need Roslyn Installed, see [Prerequisite](#prerequisites)).
|
||||
</details>
|
||||
|
||||
### 🔬 Research & Exploration
|
||||
|
||||
- [ ] **AI-Powered Asset Generation** - Integration with AI tools for automatic 3D models, textures, and animations
|
||||
- [ ] **Real-time Collaboration** - Live editing sessions between multiple developers *(Currently in progress)*
|
||||
- [ ] **Analytics Dashboard** - Usage analytics, project insights, and performance metrics
|
||||
- [ ] **Voice Commands** - Voice-controlled Unity operations for accessibility
|
||||
- [ ] **AR/VR Tool Integration** - Extended support for immersive development workflows
|
||||
|
||||
---
|
||||
|
||||
## For Developers 🛠️
|
||||
|
||||
### Development Tools
|
||||
|
||||
If you\'re contributing to MCP for Unity or want to test core changes, we have development tools to streamline your workflow:
|
||||
If you're contributing to MCP for Unity or want to test core changes, we have development tools to streamline your workflow:
|
||||
|
||||
- **Development Deployment Scripts**: Quickly deploy and test your changes to MCP for Unity Bridge and Python Server
|
||||
- **Automatic Backup System**: Safe testing with easy rollback capabilities
|
||||
- **Hot Reload Workflow**: Fast iteration cycle for core development
|
||||
- **More coming!**
|
||||
|
||||
📖 **See [README-DEV.md](README-DEV.md)** for complete development setup and workflow documentation.
|
||||
|
||||
|
|
@ -321,15 +284,10 @@ If you\'re contributing to MCP for Unity or want to test core changes, we have d
|
|||
Help make MCP for Unity better!
|
||||
|
||||
1. **Fork** the main repository.
|
||||
|
||||
2. **Create a branch** (`feature/your-idea` or `bugfix/your-fix`).
|
||||
|
||||
3. **Make changes.**
|
||||
|
||||
4. **Commit** (feat: Add cool new feature).
|
||||
|
||||
5. **Push** your branch.
|
||||
|
||||
6. **Open a Pull Request** against the main branch.
|
||||
|
||||
---
|
||||
|
|
@ -356,12 +314,18 @@ Your privacy matters to us. All telemetry is optional and designed to respect yo
|
|||
- Check the status window: Window > MCP for Unity.
|
||||
- Restart Unity.
|
||||
- **MCP Client Not Connecting / Server Not Starting:**
|
||||
- **Verify Server Path:** Double-check the --directory path in your MCP Client\'s JSON config. It must exactly match the location where you cloned the UnityMCP repository in Installation Step 1 (e.g., .../Programs/UnityMCP/UnityMcpServer/src).
|
||||
- **Verify uv:** Make sure `uv` is installed and working (pip show uv).
|
||||
- **Run Manually:** Try running the server directly from the terminal to see errors: `# Navigate to the src directory first! cd /path/to/your/UnityMCP/UnityMcpServer/src uv run server.py`
|
||||
- **Permissions (macOS/Linux):** If you installed the server in a system location like /usr/local/bin, ensure the user running the MCP client has permission to execute uv and access files there. Installing in ~/bin might be easier.
|
||||
- **Verify Server Path:** Double-check the --directory path in your MCP Client's JSON config. It must exactly match the installation location:
|
||||
- **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
|
||||
- **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`
|
||||
- **Linux:** `~/.local/share/UnityMCP/UnityMcpServer\src`
|
||||
- **Verify uv:** Make sure `uv` is installed and working (`uv --version`).
|
||||
- **Run Manually:** Try running the server directly from the terminal to see errors:
|
||||
```bash
|
||||
cd /path/to/your/UnityMCP/UnityMcpServer/src
|
||||
uv run server.py
|
||||
```
|
||||
- **Auto-Configure Failed:**
|
||||
- Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client\'s config file.
|
||||
- Use the Manual Configuration steps. Auto-configure might lack permissions to write to the MCP client's config file.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -261,6 +261,31 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
// 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
|
||||
|
||||
// Harden: verify structured error response with failures list contains both invalid fields
|
||||
var successProp = result.GetType().GetProperty("success");
|
||||
Assert.IsNotNull(successProp, "Result should expose 'success' property");
|
||||
Assert.IsFalse((bool)successProp.GetValue(result), "Result.success should be false for partial failure");
|
||||
|
||||
var dataProp = result.GetType().GetProperty("data");
|
||||
Assert.IsNotNull(dataProp, "Result should include 'data' with errors");
|
||||
var dataVal = dataProp.GetValue(result);
|
||||
Assert.IsNotNull(dataVal, "Result.data should not be null");
|
||||
var errorsProp = dataVal.GetType().GetProperty("errors");
|
||||
Assert.IsNotNull(errorsProp, "Result.data should include 'errors' list");
|
||||
var errorsEnum = errorsProp.GetValue(dataVal) as System.Collections.IEnumerable;
|
||||
Assert.IsNotNull(errorsEnum, "errors should be enumerable");
|
||||
|
||||
bool foundRotatoin = false;
|
||||
bool foundInvalidProp = false;
|
||||
foreach (var err in errorsEnum)
|
||||
{
|
||||
string s = err?.ToString() ?? string.Empty;
|
||||
if (s.Contains("rotatoin")) foundRotatoin = true;
|
||||
if (s.Contains("invalidProp")) foundInvalidProp = true;
|
||||
}
|
||||
Assert.IsTrue(foundRotatoin, "errors should mention the misspelled 'rotatoin' property");
|
||||
Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
@ -307,6 +332,28 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
|
||||
// The key test: processing continued after the exception and set useGravity
|
||||
// This proves the collect-and-continue behavior works even with exceptions
|
||||
|
||||
// Harden: verify structured error response contains velocity failure
|
||||
var successProp2 = result.GetType().GetProperty("success");
|
||||
Assert.IsNotNull(successProp2, "Result should expose 'success' property");
|
||||
Assert.IsFalse((bool)successProp2.GetValue(result), "Result.success should be false when an exception occurs for a property");
|
||||
|
||||
var dataProp2 = result.GetType().GetProperty("data");
|
||||
Assert.IsNotNull(dataProp2, "Result should include 'data' with errors");
|
||||
var dataVal2 = dataProp2.GetValue(result);
|
||||
Assert.IsNotNull(dataVal2, "Result.data should not be null");
|
||||
var errorsProp2 = dataVal2.GetType().GetProperty("errors");
|
||||
Assert.IsNotNull(errorsProp2, "Result.data should include 'errors' list");
|
||||
var errorsEnum2 = errorsProp2.GetValue(dataVal2) as System.Collections.IEnumerable;
|
||||
Assert.IsNotNull(errorsEnum2, "errors should be enumerable");
|
||||
|
||||
bool foundVelocityError = false;
|
||||
foreach (var err in errorsEnum2)
|
||||
{
|
||||
string s = err?.ToString() ?? string.Empty;
|
||||
if (s.Contains("velocity")) { foundVelocityError = true; break; }
|
||||
}
|
||||
Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,11 @@ namespace MCPForUnity.Editor
|
|||
private static bool isRunning = false;
|
||||
private static readonly object lockObj = new();
|
||||
private static readonly object startStopLock = new();
|
||||
private static readonly object clientsLock = new();
|
||||
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
|
||||
private static CancellationTokenSource cts;
|
||||
private static Task listenerTask;
|
||||
private static int processingCommands = 0;
|
||||
private static bool initScheduled = false;
|
||||
private static bool ensureUpdateHooked = false;
|
||||
private static bool isStarting = false;
|
||||
|
|
@ -199,9 +204,15 @@ namespace MCPForUnity.Editor
|
|||
}
|
||||
|
||||
isStarting = true;
|
||||
// Attempt start; if it succeeds, remove the hook to avoid overhead
|
||||
Start();
|
||||
isStarting = false;
|
||||
try
|
||||
{
|
||||
// Attempt start; if it succeeds, remove the hook to avoid overhead
|
||||
Start();
|
||||
}
|
||||
finally
|
||||
{
|
||||
isStarting = false;
|
||||
}
|
||||
if (isRunning)
|
||||
{
|
||||
EditorApplication.update -= EnsureStartedOnEditorIdle;
|
||||
|
|
@ -325,8 +336,17 @@ namespace MCPForUnity.Editor
|
|||
string platform = Application.platform.ToString();
|
||||
string serverVer = ReadInstalledServerVersionSafe();
|
||||
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
|
||||
Task.Run(ListenerLoop);
|
||||
// Start background listener with cooperative cancellation
|
||||
cts = new CancellationTokenSource();
|
||||
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
|
||||
EditorApplication.update += ProcessCommands;
|
||||
// Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
|
||||
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
|
||||
try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
|
||||
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
|
||||
try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
|
||||
try { EditorApplication.quitting -= Stop; } catch { }
|
||||
try { EditorApplication.quitting += Stop; } catch { }
|
||||
// Write initial heartbeat immediately
|
||||
heartbeatSeq++;
|
||||
WriteHeartbeat(false, "ready");
|
||||
|
|
@ -341,6 +361,7 @@ namespace MCPForUnity.Editor
|
|||
|
||||
public static void Stop()
|
||||
{
|
||||
Task toWait = null;
|
||||
lock (startStopLock)
|
||||
{
|
||||
if (!isRunning)
|
||||
|
|
@ -352,23 +373,55 @@ namespace MCPForUnity.Editor
|
|||
{
|
||||
// Mark as stopping early to avoid accept logging during disposal
|
||||
isRunning = false;
|
||||
// Mark heartbeat one last time before stopping
|
||||
WriteHeartbeat(false, "stopped");
|
||||
listener?.Stop();
|
||||
|
||||
// Quiesce background listener quickly
|
||||
var cancel = cts;
|
||||
cts = null;
|
||||
try { cancel?.Cancel(); } catch { }
|
||||
|
||||
try { listener?.Stop(); } catch { }
|
||||
listener = null;
|
||||
EditorApplication.update -= ProcessCommands;
|
||||
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
|
||||
|
||||
// Capture background task to wait briefly outside the lock
|
||||
toWait = listenerTask;
|
||||
listenerTask = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Proactively close all active client sockets to unblock any pending reads
|
||||
TcpClient[] toClose;
|
||||
lock (clientsLock)
|
||||
{
|
||||
toClose = activeClients.ToArray();
|
||||
activeClients.Clear();
|
||||
}
|
||||
foreach (var c in toClose)
|
||||
{
|
||||
try { c.Close(); } catch { }
|
||||
}
|
||||
|
||||
// Give the background loop a short window to exit without blocking the editor
|
||||
if (toWait != null)
|
||||
{
|
||||
try { toWait.Wait(100); } catch { }
|
||||
}
|
||||
|
||||
// Now unhook editor events safely
|
||||
try { EditorApplication.update -= ProcessCommands; } catch { }
|
||||
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
|
||||
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
|
||||
try { EditorApplication.quitting -= Stop; } catch { }
|
||||
|
||||
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
|
||||
}
|
||||
|
||||
private static async Task ListenerLoop()
|
||||
private static async Task ListenerLoopAsync(CancellationToken token)
|
||||
{
|
||||
while (isRunning)
|
||||
while (isRunning && !token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -384,19 +437,23 @@ namespace MCPForUnity.Editor
|
|||
client.ReceiveTimeout = 60000; // 60 seconds
|
||||
|
||||
// Fire and forget each client connection
|
||||
_ = HandleClientAsync(client);
|
||||
_ = Task.Run(() => HandleClientAsync(client, token), token);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Listener was disposed during stop/reload; exit quietly
|
||||
if (!isRunning)
|
||||
if (!isRunning || token.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (isRunning)
|
||||
if (isRunning && !token.IsCancellationRequested)
|
||||
{
|
||||
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
|
||||
}
|
||||
|
|
@ -404,11 +461,14 @@ namespace MCPForUnity.Editor
|
|||
}
|
||||
}
|
||||
|
||||
private static async Task HandleClientAsync(TcpClient client)
|
||||
private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
using (client)
|
||||
using (NetworkStream stream = client.GetStream())
|
||||
{
|
||||
lock (clientsLock) { activeClients.Add(client); }
|
||||
try
|
||||
{
|
||||
// Framed I/O only; legacy mode removed
|
||||
try
|
||||
{
|
||||
|
|
@ -443,12 +503,12 @@ namespace MCPForUnity.Editor
|
|||
return; // abort this client
|
||||
}
|
||||
|
||||
while (isRunning)
|
||||
while (isRunning && !token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Strict framed mode only: enforced framed I/O for this connection
|
||||
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs);
|
||||
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -460,7 +520,7 @@ namespace MCPForUnity.Editor
|
|||
}
|
||||
catch { }
|
||||
string commandId = Guid.NewGuid().ToString();
|
||||
TaskCompletionSource<string> tcs = new();
|
||||
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Special handling for ping command to avoid JSON parsing
|
||||
if (commandText.Trim() == "ping")
|
||||
|
|
@ -479,7 +539,7 @@ namespace MCPForUnity.Editor
|
|||
commandQueue[commandId] = (commandText, tcs);
|
||||
}
|
||||
|
||||
string response = await tcs.Task;
|
||||
string response = await tcs.Task.ConfigureAwait(false);
|
||||
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
|
||||
await WriteFrameAsync(stream, responseBytes);
|
||||
}
|
||||
|
|
@ -502,6 +562,11 @@ namespace MCPForUnity.Editor
|
|||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (clientsLock) { activeClients.Remove(client); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -580,9 +645,9 @@ namespace MCPForUnity.Editor
|
|||
#endif
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs)
|
||||
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
|
||||
{
|
||||
byte[] header = await ReadExactAsync(stream, 8, timeoutMs);
|
||||
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
|
||||
ulong payloadLen = ReadUInt64BigEndian(header);
|
||||
if (payloadLen > MaxFrameBytes)
|
||||
{
|
||||
|
|
@ -595,7 +660,7 @@ namespace MCPForUnity.Editor
|
|||
throw new System.IO.IOException("Frame too large for buffer");
|
||||
}
|
||||
int count = (int)payloadLen;
|
||||
byte[] payload = await ReadExactAsync(stream, count, timeoutMs);
|
||||
byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
|
||||
return System.Text.Encoding.UTF8.GetString(payload);
|
||||
}
|
||||
|
||||
|
|
@ -630,6 +695,10 @@ namespace MCPForUnity.Editor
|
|||
|
||||
private static void ProcessCommands()
|
||||
{
|
||||
if (!isRunning) return;
|
||||
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
|
||||
try
|
||||
{
|
||||
// Heartbeat without holding the queue lock
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
if (now >= nextHeartbeatAt)
|
||||
|
|
@ -740,6 +809,11 @@ namespace MCPForUnity.Editor
|
|||
// Remove quickly under lock
|
||||
lock (lockObj) { commandQueue.Remove(id); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref processingCommands, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check if a string is valid JSON
|
||||
|
|
@ -871,8 +945,7 @@ namespace MCPForUnity.Editor
|
|||
{
|
||||
// Stop cleanly before reload so sockets close and clients see 'reloading'
|
||||
try { Stop(); } catch { }
|
||||
WriteHeartbeat(true, "reloading");
|
||||
LogBreadcrumb("Reload");
|
||||
// Avoid file I/O or heavy work here
|
||||
}
|
||||
|
||||
private static void OnAfterAssemblyReload()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ namespace MCPForUnity.Editor.Tools
|
|||
/// </summary>
|
||||
public static object HandleCommand(JObject @params)
|
||||
{
|
||||
string action = @params["action"]?.ToString().ToLower() ?? "execute"; // Default action
|
||||
string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -96,14 +96,15 @@ namespace MCPForUnity.Editor.Tools
|
|||
|
||||
try
|
||||
{
|
||||
// Trace incoming execute requests
|
||||
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
|
||||
// Trace incoming execute requests (debug-gated)
|
||||
McpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false);
|
||||
|
||||
// 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}'");
|
||||
// Success trace (debug-gated)
|
||||
McpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false);
|
||||
return Response.Success(
|
||||
$"Executed menu item: '{menuPath}'",
|
||||
new { executed = true, menuPath }
|
||||
|
|
|
|||
|
|
@ -814,9 +814,34 @@ namespace MCPForUnity.Editor.Tools
|
|||
// Return component errors if any occurred (after processing all components)
|
||||
if (componentErrors.Count > 0)
|
||||
{
|
||||
// Aggregate flattened error strings to make tests/API assertions simpler
|
||||
var aggregatedErrors = new System.Collections.Generic.List<string>();
|
||||
foreach (var errorObj in componentErrors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dataProp = errorObj?.GetType().GetProperty("data");
|
||||
var dataVal = dataProp?.GetValue(errorObj);
|
||||
if (dataVal != null)
|
||||
{
|
||||
var errorsProp = dataVal.GetType().GetProperty("errors");
|
||||
var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable;
|
||||
if (errorsEnum != null)
|
||||
{
|
||||
foreach (var item in errorsEnum)
|
||||
{
|
||||
var s = item?.ToString();
|
||||
if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return Response.Error(
|
||||
$"One or more component property operations failed on '{targetGo.name}'.",
|
||||
new { componentErrors = componentErrors }
|
||||
new { componentErrors = componentErrors, errors = aggregatedErrors }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1347,6 +1347,10 @@ namespace MCPForUnity.Editor.Tools
|
|||
appliedCount = replacements.Count;
|
||||
}
|
||||
|
||||
// Guard against structural imbalance before validation
|
||||
if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal))
|
||||
return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() });
|
||||
|
||||
// No-op guard for structured edits: if text unchanged, return explicit no-op
|
||||
if (string.Equals(working, original, StringComparison.Ordinal))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "MCPForUnityServer"
|
||||
version = "3.3.1"
|
||||
version = "3.3.2"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -428,6 +428,11 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
"level": level,
|
||||
}
|
||||
resp = send_command_with_retry("manage_script", params)
|
||||
if isinstance(resp, dict) and resp.get("success"):
|
||||
diags = resp.get("data", {}).get("diagnostics", []) or []
|
||||
warnings = sum(d.get("severity", "").lower() == "warning" for d in diags)
|
||||
errors = sum(d.get("severity", "").lower() in ("error", "fatal") for d in diags)
|
||||
return {"success": True, "data": {"warnings": warnings, "errors": errors}}
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
|
||||
@mcp.tool(description=(
|
||||
|
|
@ -601,6 +606,15 @@ def register_manage_script_tools(mcp: FastMCP):
|
|||
name, directory = _split_uri(uri)
|
||||
params = {"action": "get_sha", "name": name, "path": directory}
|
||||
resp = send_command_with_retry("manage_script", params)
|
||||
if isinstance(resp, dict) and resp.get("success"):
|
||||
data = resp.get("data", {})
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"sha256": data.get("sha256"),
|
||||
"lengthBytes": data.get("lengthBytes"),
|
||||
},
|
||||
}
|
||||
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}"}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,16 @@ def register_read_console_tools(mcp: FastMCP):
|
|||
# Get the connection instance
|
||||
bridge = get_unity_connection()
|
||||
|
||||
# Set defaults if values are None
|
||||
# Set defaults if values are None (conservative but useful for CI)
|
||||
action = action if action is not None else 'get'
|
||||
types = types if types is not None else ['error', 'warning', 'log']
|
||||
format = format if format is not None else 'detailed'
|
||||
types = types if types is not None else ['error']
|
||||
# Normalize types if passed as a single string
|
||||
if isinstance(types, str):
|
||||
types = [types]
|
||||
format = format if format is not None else 'json'
|
||||
include_stacktrace = include_stacktrace if include_stacktrace is not None else True
|
||||
# Default count to a higher value unless explicitly provided
|
||||
count = 50 if count is None else count
|
||||
|
||||
# Normalize action if it's a string
|
||||
if isinstance(action, str):
|
||||
|
|
@ -68,6 +73,25 @@ def register_read_console_tools(mcp: FastMCP):
|
|||
if 'count' not in params_dict:
|
||||
params_dict['count'] = None
|
||||
|
||||
# Use centralized retry helper
|
||||
# Use centralized retry helper (tolerate legacy list payloads from some agents)
|
||||
resp = send_command_with_retry("read_console", params_dict)
|
||||
if isinstance(resp, dict) and resp.get("success") and not include_stacktrace:
|
||||
data = resp.get("data", {}) or {}
|
||||
lines = data.get("lines")
|
||||
if lines is None:
|
||||
# Some handlers return the raw list under data
|
||||
lines = data if isinstance(data, list) else []
|
||||
|
||||
def _entry(x: Any) -> Dict[str, Any]:
|
||||
if isinstance(x, dict):
|
||||
return {
|
||||
"level": x.get("level") or x.get("type"),
|
||||
"message": x.get("message") or x.get("text"),
|
||||
}
|
||||
if isinstance(x, (list, tuple)) and len(x) >= 2:
|
||||
return {"level": x[0], "message": x[1]}
|
||||
return {"level": None, "message": str(x)}
|
||||
|
||||
trimmed = [_entry(l) for l in (lines or [])]
|
||||
return {"success": True, "data": {"lines": trimmed}}
|
||||
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
|
||||
|
|
@ -183,10 +183,12 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
tail_lines: int | None = None,
|
||||
project_root: str | None = None,
|
||||
request: str | None = None,
|
||||
include_text: bool = False,
|
||||
) -> 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.
|
||||
By default only the SHA-256 hash and byte length are returned; set
|
||||
``include_text`` or provide window arguments to receive text.
|
||||
"""
|
||||
try:
|
||||
# Serve the canonical spec directly when requested (allow bare or with scheme)
|
||||
|
|
@ -291,25 +293,43 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
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])
|
||||
raw = p.read_bytes()
|
||||
sha = hashlib.sha256(raw).hexdigest()
|
||||
length = len(raw)
|
||||
|
||||
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return {"success": True, "data": {"text": text, "metadata": {"sha256": sha}}}
|
||||
want_text = (
|
||||
bool(include_text)
|
||||
or (head_bytes is not None and head_bytes >= 0)
|
||||
or (tail_lines is not None and tail_lines > 0)
|
||||
or (start_line is not None and line_count is not None)
|
||||
)
|
||||
if want_text:
|
||||
text: str
|
||||
if head_bytes is not None and head_bytes >= 0:
|
||||
text = raw[: head_bytes].decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
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])
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"text": text, "metadata": {"sha256": sha}},
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"metadata": {"sha256": sha, "lengthBytes": length}},
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
|
@ -320,10 +340,10 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
ctx: Context | None = None,
|
||||
ignore_case: bool | None = True,
|
||||
project_root: str | None = None,
|
||||
max_results: int | None = 200,
|
||||
max_results: int | None = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Searches a file with a regex pattern and returns line numbers and excerpts.
|
||||
Searches a file with a regex pattern and returns match positions only.
|
||||
- uri: unity://path/Assets/... or file path form supported by read_resource
|
||||
- pattern: regular expression (Python re)
|
||||
- ignore_case: case-insensitive by default
|
||||
|
|
@ -345,8 +365,17 @@ def register_resource_tools(mcp: FastMCP) -> None:
|
|||
results = []
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if rx.search(line):
|
||||
results.append({"line": i, "text": line})
|
||||
m = rx.search(line)
|
||||
if m:
|
||||
start_col, end_col = m.span()
|
||||
results.append(
|
||||
{
|
||||
"startLine": i,
|
||||
"startCol": start_col + 1,
|
||||
"endLine": i,
|
||||
"endCol": end_col + 1,
|
||||
}
|
||||
)
|
||||
if max_results and len(results) >= max_results:
|
||||
break
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "com.coplaydev.unity-mcp",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys, json, re
|
||||
|
||||
def summarize(txt):
|
||||
try:
|
||||
obj = json.loads(txt)
|
||||
except Exception:
|
||||
return f"tool_result: {len(txt)} bytes"
|
||||
data = obj.get("data", {}) or {}
|
||||
msg = obj.get("message") or obj.get("status") or ""
|
||||
# Common tool shapes
|
||||
if "sha256" in str(data):
|
||||
ln = data.get("lengthBytes") or data.get("length") or ""
|
||||
return f"len={ln}".strip()
|
||||
if "diagnostics" in data:
|
||||
diags = data["diagnostics"] or []
|
||||
w = sum(d.get("severity","" ).lower()=="warning" for d in diags)
|
||||
e = sum(d.get("severity","" ).lower() in ("error","fatal") for d in diags)
|
||||
ok = "OK" if not e else "FAIL"
|
||||
return f"validate: {ok} (warnings={w}, errors={e})"
|
||||
if "matches" in data:
|
||||
m = data["matches"] or []
|
||||
if m:
|
||||
first = m[0]
|
||||
return f"find_in_file: {len(m)} match(es) first@{first.get('line',0)}:{first.get('col',0)}"
|
||||
return "find_in_file: 0 matches"
|
||||
if "lines" in data: # console
|
||||
lines = data["lines"] or []
|
||||
lvls = {"info":0,"warning":0,"error":0}
|
||||
for L in lines:
|
||||
lvls[L.get("level","" ).lower()] = lvls.get(L.get("level","" ).lower(),0)+1
|
||||
return f"console: {len(lines)} lines (info={lvls.get('info',0)},warn={lvls.get('warning',0)},err={lvls.get('error',0)})"
|
||||
# Fallback: short status
|
||||
return (msg or "tool_result")[:80]
|
||||
|
||||
def prune_message(msg):
|
||||
if "content" not in msg: return msg
|
||||
newc=[]
|
||||
for c in msg["content"]:
|
||||
if c.get("type")=="tool_result" and c.get("content"):
|
||||
out=[]
|
||||
for chunk in c["content"]:
|
||||
if chunk.get("type")=="text":
|
||||
out.append({"type":"text","text":summarize(chunk.get("text","" ))})
|
||||
newc.append({"type":"tool_result","tool_use_id":c.get("tool_use_id"),"content":out})
|
||||
else:
|
||||
newc.append(c)
|
||||
msg["content"]=newc
|
||||
return msg
|
||||
|
||||
def main():
|
||||
convo=json.load(sys.stdin)
|
||||
if isinstance(convo, dict) and "messages" in convo:
|
||||
convo["messages"]=[prune_message(m) for m in convo["messages"]]
|
||||
elif isinstance(convo, list):
|
||||
convo=[prune_message(m) for m in convo]
|
||||
json.dump(convo, sys.stdout, ensure_ascii=False)
|
||||
main()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
missing=()
|
||||
for id in 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; do
|
||||
[[ -s "reports/${id}_results.xml" ]] || missing+=("$id")
|
||||
done
|
||||
if (( ${#missing[@]} )); then
|
||||
echo "Missing fragments: ${missing[*]}"
|
||||
exit 2
|
||||
fi
|
||||
echo "All NL/T fragments present."
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import sys
|
||||
import pathlib
|
||||
import importlib.util
|
||||
import types
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src"
|
||||
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):
|
||||
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_find_in_file_returns_positions(resource_tools, tmp_path):
|
||||
proj = tmp_path
|
||||
assets = proj / "Assets"
|
||||
assets.mkdir()
|
||||
f = assets / "A.txt"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
find_in_file = resource_tools["find_in_file"]
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
resp = loop.run_until_complete(
|
||||
find_in_file(uri="unity://path/Assets/A.txt", pattern="world", ctx=None, project_root=str(proj))
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
assert resp["success"] is True
|
||||
assert resp["data"]["matches"] == [{"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}]
|
||||
|
|
@ -71,4 +71,5 @@ def test_get_sha_param_shape_and_routing(monkeypatch):
|
|||
assert captured["params"]["name"] == "A"
|
||||
assert captured["params"]["path"].endswith("Assets/Scripts")
|
||||
assert resp["success"] is True
|
||||
assert resp["data"] == {"sha256": "abc", "lengthBytes": 1}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
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_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
|
||||
|
||||
read_console_mod = _load_module(SRC / "tools" / "read_console.py", "read_console_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()
|
||||
read_console_mod.register_read_console_tools(mcp)
|
||||
return mcp.tools
|
||||
|
||||
def test_read_console_full_default(monkeypatch):
|
||||
tools = setup_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_send(cmd, params):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
|
||||
monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object())
|
||||
|
||||
resp = read_console(ctx=None, count=10)
|
||||
assert resp == {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace", "time": "t"}]},
|
||||
}
|
||||
assert captured["params"]["count"] == 10
|
||||
assert captured["params"]["includeStacktrace"] is True
|
||||
|
||||
|
||||
def test_read_console_truncated(monkeypatch):
|
||||
tools = setup_tools()
|
||||
read_console = tools["read_console"]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_send(cmd, params):
|
||||
captured["params"] = params
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"lines": [{"level": "error", "message": "oops", "stacktrace": "trace"}]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send)
|
||||
monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object())
|
||||
|
||||
resp = read_console(ctx=None, count=10, include_stacktrace=False)
|
||||
assert resp == {"success": True, "data": {"lines": [{"level": "error", "message": "oops"}]}}
|
||||
assert captured["params"]["includeStacktrace"] is False
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import sys
|
||||
import pathlib
|
||||
import asyncio
|
||||
import types
|
||||
import pytest
|
||||
|
||||
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 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)
|
||||
|
||||
from tools.resource_tools import register_resource_tools # type: ignore
|
||||
|
||||
|
||||
class DummyMCP:
|
||||
def __init__(self):
|
||||
self.tools = {}
|
||||
|
||||
def tool(self, *args, **kwargs):
|
||||
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_read_resource_minimal_metadata_only(resource_tools, tmp_path):
|
||||
proj = tmp_path
|
||||
assets = proj / "Assets"
|
||||
assets.mkdir()
|
||||
f = assets / "A.txt"
|
||||
content = "hello world"
|
||||
f.write_text(content, encoding="utf-8")
|
||||
|
||||
read_resource = resource_tools["read_resource"]
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
resp = loop.run_until_complete(
|
||||
read_resource(uri="unity://path/Assets/A.txt", ctx=None, project_root=str(proj))
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
assert resp["success"] is True
|
||||
data = resp["data"]
|
||||
assert "text" not in data
|
||||
meta = data["metadata"]
|
||||
assert "sha256" in meta and len(meta["sha256"]) == 64
|
||||
assert meta["lengthBytes"] == len(content.encode("utf-8"))
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
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 similar to test_get_sha
|
||||
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_validate_script_returns_counts(monkeypatch):
|
||||
tools = setup_tools()
|
||||
validate_script = tools["validate_script"]
|
||||
|
||||
def fake_send(cmd, params):
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"diagnostics": [
|
||||
{"severity": "warning"},
|
||||
{"severity": "error"},
|
||||
{"severity": "fatal"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send)
|
||||
|
||||
resp = validate_script(None, uri="unity://path/Assets/Scripts/A.cs")
|
||||
assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
|
||||
|
||||
TIMEOUT = float(os.environ.get("MCP_STRESS_TIMEOUT", "2.0"))
|
||||
DEBUG = os.environ.get("MCP_STRESS_DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def dlog(*args):
|
||||
if DEBUG:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def find_status_files() -> list[Path]:
|
||||
home = Path.home()
|
||||
status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
|
||||
if not status_dir.exists():
|
||||
return []
|
||||
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
def discover_port(project_path: str | None) -> int:
|
||||
# Default bridge port if nothing found
|
||||
default_port = 6400
|
||||
files = find_status_files()
|
||||
for f in files:
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
port = int(data.get("unity_port", 0) or 0)
|
||||
proj = data.get("project_path") or ""
|
||||
if project_path:
|
||||
# Match status for the given project if possible
|
||||
if proj and project_path in proj:
|
||||
if 0 < port < 65536:
|
||||
return port
|
||||
else:
|
||||
if 0 < port < 65536:
|
||||
return port
|
||||
except Exception:
|
||||
pass
|
||||
return default_port
|
||||
|
||||
|
||||
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = await reader.read(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed while reading")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
async def read_frame(reader: asyncio.StreamReader) -> bytes:
|
||||
header = await read_exact(reader, 8)
|
||||
(length,) = struct.unpack(">Q", header)
|
||||
if length <= 0 or length > (64 * 1024 * 1024):
|
||||
raise ValueError(f"Invalid frame length: {length}")
|
||||
return await read_exact(reader, length)
|
||||
|
||||
|
||||
async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None:
|
||||
header = struct.pack(">Q", len(payload))
|
||||
writer.write(header)
|
||||
writer.write(payload)
|
||||
await asyncio.wait_for(writer.drain(), timeout=TIMEOUT)
|
||||
|
||||
|
||||
async def do_handshake(reader: asyncio.StreamReader) -> None:
|
||||
# Server sends a single line handshake: "WELCOME UNITY-MCP 1 FRAMING=1\n"
|
||||
line = await reader.readline()
|
||||
if not line or b"WELCOME UNITY-MCP" not in line:
|
||||
raise ConnectionError(f"Unexpected handshake from server: {line!r}")
|
||||
|
||||
|
||||
def make_ping_frame() -> bytes:
|
||||
return b"ping"
|
||||
|
||||
|
||||
def make_execute_menu_item(menu_path: str) -> bytes:
|
||||
# Retained for manual debugging; not used in normal stress runs
|
||||
payload = {"type": "execute_menu_item", "params": {"action": "execute", "menu_path": menu_path}}
|
||||
return json.dumps(payload).encode("utf-8")
|
||||
|
||||
|
||||
async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: dict):
|
||||
reconnect_delay = 0.2
|
||||
while time.time() < stop_time:
|
||||
writer = None
|
||||
try:
|
||||
# slight stagger to prevent burst synchronization across clients
|
||||
await asyncio.sleep(0.003 * (idx % 11))
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
|
||||
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
|
||||
# Send a quick ping first
|
||||
await write_frame(writer, make_ping_frame())
|
||||
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) # ignore content
|
||||
|
||||
# Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task.
|
||||
while time.time() < stop_time:
|
||||
# Ping-only; edits are sent via reload_churn_task to avoid console spam
|
||||
await write_frame(writer, make_ping_frame())
|
||||
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
||||
stats["pings"] += 1
|
||||
await asyncio.sleep(0.02 + random.uniform(-0.003, 0.003))
|
||||
|
||||
except (ConnectionError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError):
|
||||
stats["disconnects"] += 1
|
||||
dlog(f"[client {idx}] disconnect/backoff {reconnect_delay}s")
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 1.5, 2.0)
|
||||
continue
|
||||
except Exception:
|
||||
stats["errors"] += 1
|
||||
dlog(f"[client {idx}] unexpected error")
|
||||
await asyncio.sleep(0.2)
|
||||
continue
|
||||
finally:
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int, stats: dict, storm_count: int = 1):
|
||||
# Use script edit tool to touch a C# file, which triggers compilation reliably
|
||||
path = Path(unity_file) if unity_file else None
|
||||
seq = 0
|
||||
proj_root = Path(project_path).resolve() if project_path else None
|
||||
# Build candidate list for storm mode
|
||||
candidates: list[Path] = []
|
||||
if proj_root:
|
||||
try:
|
||||
for p in (proj_root / "Assets").rglob("*.cs"):
|
||||
candidates.append(p.resolve())
|
||||
except Exception:
|
||||
candidates = []
|
||||
if path and path.exists():
|
||||
rp = path.resolve()
|
||||
if rp not in candidates:
|
||||
candidates.append(rp)
|
||||
while time.time() < stop_time:
|
||||
try:
|
||||
if path and path.exists():
|
||||
# Determine files to touch this cycle
|
||||
targets: list[Path]
|
||||
if storm_count and storm_count > 1 and candidates:
|
||||
k = min(max(1, storm_count), len(candidates))
|
||||
targets = random.sample(candidates, k)
|
||||
else:
|
||||
targets = [path]
|
||||
|
||||
for tpath in targets:
|
||||
# Build a tiny ApplyTextEdits request that toggles a trailing comment
|
||||
relative = None
|
||||
try:
|
||||
# Derive Unity-relative path under Assets/ (cross-platform)
|
||||
resolved = tpath.resolve()
|
||||
parts = list(resolved.parts)
|
||||
if "Assets" in parts:
|
||||
i = parts.index("Assets")
|
||||
relative = Path(*parts[i:]).as_posix()
|
||||
elif proj_root and str(resolved).startswith(str(proj_root)):
|
||||
rel = resolved.relative_to(proj_root)
|
||||
parts2 = list(rel.parts)
|
||||
if "Assets" in parts2:
|
||||
i2 = parts2.index("Assets")
|
||||
relative = Path(*parts2[i2:]).as_posix()
|
||||
except Exception:
|
||||
relative = None
|
||||
|
||||
if relative:
|
||||
# Derive name and directory for ManageScript and compute precondition SHA + EOF position
|
||||
name_base = Path(relative).stem
|
||||
dir_path = str(Path(relative).parent).replace('\\', '/')
|
||||
|
||||
# 1) Read current contents via manage_script.read to compute SHA and true EOF location
|
||||
contents = None
|
||||
read_success = False
|
||||
for attempt in range(3):
|
||||
writer = None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
|
||||
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
|
||||
read_payload = {
|
||||
"type": "manage_script",
|
||||
"params": {
|
||||
"action": "read",
|
||||
"name": name_base,
|
||||
"path": dir_path
|
||||
}
|
||||
}
|
||||
await write_frame(writer, json.dumps(read_payload).encode("utf-8"))
|
||||
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
||||
|
||||
read_obj = json.loads(resp.decode("utf-8", errors="ignore"))
|
||||
result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {}
|
||||
if result.get("success"):
|
||||
data_obj = result.get("data", {})
|
||||
contents = data_obj.get("contents") or ""
|
||||
read_success = True
|
||||
break
|
||||
except Exception:
|
||||
# retry with backoff
|
||||
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
|
||||
finally:
|
||||
if 'writer' in locals() and writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not read_success or contents is None:
|
||||
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
|
||||
await asyncio.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Compute SHA and EOF insertion point
|
||||
import hashlib
|
||||
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
|
||||
lines = contents.splitlines(keepends=True)
|
||||
# Insert at true EOF (safe against header guards)
|
||||
end_line = len(lines) + 1 # 1-based exclusive end
|
||||
end_col = 1
|
||||
|
||||
# Build a unique marker append; ensure it begins with a newline if needed
|
||||
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
|
||||
seq += 1
|
||||
insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n"
|
||||
|
||||
# 2) Apply text edits with immediate refresh and precondition
|
||||
apply_payload = {
|
||||
"type": "manage_script",
|
||||
"params": {
|
||||
"action": "apply_text_edits",
|
||||
"name": name_base,
|
||||
"path": dir_path,
|
||||
"edits": [
|
||||
{
|
||||
"startLine": end_line,
|
||||
"startCol": end_col,
|
||||
"endLine": end_line,
|
||||
"endCol": end_col,
|
||||
"newText": insert_text
|
||||
}
|
||||
],
|
||||
"precondition_sha256": sha,
|
||||
"options": {"refresh": "immediate", "validate": "standard"}
|
||||
}
|
||||
}
|
||||
|
||||
apply_success = False
|
||||
for attempt in range(3):
|
||||
writer = None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
|
||||
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
|
||||
await write_frame(writer, json.dumps(apply_payload).encode("utf-8"))
|
||||
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
||||
try:
|
||||
data = json.loads(resp.decode("utf-8", errors="ignore"))
|
||||
result = data.get("result", data) if isinstance(data, dict) else {}
|
||||
ok = bool(result.get("success", False))
|
||||
if ok:
|
||||
stats["applies"] = stats.get("applies", 0) + 1
|
||||
apply_success = True
|
||||
break
|
||||
except Exception:
|
||||
# fall through to retry
|
||||
pass
|
||||
except Exception:
|
||||
# retry with backoff
|
||||
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
|
||||
finally:
|
||||
if 'writer' in locals() and writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
if not apply_success:
|
||||
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
async def main():
|
||||
ap = argparse.ArgumentParser(description="Stress test the Unity MCP bridge with concurrent clients and reload churn")
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--project", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests"))
|
||||
ap.add_argument("--unity-file", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs"))
|
||||
ap.add_argument("--clients", type=int, default=10)
|
||||
ap.add_argument("--duration", type=int, default=60)
|
||||
ap.add_argument("--storm-count", type=int, default=1, help="Number of scripts to touch each cycle")
|
||||
args = ap.parse_args()
|
||||
|
||||
port = discover_port(args.project)
|
||||
stop_time = time.time() + max(10, args.duration)
|
||||
|
||||
stats = {"pings": 0, "menus": 0, "mods": 0, "disconnects": 0, "errors": 0}
|
||||
tasks = []
|
||||
|
||||
# Spawn clients
|
||||
for i in range(max(1, args.clients)):
|
||||
tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats)))
|
||||
|
||||
# Spawn reload churn task
|
||||
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port, stats, storm_count=args.storm_count)))
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
print(json.dumps({"port": port, "stats": stats}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
Loading…
Reference in New Issue