diff --git a/.claude/prompts/nl-unity-claude-tests-mini.md b/.claude/prompts/nl-unity-claude-tests-mini.md new file mode 100644 index 00000000..35900b71 --- /dev/null +++ b/.claude/prompts/nl-unity-claude-tests-mini.md @@ -0,0 +1,45 @@ +# Unity NL Editing Suite — Natural Mode + +You are running inside CI for the **unity-mcp** repository. Your task is to demonstrate 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 `` per sub‑test; include `` 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 `` 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. diff --git a/.claude/prompts/nl-unity-suite-full.md b/.claude/prompts/nl-unity-suite-full.md new file mode 100644 index 00000000..a4cc1488 --- /dev/null +++ b/.claude/prompts/nl-unity-suite-full.md @@ -0,0 +1,133 @@ +# 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 + +--- + +## 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 `` XML fragment per test to `reports/_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` +- **Precise ranges / atomic batch**: `mcp__unity__apply_text_edits` (non‑overlapping ranges) +- **Validation**: `mcp__unity__validate_script(level:"standard")` +- **Reporting**: `Write` small XML fragments to `reports/*_results.xml` +- **Snapshot/Restore**: `Bash(scripts/nlt-revert.sh:*)` + +> Don’t use `mcp__unity__create_script`. Avoid the header/`using` region entirely. + +--- +### Structured edit ops (required usage) +- For method insertion anchored after `GetCurrentTarget`: use `script_apply_edits` with `{"op":"anchor_insert", "afterMethodName":"GetCurrentTarget", "text": ""}` +- To delete the temporary helper (T‑A/T‑E): **do not** use `anchor_replace`. Prefer: + 1) `script_apply_edits` with `{"op":"regex_replace", "pattern":"(?s)^\\s*private\\s+int\\s+__TempHelper\\s*\\(.*?\\)\\s*=>\\s*.*?;\\s*\\r?\\n", "replacement":""}` + 2) If that returns `missing_field` or `bad_request`, fallback to `apply_text_edits` with a single `replace_range` computed from the method’s start/end offsets (found by scanning braces). +- If any write returns `missing_field`, `bad_request`, or `unsupported`: **write the testcase fragment anyway** with the error in ``, mark `VERDICT: FAIL`, then **restore** and proceed to the next test. +- Never call generic Bash like `mkdir`; the revert helper creates needed directories. Do not attempt directory creation; use only `scripts/nlt-revert.sh` for snapshot/restore. + + +## Output Rules (JUnit fragments only) +- For each test, create **one** file: `reports/_results.xml` containing exactly a single ` ... `. +- Put human‑readable lines (PLAN/PROGRESS/evidence) **inside** ``. +- 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. + +**Example fragment** +```xml + + +... evidence windows ... +VERDICT: PASS +]]> + + +``` + +Note: Emit the PLAN line only in NL‑0 (do not repeat it for later tests or later `` blocks). + + +### 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")`. +- The helper creates parent directories for the snapshot path if missing. +- 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: + - Call `mcp__unity__read_resource(uri, project_root, ctx)` and set `buf = res.text` and `pre_sha = res.sha256` (server‑computed over raw on‑disk bytes). +- Write with `precondition_sha256 = pre_sha`. +- On `{status:"stale_file"}`: + - Retry once using a server hash (`data.current_sha256` or `data.expected_sha256`) if present. + - Otherwise perform one `read_resource(...)` to refresh `pre_sha` and retry. No loops. +- After success: + - Prefer not to re‑read. Update `buf` locally; refresh `pre_sha = mcp__unity__read_resource(...).sha256` only when the next step requires exact on‑disk sync (validation, anchor recompute) or before leaving the test. +- Prefer anchors (`script_apply_edits`) for end‑of‑class / above‑method insertions. Keep edits inside method bodies. Avoid header/using. + +### 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). +- NL‑0 must include the PLAN line (len=15). +- After each testcase, include `PROGRESS: /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. +- T‑C. Header/region preservation — Edit interior of `ApplyBlend`; preserve signature/docs/regions; restore. +- 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 call: two small `replace_range` + one end‑of‑class comment; all‑or‑nothing; restore. +- T‑G. Path normalization — Same edit with `unity://path/Assets/...` then `Assets/...`; second returns `{status:"no_change"}`. +- T‑H. Validation — `standard` after edits; `basic` only for transient checks. +- T‑I. Failure surfaces — Record INFO on `{too_large}`, `{stale_file}`, overlap rejection, validation failure, `{using_guard}`. +- T‑J. Idempotency — Repeat `replace_range` → `{status:"no_change"}`; repeat delete → no‑op. + +### Status & Reporting + +- Safeguard statuses are non‑fatal; record and continue. +- End each testcase `` with `VERDICT: PASS` or `VERDICT: FAIL`. \ No newline at end of file diff --git a/.claude/prompts/nl-unity-suite.md b/.claude/prompts/nl-unity-suite.md deleted file mode 100644 index 8d934939..00000000 --- a/.claude/prompts/nl-unity-suite.md +++ /dev/null @@ -1,103 +0,0 @@ -# CLAUDE TASK: Run NL/T editing tests for Unity MCP repo and emit JUnit - -You are running in CI at the repository root. Use only the tools that are allowed by the workflow: -- View, GlobTool, GrepTool for reading. -- Bash for local shell (git is allowed). -- BatchTool for grouping. -- MCP tools from server "unity" (exposed as mcp__unity__*). - -## Test target -- Primary file: `ClaudeTests/longUnityScript-claudeTest.cs` -- For each operation, prefer structured edit tools (`replace_method`, `insert_method`, `delete_method`, `anchor_insert`, `apply_text_edits`, `regex_replace`) via the MCP server. -- Include `precondition_sha256` for any text path write. - -## Output requirements -- Create a JUnit XML at `reports/claude-nl-tests.xml`. -- Each test = one `` with `classname="UnityMCP.NL"` or `UnityMCP.T`. -- On failure, include a `` node with a concise message and the last evidence snippet (10–20 lines). -- Also write a human summary at `reports/claude-nl-tests.md` with checkboxes and the windowed reads. - -## Safety & hygiene -- Make edits in-place, then revert them at the end (`git stash -u`/`git reset --hard` or balanced counter-edits) so the workspace is clean for subsequent steps. -- Never push commits from CI. -- If a write fails midway, ensure the file is restored before proceeding. - -## NL-0. Sanity Reads (windowed) -- Tail 120 lines of `ClaudeTests/longUnityScript-claudeTest.cs`. -- Show 40 lines around method `Update`. -- **Pass** if both windows render with expected anchors present. - -## NL-1. Method replace/insert/delete (natural-language) -- Replace `HasTarget` with block-bodied version returning `currentTarget != null`. -- Insert `PrintSeries()` after `GetCurrentTarget` logging `1,2,3`. -- Verify by reading 20 lines around the anchor. -- Delete `PrintSeries()` and verify removal. -- **Pass** if diffs match and verification windows show expected content. - -## NL-2. Anchor comment insertion -- Add a comment `Build marker OK` immediately above the `Update` method. -- **Pass** if the comment appears directly above the `public void Update()` line. - -## NL-3. End-of-class insertion -- Insert a 3-line comment `Tail test A/B/C` before the last method or immediately before the final class brace (preview, then apply). -- **Pass** if windowed read shows the three lines at the intended location. - -## NL-4. Compile trigger -- After any NL edit, ensure no stale compiler errors: - - Write a short marker edit, then **revert** after validating. - - The CI job will run Unity compile separately; record your local check (e.g., file parity and syntax sanity) as INFO, but do not attempt to invoke Unity here. - -## T-A. Anchor insert (text path) -- Insert after `GetCurrentTarget`: `private int __TempHelper(int a, int b) => a + b;` -- Verify via read; then delete with a `regex_replace` targeting only that helper block. -- **Pass** if round-trip leaves the file exactly as before. - -## T-B. Replace method body with minimal range -- Identify `HasTarget` body lines; single `replace_range` to change only inside braces; then revert. -- **Pass** on exact-range change + revert. - -## T-C. Header/region preservation -- For `ApplyBlend`, change only interior lines via `replace_range`; the method signature and surrounding `#region`/`#endregion` markers must remain untouched. -- **Pass** if signature and region markers unchanged. - -## T-D. End-of-class insertion (anchor) -- Find final class brace; `position: before` to append a temporary helper; then remove. -- **Pass** if insert/remove verified. - -## T-E. Temporary method lifecycle -- Insert helper (T-A), update helper implementation via `apply_text_edits`, then delete with `regex_replace`. -- **Pass** if lifecycle completes and file returns to original checksum. - -## T-F. Multi-edit atomic batch -- In one call, perform two `replace_range` tweaks and one comment insert at the class end; verify all-or-nothing behavior. -- **Pass** if either all 3 apply or none. - -## T-G. Path normalization -- Run the same edit once with `unity://path/ClaudeTests/longUnityScript-claudeTest.cs` and once with `ClaudeTests/longUnityScript-claudeTest.cs` (if supported). -- **Pass** if both target the same file and no path duplication. - -## T-H. Validation levels -- After edits, run `validate` with `level: "standard"`, then `"basic"` for temporarily unbalanced text ops; final state must be valid. -- **Pass** if validation OK and final file compiles in CI step. - -## T-I. Failure surfaces (expected) -- Too large payload: `apply_text_edits` with >15 KB aggregate → expect `{status:"too_large"}`. -- Stale file: change externally, then resend with old `precondition_sha256` → expect `{status:"stale_file"}` with hashes. -- Overlap: two overlapping ranges → expect rejection. -- Unbalanced braces: remove a closing `}` → expect validation failure and **no write**. -- Header guard: attempt insert before the first `using` → expect `{status:"header_guard"}`. -- Anchor aliasing: `insert`/`content` alias → expect success (aliased to `text`). -- Auto-upgrade: try a text edit overwriting a method header → prefer structured `replace_method` or return a clear error. -- **Pass** when each negative case returns the expected failure without persisting changes. - -## T-J. Idempotency & no-op -- Re-run the same `replace_range` with identical content → expect success with no change. -- Re-run a delete of an already-removed helper via `regex_replace` → clean no-op. -- **Pass** if both behave idempotently. - -### Implementation notes -- Always capture pre- and post‑windows (±20–40 lines) as evidence in the JUnit `` or as ``. -- For any file write, include `precondition_sha256` and verify the post‑hash in your log. -- At the end, restore the repository to its original state (`git status` must be clean). - -# Emit the JUnit file to reports/claude-nl-tests.xml and a summary markdown to reports/claude-nl-tests.md. diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py new file mode 100755 index 00000000..dc06a020 --- /dev/null +++ b/.github/scripts/mark_skipped.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Post-processes a JUnit XML so that "expected"/environmental failures +(e.g., permission prompts, empty MCP resources, or schema hiccups) +are converted to . Leaves real failures intact. + +Usage: + python .github/scripts/mark_skipped.py reports/claude-nl-tests.xml +""" + +from __future__ import annotations +import sys +import os +import re +import xml.etree.ElementTree as ET + +PATTERNS = [ + r"\bpermission\b", + r"\bpermissions\b", + r"\bautoApprove\b", + r"\bapproval\b", + r"\bdenied\b", + r"requested\s+permissions", + r"^MCP resources list is empty$", + r"No MCP resources detected", + r"aggregator.*returned\s*\[\s*\]", + r"Unknown resource:\s*unity://", + r"Input should be a valid dictionary.*ctx", + r"validation error .* ctx", +] + +def should_skip(msg: str) -> bool: + if not msg: + return False + msg_l = msg.strip() + for pat in PATTERNS: + if re.search(pat, msg_l, flags=re.IGNORECASE | re.MULTILINE): + return True + return False + +def summarize_counts(ts): + tests = 0 + failures = 0 + errors = 0 + skipped = 0 + for case in ts.findall("testcase"): + tests += 1 + if case.find("failure") is not None: + failures += 1 + if case.find("error") is not None: + errors += 1 + if case.find("skipped") is not None: + skipped += 1 + return tests, failures, errors, skipped + +def main(path: str) -> int: + if not os.path.exists(path): + print(f"[mark_skipped] No JUnit at {path}; nothing to do.") + return 0 + + try: + tree = ET.parse(path) + except ET.ParseError as e: + print(f"[mark_skipped] Could not parse {path}: {e}") + return 0 + + root = tree.getroot() + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + + changed = False + for ts in suites: + for case in list(ts.findall("testcase")): + for node_name in ("failure", "error"): + node = case.find(node_name) + if node is None: + continue + msg = (node.get("message") or "") + "\n" + (node.text or "") + if should_skip(msg): + # Replace with + reason = "Marked skipped: environment/permission precondition not met" + case.remove(node) + skip = ET.SubElement(case, "skipped") + skip.set("message", reason) + skip.text = (node.text or "").strip() or reason + changed = True + break # only one conversion per case + + # Recompute tallies per testsuite + tests, failures, errors, skipped = summarize_counts(ts) + ts.set("tests", str(tests)) + ts.set("failures", str(failures)) + ts.set("errors", str(errors)) + ts.set("skipped", str(skipped)) + + if changed: + tree.write(path, encoding="utf-8", xml_declaration=True) + print(f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") + else: + print(f"[mark_skipped] No environmental failures detected in {path}.") + + return 0 + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "reports/claude-nl-tests.xml" + raise SystemExit(main(target)) diff --git a/.github/workflows/claude-desktop-parity.yml b/.github/workflows/claude-desktop-parity.yml deleted file mode 100644 index 569062b5..00000000 --- a/.github/workflows/claude-desktop-parity.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Unity MCP — Desktop Parity - -on: - workflow_dispatch: {} - -jobs: - desktop-parity: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v4 - - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Run Claude (desktop-parity) - uses: anthropics/claude-code-base-action@beta - with: - # Use the same model Desktop uses today in your logs - model: claude-3-7-sonnet-20250219 - - # Let it actually think & iterate like Desktop - max_turns: 60 - timeout_minutes: 15 - - # Give it the standard code tools + your Unity MCP tools - allowed_tools: > - Bash(git:*),Read,Write,Edit,MultiEdit,LS,Glob,Grep,BashOutput,KillBash, - mcp__unity__* - - # Prevent planner/subagent drift that doesn't carry MCP tools - disallowed_tools: TodoWrite - - # Keep the exact MCP wiring you already use - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": ["run","--directory","UnityMcpBridge/UnityMcpServer~/src","python","server.py"], - "transport": { "type": "stdio" }, - "env": { "PYTHONUNBUFFERED": "1", "MCP_LOG_LEVEL": "debug" } - } - } - } - - # Map Desktop’s “click allow” to CI: auto-approve only what you intend - settings: | - { - "permissionMode": "allow", - "defaultMode": "bypassPermissions", - "permissionStorage": "none", - "autoApprove": ["Bash","Write","Edit","MultiEdit","mcp__unity__*"] - } - - prompt_file: .claude/prompts/nl-unity-suite.md diff --git a/.github/workflows/claude-nl-suite-mini.yml b/.github/workflows/claude-nl-suite-mini.yml new file mode 100644 index 00000000..272e04d6 --- /dev/null +++ b/.github/workflows/claude-nl-suite-mini.yml @@ -0,0 +1,356 @@ +name: Claude Mini NL Test Suite (Unity live) + +on: + workflow_dispatch: {} + +permissions: + contents: read + checks: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + UNITY_VERSION: 2021.3.45f1 + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 + UNITY_CACHE_ROOT: /home/runner/work/_temp/_github_home + +jobs: + nl-suite: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md + + steps: + # ---------- Detect secrets ---------- + - name: Detect secrets (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -e + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ---------- Python env for MCP server (uv) ---------- + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install MCP server + run: | + set -eux + uv venv + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found (skipping)" + fi + + # ---------- License prime on host (handles ULF or EBL) ---------- + - name: Prime Unity license on host (GameCI) + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + unityVersion: ${{ env.UNITY_VERSION }} + + # (Optional) Show where the license actually got written + - name: Inspect GameCI license caches (host) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true + + # ---------- Clean any stale MCP status from previous runs ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + + # ---------- Start headless Unity that stays up (bridge enabled) ---------- + - name: Start Unity (persistent bridge) + if: steps.detect.outputs.unity_ok == 'true' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; failing fast." + exit 1 + fi + mkdir -p "$HOME/.unity-mcp" + MANUAL_ARG=() + if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) + fi + EBL_ARGS=() + [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") + [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") + [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") + docker rm -f unity-mcp >/dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${MANUAL_ARG[@]}" \ + "${EBL_ARGS[@]}" \ + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + + # ---------- Wait for Unity bridge (fail fast if not running/ready) ---------- + - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container failed to start"; docker ps -a || true; exit 1 + fi + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! + deadline=$((SECONDS+420)); READY=0 + try_connect_host() { + P="$1" + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true + if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi + return 1 + } + + # in-container probe will try IPv4 then IPv6 via nc or /dev/tcp + + while [ $SECONDS -lt $deadline ]; do + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + READY=1; echo "Bridge ready (log markers)"; break + fi + PORT=$(python -c "import os,glob,json,sys,time; b=os.path.expanduser('~/.unity-mcp'); fs=sorted(glob.glob(os.path.join(b,'unity-mcp-status-*.json')), key=os.path.getmtime, reverse=True); print(next((json.load(open(f,'r',encoding='utf-8')).get('unity_port') for f in fs if time.time()-os.path.getmtime(f)<=300 and json.load(open(f,'r',encoding='utf-8')).get('unity_port')), '' ))" 2>/dev/null || true) + if [ -n "${PORT:-}" ] && { try_connect_host "$PORT" || docker exec unity-mcp bash -lc "timeout 1 bash -lc 'exec 3<>/dev/tcp/127.0.0.1/$PORT' || (command -v nc >/dev/null 2>&1 && nc -6 -z ::1 $PORT)"; }; then + READY=1; echo "Bridge ready on port $PORT"; break + fi + if docker logs unity-mcp 2>&1 | grep -qE "No valid Unity Editor license|Token not found in cache|com\.unity\.editor\.headless"; then + echo "Licensing error detected"; break + fi + sleep 2 + done + + kill $LOGPID || true + + if [ "$READY" != "1" ]; then + echo "Bridge not ready; diagnostics:" + echo "== status files =="; ls -la "$HOME/.unity-mcp" || true + echo "== status contents =="; for f in "$HOME"/.unity-mcp/unity-mcp-status-*.json; do [ -f "$f" ] && { echo "--- $f"; sed -n '1,120p' "$f"; }; done + echo "== sockets (inside container) =="; docker exec unity-mcp bash -lc 'ss -lntp || netstat -tulpen || true' + echo "== tail of Unity log ==" + docker logs --tail 200 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + exit 1 + fi + + # ---------- Make MCP config available to the action ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json < 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' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > 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 diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 6b3b2950..660538ac 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,134 +1,507 @@ -name: Claude NL suite + (optional) Unity compile +name: Claude NL/T Full Suite (Unity live) on: - workflow_dispatch: {} - + workflow_dispatch: {} + permissions: - contents: write # allow Claude to write test artifacts - pull-requests: write # allow annotations / comments - issues: write - + contents: read + checks: write + concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - + 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 - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Python + uv for the Unity MCP server - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - - name: Install UnityMcpServer deps - run: | - set -eux - if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then - - uv venv - . .venv/bin/activate - uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" - elif [ -f "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" ]; then + nl-suite: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + JUNIT_OUT: reports/junit-nl-suite.xml + MD_OUT: reports/junit-nl-suite.md + + steps: + # ---------- Secrets check ---------- + - name: Detect secrets (outputs) + id: detect + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -e + if [ -n "$ANTHROPIC_API_KEY" ]; then echo "anthropic_ok=true" >> "$GITHUB_OUTPUT"; else echo "anthropic_ok=false" >> "$GITHUB_OUTPUT"; fi + if [ -n "$UNITY_LICENSE" ] || { [ -n "$UNITY_EMAIL" ] && [ -n "$UNITY_PASSWORD" ]; } || [ -n "$UNITY_SERIAL" ]; then + echo "unity_ok=true" >> "$GITHUB_OUTPUT" + else + echo "unity_ok=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ---------- Python env for MCP server (uv) ---------- + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + + - name: Install MCP server + run: | + set -eux uv venv - . .venv/bin/activate - uv pip install -r "UnityMcpBridge/UnityMcpServer~/src/requirements.txt" - else - echo "No Python deps found (skipping)" - - fi - - - name: Run Claude NL/T test suite - id: claude - uses: anthropics/claude-code-base-action@beta - with: - # Test instructions live here - prompt_file: .claude/prompts/nl-unity-suite.md - - # Tight tool allowlist - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool,mcp__unity__*" - - # MCP server path (matches your screenshots) - mcp_config: | + echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" + echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" + if [ -f UnityMcpBridge/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/src + elif [ -f UnityMcpBridge/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/src/requirements.txt + elif [ -f UnityMcpBridge/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e UnityMcpBridge/UnityMcpServer~/ + elif [ -f UnityMcpBridge/UnityMcpServer~/requirements.txt ]; then + uv pip install -r UnityMcpBridge/UnityMcpServer~/requirements.txt + else + echo "No MCP Python deps found (skipping)" + fi + + # ---------- License prime on host (GameCI) ---------- + - name: Prime Unity license on host (GameCI) + if: steps.detect.outputs.unity_ok == 'true' + uses: game-ci/unity-test-runner@v4 + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + with: + projectPath: TestProjects/UnityMCPTests + testMode: EditMode + customParameters: -runTests -testFilter __NoSuchTest__ -batchmode -nographics + unityVersion: ${{ env.UNITY_VERSION }} + + # (Optional) Inspect license caches + - name: Inspect GameCI license caches (host) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -eux + find "${{ env.UNITY_CACHE_ROOT }}" -maxdepth 4 \( -path "*/.cache" -prune -o -type f \( -name '*.ulf' -o -name 'user.json' \) -print \) 2>/dev/null || true + + # ---------- Clean old MCP status ---------- + - name: Clean old MCP status + run: | + set -eux + mkdir -p "$HOME/.unity-mcp" + rm -f "$HOME/.unity-mcp"/unity-mcp-status-*.json || true + + # ---------- Start headless Unity (persistent bridge) ---------- + - name: Start Unity (persistent bridge) + if: steps.detect.outputs.unity_ok == 'true' + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + if [ ! -d "${{ github.workspace }}/TestProjects/UnityMCPTests/ProjectSettings" ]; then + echo "Unity project not found; failing fast." + exit 1 + fi + mkdir -p "$HOME/.unity-mcp" + MANUAL_ARG=() + if [ -f "${UNITY_CACHE_ROOT}/.local/share/unity3d/Unity_lic.ulf" ]; then + MANUAL_ARG=(-manualLicenseFile /root/.local/share/unity3d/Unity_lic.ulf) + fi + EBL_ARGS=() + [ -n "${UNITY_SERIAL:-}" ] && EBL_ARGS+=(-serial "$UNITY_SERIAL") + [ -n "${UNITY_EMAIL:-}" ] && EBL_ARGS+=(-username "$UNITY_EMAIL") + [ -n "${UNITY_PASSWORD:-}" ] && EBL_ARGS+=(-password "$UNITY_PASSWORD") + docker rm -f unity-mcp >/dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "${{ env.UNITY_CACHE_ROOT }}:/root" \ + -v "$HOME/.unity-mcp:/root/.unity-mcp" \ + ${{ env.UNITY_IMAGE }} /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${MANUAL_ARG[@]}" \ + "${EBL_ARGS[@]}" \ + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + + # ---------- Wait for Unity bridge ---------- + - name: Wait for Unity bridge (robust) + if: steps.detect.outputs.unity_ok == 'true' + run: | + set -euo pipefail + if ! docker ps --format '{{.Names}}' | grep -qx 'unity-mcp'; then + echo "Unity container failed to start"; docker ps -a || true; exit 1 + fi + docker logs -f unity-mcp 2>&1 | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' & LOGPID=$! + deadline=$((SECONDS+420)); READY=0 + try_connect_host() { + P="$1" + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$P; head -c 8 <&3 >/dev/null" && return 0 || true + if command -v nc >/dev/null 2>&1; then nc -6 -z ::1 "$P" && return 0 || true; fi + return 1 + } + while [ $SECONDS -lt $deadline ]; do + if docker logs unity-mcp 2>&1 | grep -qE "MCP Bridge listening|Bridge ready|Server started"; then + READY=1; echo "Bridge ready (log markers)"; break + fi + PORT=$(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 + + # ---------- MCP client config ---------- + - name: Write MCP config (.claude/mcp.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/mcp.json <> "$GITHUB_OUTPUT"; else echo "has_license=false" >> "$GITHUB_OUTPUT"; fi - if [ -f "ProjectSettings/ProjectVersion.txt" ]; then echo "is_project=true" >> "$GITHUB_OUTPUT"; else echo "is_project=false" >> "$GITHUB_OUTPUT"; fi - if [ -f "Packages/manifest.json" ] && [ ! -f "ProjectSettings/ProjectVersion.txt" ]; then echo "is_package=true" >> "$GITHUB_OUTPUT"; else echo "is_package=false" >> "$GITHUB_OUTPUT"; fi - - # --- Optional: Unity compile after Claude’s edits (satisfies NL-4) --- - - name: Unity compile (Project) - if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_project == 'true' - uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - with: - projectPath: . - githubToken: ${{ secrets.GITHUB_TOKEN }} - testMode: EditMode - - - name: Unity compile (Package) - if: always() && steps.detect.outputs.has_license == 'true' && steps.detect.outputs.is_package == 'true' - uses: game-ci/unity-test-runner@v4 - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - with: - packageMode: true - unityVersion: 2022.3.45f1 # set your exact version - projectPath: . - githubToken: ${{ secrets.GITHUB_TOKEN }} - - - name: Clean working tree (discard temp edits) - if: always() - run: | - git restore -SW :/ - git clean -fd + JSON + + # ---------- Reports & helper ---------- + - name: Prepare reports and dirs + run: | + set -eux + rm -f reports/*.xml reports/*.md || true + mkdir -p reports reports/_snapshots scripts + + - name: Create report skeletons + run: | + set -eu + cat > "$JUNIT_OUT" <<'XML' + + + + Bootstrap placeholder; suite will append real tests. + + + XML + printf '# Unity NL/T Editing Suite Test Results\n\n' > "$MD_OUT" + + - name: Write safe revert helper (scripts/nlt-revert.sh) + shell: bash + run: | + set -eux + cat > scripts/nlt-revert.sh <<'BASH' + #!/usr/bin/env bash + set -euo pipefail + sub="${1:-}"; target_rel="${2:-}"; snap="${3:-}" + WS="${GITHUB_WORKSPACE:-$PWD}" + ROOT="$WS/TestProjects/UnityMCPTests" + t_abs="$(realpath -m "$WS/$target_rel")" + s_abs="$(realpath -m "$WS/$snap")" + if [[ "$t_abs" != "$ROOT/Assets/"* ]]; then + echo "refuse: target outside allowed scope: $t_abs" >&2; exit 2 + fi + mkdir -p "$(dirname "$s_abs")" + case "$sub" in + snapshot) + cp -f "$t_abs" "$s_abs" + sha=$(sha256sum "$s_abs" | awk '{print $1}') + echo "snapshot_sha=$sha" + ;; + restore) + if [[ ! -f "$s_abs" ]]; then echo "snapshot missing: $s_abs" >&2; exit 3; fi + cp -f "$s_abs" "$t_abs" + touch "$t_abs" + sha=$(sha256sum "$t_abs" | awk '{print $1}') + echo "restored_sha=$sha" + ;; + *) + echo "usage: $0 snapshot|restore " >&2; exit 1 + ;; + esac + BASH + chmod +x scripts/nlt-revert.sh + + # ---------- Run suite ---------- + - name: Run Claude NL suite (single pass) + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + with: + use_node_cache: false + prompt_file: .claude/prompts/nl-unity-suite-full.md + mcp_config: .claude/mcp.json + allowed_tools: >- + 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 + disallowed_tools: TodoWrite,Task + model: claude-3-7-sonnet-latest + timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # ---------- Merge testcase fragments into JUnit ---------- + - name: Normalize/assemble JUnit in-place (single file) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import re, os + def localname(tag: str) -> str: return tag.rsplit('}', 1)[-1] if '}' in tag else tag + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + if not src.exists(): raise SystemExit(0) + tree = ET.parse(src); root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + if suite is None: raise SystemExit(0) + fragments = sorted(Path('reports').glob('*_results.xml')) + added = 0 + for frag in fragments: + try: + froot = ET.parse(frag).getroot() + if localname(froot.tag) == 'testcase': + suite.append(froot); added += 1 + else: + for tc in froot.findall('.//testcase'): + suite.append(tc); added += 1 + except Exception: + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + for m in re.findall(r'', txt, flags=re.DOTALL): + try: suite.append(ET.fromstring(m)); added += 1 + except Exception: pass + if added: + # If we added real testcases, drop the bootstrap placeholder and recompute counts + removed_bootstrap = 0 + for tc in list(suite.findall('.//testcase')): + name = (tc.get('name') or '') + fail = tc.find('failure') + if name == 'NL-Suite.Bootstrap' and (added > 0): + suite.remove(tc) + removed_bootstrap += 1 + # Recompute suite attributes + testcases = suite.findall('.//testcase') + tests_cnt = len(testcases) + failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) + suite.set('tests', str(tests_cnt)) + suite.set('failures', str(failures_cnt)) + suite.set('errors', str(0)) + suite.set('skipped', str(0)) + tree.write(src, encoding='utf-8', xml_declaration=True) + print(f"Added {added} testcase fragments, removed bootstrap={removed_bootstrap}, tests={tests_cnt}, failures={failures_cnt}") + PY + + # ---------- Markdown summary from JUnit ---------- + - name: Build markdown summary from JUnit + if: always() + shell: bash + run: | + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + import os + 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')) + if not src.exists(): + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8'); raise SystemExit(0) + tree = ET.parse(src); root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + cases = [] if suite is None else [tc for tc in suite.findall('.//testcase')] + total = len(cases) + failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) + passed = total - failures + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] + name_to_case = {(tc.get('name') or ''): tc for tc in cases} + def status_for(prefix: str): + for name, tc in name_to_case.items(): + if name.startswith(prefix): + return not ((tc.find('failure') is not None) or (tc.find('error') is not None)) + return None + lines = [] + lines += ['# Unity NL/T Editing Suite Test Results','',f'Totals: {passed} passed, {failures} failed, {total} total','', '## Test Checklist'] + for p in desired: + st = status_for(p) + lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) + lines.append('') + # Rich per-test details (pull from ) + lines.append('## Test Details') + # Sort by canonical order where possible + def order_key(n: str): + try: + if n.startswith('NL-') and n[3].isdigit(): + return (0, int(n.split('.')[0].split('-')[1])) + except Exception: + pass + if n.startswith('T-') and len(n)>2 and n[2].isalpha(): + return (1, ord(n[2])) + return (2, n) + for name in sorted(name_to_case.keys(), key=order_key): + tc = name_to_case[name] + so = tc.find('system-out') + text = '' if so is None or so.text is None else so.text + text = text.replace('\r\n','\n') + # Trim overly long outputs + MAX_CHARS = 2000 + if len(text) > MAX_CHARS: + text = text[:MAX_CHARS] + "\n…(truncated)" + lines.append(f"### {name}") + if text.strip(): + lines.append('```') + lines.append(text.strip()) + lines.append('```') + else: + lines.append('(no system-out)') + lines.append('') + for name, tc in name_to_case.items(): + node = tc.find('failure') or tc.find('error') + if node is None: continue + msg = (node.get('message') or '').strip() + text = (node.text or '').strip() + lines.append(f"### {name}") + if msg: lines.append(f"- Message: {msg}") + if text: lines.append(f"- Detail: {text.splitlines()[0][:500]}") + lines.append('') + md_out.write_text('\n'.join(lines), encoding='utf-8') + PY + + - name: "Debug: list report files" + if: always() + shell: bash + run: | + set -eux + ls -la reports || true + shopt -s nullglob + for f in reports/*.xml; do + echo "===== $f =====" + head -n 40 "$f" || true + done + + # ---------- Collect execution transcript (if present) ---------- + - name: Collect action execution transcript + if: always() + shell: bash + run: | + set -eux + if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then + cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json + elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then + cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json + fi + + - name: Sanitize markdown (normalize newlines) + if: always() + run: | + set -eu + 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 — Summary" >> $GITHUB_STEP_SUMMARY + python - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + p = Path('reports/junit-nl-suite.md') + if p.exists(): + text = p.read_bytes().decode('utf-8', 'replace') + MAX = 65000 + print(text[:MAX]) + if len(text) > MAX: + print("\n\n_…truncated; full report in artifacts._") + else: + print("_No markdown report found._") + PY + + - name: Fallback JUnit if missing + if: always() + run: | + set -eu + mkdir -p reports + if [ ! -f "$JUNIT_OUT" ]; then + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "$JUNIT_OUT" + fi + + - name: Publish JUnit report + if: always() + uses: mikepenz/action-junit-report@v5 + with: + report_paths: '${{ env.JUNIT_OUT }}' + include_passed: true + detailed_summary: true + annotate_notice: true + require_tests: false + fail_on_parse_error: true + + - name: Upload artifacts (reports + fragments + transcript) + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-nl-suite-artifacts + path: | + ${{ env.JUNIT_OUT }} + ${{ env.MD_OUT }} + reports/*_results.xml + reports/claude-execution-output.json + retention-days: 7 + + # ---------- Always stop Unity ---------- + - name: Stop Unity + if: always() + run: | + docker logs --tail 400 unity-mcp | sed -E 's/((serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + docker rm -f unity-mcp || true + \ No newline at end of file diff --git a/.github/workflows/unity-mcp-smoke.yml b/.github/workflows/unity-mcp-smoke.yml deleted file mode 100644 index 50f4c802..00000000 --- a/.github/workflows/unity-mcp-smoke.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Unity MCP — Smoke - -on: - workflow_dispatch: {} - -permissions: - contents: read - -jobs: - smoke: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@v4 - - # Minimal Python + uv so the server can run in its own project env - - name: Install Python + uv - uses: astral-sh/setup-uv@v4 - with: - python-version: '3.11' - - # Optional but cheap: set up a venv and install the server in editable mode - - name: Install UnityMcpServer (editable) - run: | - set -eux - if [ -f "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" ]; then - uv venv - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> "$GITHUB_ENV" - echo "$GITHUB_WORKSPACE/.venv/bin" >> "$GITHUB_PATH" - uv pip install -e "UnityMcpBridge/UnityMcpServer~/src" - fi - - - name: Make prompt file (smoke) - run: | - mkdir -p .claude/prompts - cat > .claude/prompts/mcp-smoke.md <<'MD' - You are running a one-shot smoke check. Do exactly one Unity MCP tool call and nothing else. - - 1) Call the tool named: mcp__unity__list_resources - 2) Use this exact JSON input: - { - "ctx": {}, // IMPORTANT: ctx must be a dictionary (not a string) - "under": "ClaudeTests", // keep it local/fast - "pattern": "*.cs" - } - 3) Print the raw tool result to the console. Do not transform it. - 4) If the call raises a validation error, print the exception type and message exactly. - 5) Stop. - MD - - - name: Run smoke (single tool call) - uses: anthropics/claude-code-base-action@beta - with: - prompt_file: .claude/prompts/mcp-smoke.md - # Only allow the one Unity tool needed for the smoke (no wildcards) - allowed_tools: mcp__unity__list_resources - # Start the Unity MCP server via stdio using uv - mcp_config: | - { - "mcpServers": { - "unity": { - "command": "uv", - "args": [ - "run", - "--active", - "--directory", - "UnityMcpBridge/UnityMcpServer~/src", - "python", - "server.py" - ], - "transport": { "type": "stdio" }, - "env": { - "PYTHONUNBUFFERED": "1", - "MCP_LOG_LEVEL": "debug" - } - } - } - } - # Remove permission friction; the agent only has one tool anyway - settings: | - { - "permissionMode": "allow", - "autoApprove": ["mcp__unity__list_resources"], - "defaultMode": "bypassPermissions", - "permissionStorage": "none" - } - model: claude-3-7-sonnet-20250219 - max_turns: 3 - timeout_minutes: 3 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.gitignore b/.gitignore index 4ede4e8b..0e2cbb03 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ CONTRIBUTING.md.meta .idea/ .vscode/ .aider* -.DS_Store* \ No newline at end of file +.DS_Store* +# Unity test project lock files +TestProjects/UnityMCPTests/Packages/packages-lock.json diff --git a/README-DEV.md b/README-DEV.md index eac08193..debcffc7 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -66,6 +66,41 @@ To find it reliably: Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. +## CI Test Workflow (GitHub Actions) + +We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge. + +- Trigger: Workflow dispatch (`Claude NL suite (Unity live)`). +- Image: `UNITY_IMAGE` (UnityCI) pulled by tag; the job resolves a digest at runtime. Logs are sanitized. +- Reports: JUnit at `reports/junit-nl-suite.xml`, Markdown at `reports/junit-nl-suite.md`. +- Publishing: JUnit is normalized to `reports/junit-for-actions.xml` and published; artifacts upload all files under `reports/`. + +### Test target script +- The repo includes a long, standalone C# script used to exercise larger edits and windows: + - `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` + Use this file locally and in CI to validate multi-edit batches, anchor inserts, and windowed reads on a sizable script. + +### Add a new NL test +- Edit `.claude/prompts/nl-unity-claude-tests-mini.md` (or `nl-unity-suite-full.md` for the larger suite). +- Follow the conventions: single `` root, one `` per sub-test, end system-out with `VERDICT: PASS|FAIL`. +- Keep edits minimal and reversible; include evidence windows and compact diffs. + +### Run the suite +1) Push your branch, then manually run the workflow from the Actions tab. +2) The job writes reports into `reports/` and uploads artifacts. +3) The “JUnit Test Report” check summarizes results; open the Job Summary for full markdown. + +### View results +- Job Summary: inline markdown summary of the run on the Actions tab in GitHub +- Check: “JUnit Test Report” on the PR/commit. +- Artifacts: `claude-nl-suite-artifacts` includes XML and MD. + + +### MCP Connection Debugging +- *Enable debug logs* in the Unity MCP window (inside the Editor) to view connection status, auto-setup results, and MCP client paths. It shows: + - bridge startup/port, client connections, strict framing negotiation, and parsed frames + - auto-config path detection (Windows/macOS/Linux), uv/claude resolution, and surfaced errors +- In CI, the job tails Unity logs (redacted for serial/license/password/token) and prints socket/status JSON diagnostics if startup fails. ## Workflow 1. **Make changes** to your source code in this directory diff --git a/README.md b/README.md index c3082f74..ae5e02dd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ MCP for Unity acts as a bridge, allowing AI assistants (like Claude, Cursor) to * `manage_shader`: Performs shader CRUD operations (create, read, modify, delete). * `manage_gameobject`: Manages GameObjects: create, modify, delete, find, and component operations. * `execute_menu_item`: Executes a menu item via its path (e.g., "File/Save Project"). + * `apply_text_edits`: Precise text edits with precondition hashes and atomic multi-edit batches. + * `script_apply_edits`: Structured C# method/class edits (insert/replace/delete) with safer boundaries. + * `validate_script`: Fast validation (basic/standard) to catch syntax/structure issues before/after writes. --- diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs new file mode 100644 index 00000000..c40b5371 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs @@ -0,0 +1,2039 @@ +using UnityEngine; +using System.Collections.Generic; + +// Standalone, dependency-free long script for Claude NL/T editing tests. +// Intentionally verbose to simulate a complex gameplay script without external packages. +public class LongUnityScriptClaudeTest : MonoBehaviour +{ + [Header("Core References")] + public Transform reachOrigin; + public Animator animator; + + [Header("State")] + private Transform currentTarget; + private Transform previousTarget; + private float lastTargetFoundTime; + + [Header("Held Objects")] + private readonly List heldObjects = new List(); + + // Accumulators used by padding methods to avoid complete no-ops + private int padAccumulator = 0; + private Vector3 padVector = Vector3.zero; + + + [Header("Tuning")] + public float maxReachDistance = 2f; + public float maxHorizontalDistance = 1.0f; + public float maxVerticalDistance = 1.0f; + + // Public accessors used by NL tests + public bool HasTarget() { return currentTarget != null; } + public Transform GetCurrentTarget() => currentTarget; + + // Simple selection logic (self-contained) + private Transform FindBestTarget() + { + if (reachOrigin == null) return null; + // Dummy: prefer previously seen target within distance + if (currentTarget && Vector3.Distance(reachOrigin.position, currentTarget.position) <= maxReachDistance) + return currentTarget; + return null; + } + + private void HandleTargetSwitch(Transform next) + { + if (next == currentTarget) return; + previousTarget = currentTarget; + currentTarget = next; + lastTargetFoundTime = Time.time; + } + + private void LateUpdate() + { + // Keep file long with harmless per-frame work + if (currentTarget == null && previousTarget != null) + { + // decay previous reference over time + if (Time.time - lastTargetFoundTime > 0.5f) previousTarget = null; + } + } + + // NL tests sometimes add comments above Update() as an anchor + public void Update() + { + if (reachOrigin == null) return; + var best = FindBestTarget(); + if (best != null) HandleTargetSwitch(best); + } + + + // Dummy reach/hold API (no external deps) + public void OnObjectHeld(Transform t) + { + if (t == null) return; + if (!heldObjects.Contains(t)) heldObjects.Add(t); + animator?.SetInteger("objectsHeld", heldObjects.Count); + } + + public void OnObjectPlaced() + { + if (heldObjects.Count == 0) return; + heldObjects.RemoveAt(heldObjects.Count - 1); + animator?.SetInteger("objectsHeld", heldObjects.Count); + } + + // More padding: repetitive blocks with slight variations + #region Padding Blocks + private Vector3 AccumulateBlend(Transform t) + { + if (t == null || reachOrigin == null) return Vector3.zero; + Vector3 local = reachOrigin.InverseTransformPoint(t.position); + float bx = Mathf.Clamp(local.x / Mathf.Max(0.001f, maxHorizontalDistance), -1f, 1f); + float by = Mathf.Clamp(local.y / Mathf.Max(0.001f, maxVerticalDistance), -1f, 1f); + return new Vector3(bx, by, 0f); + } + + private void ApplyBlend(Vector3 blend) + { + if (animator == null) return; + animator.SetFloat("reachX", blend.x); + animator.SetFloat("reachY", blend.y); + } + + public void TickBlendOnce() + { + var b = AccumulateBlend(currentTarget); + ApplyBlend(b); + } + + // A long series of small no-op methods to bulk up the file without adding deps + private void Step001() { } + private void Step002() { } + private void Step003() { } + private void Step004() { } + private void Step005() { } + private void Step006() { } + private void Step007() { } + private void Step008() { } + private void Step009() { } + private void Step010() { } + private void Step011() { } + private void Step012() { } + private void Step013() { } + private void Step014() { } + private void Step015() { } + private void Step016() { } + private void Step017() { } + private void Step018() { } + private void Step019() { } + private void Step020() { } + private void Step021() { } + private void Step022() { } + private void Step023() { } + private void Step024() { } + private void Step025() { } + private void Step026() { } + private void Step027() { } + private void Step028() { } + private void Step029() { } + private void Step030() { } + private void Step031() { } + private void Step032() { } + private void Step033() { } + private void Step034() { } + private void Step035() { } + private void Step036() { } + private void Step037() { } + private void Step038() { } + private void Step039() { } + private void Step040() { } + private void Step041() { } + private void Step042() { } + private void Step043() { } + private void Step044() { } + private void Step045() { } + private void Step046() { } + private void Step047() { } + private void Step048() { } + private void Step049() { } + private void Step050() { } + #endregion + #region MassivePadding + private void Pad0051() + { + } + private void Pad0052() + { + } + private void Pad0053() + { + } + private void Pad0054() + { + } + private void Pad0055() + { + } + private void Pad0056() + { + } + private void Pad0057() + { + } + private void Pad0058() + { + } + private void Pad0059() + { + } + private void Pad0060() + { + } + private void Pad0061() + { + } + private void Pad0062() + { + } + private void Pad0063() + { + } + private void Pad0064() + { + } + private void Pad0065() + { + } + private void Pad0066() + { + } + private void Pad0067() + { + } + private void Pad0068() + { + } + private void Pad0069() + { + } + private void Pad0070() + { + } + private void Pad0071() + { + } + private void Pad0072() + { + } + private void Pad0073() + { + } + private void Pad0074() + { + } + private void Pad0075() + { + } + private void Pad0076() + { + } + private void Pad0077() + { + } + private void Pad0078() + { + } + private void Pad0079() + { + } + private void Pad0080() + { + } + private void Pad0081() + { + } + private void Pad0082() + { + } + private void Pad0083() + { + } + private void Pad0084() + { + } + private void Pad0085() + { + } + private void Pad0086() + { + } + private void Pad0087() + { + } + private void Pad0088() + { + } + private void Pad0089() + { + } + private void Pad0090() + { + } + private void Pad0091() + { + } + private void Pad0092() + { + } + private void Pad0093() + { + } + private void Pad0094() + { + } + private void Pad0095() + { + } + private void Pad0096() + { + } + private void Pad0097() + { + } + private void Pad0098() + { + } + private void Pad0099() + { + } + private void Pad0100() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 100) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0101() + { + } + private void Pad0102() + { + } + private void Pad0103() + { + } + private void Pad0104() + { + } + private void Pad0105() + { + } + private void Pad0106() + { + } + private void Pad0107() + { + } + private void Pad0108() + { + } + private void Pad0109() + { + } + private void Pad0110() + { + } + private void Pad0111() + { + } + private void Pad0112() + { + } + private void Pad0113() + { + } + private void Pad0114() + { + } + private void Pad0115() + { + } + private void Pad0116() + { + } + private void Pad0117() + { + } + private void Pad0118() + { + } + private void Pad0119() + { + } + private void Pad0120() + { + } + private void Pad0121() + { + } + private void Pad0122() + { + } + private void Pad0123() + { + } + private void Pad0124() + { + } + private void Pad0125() + { + } + private void Pad0126() + { + } + private void Pad0127() + { + } + private void Pad0128() + { + } + private void Pad0129() + { + } + private void Pad0130() + { + } + private void Pad0131() + { + } + private void Pad0132() + { + } + private void Pad0133() + { + } + private void Pad0134() + { + } + private void Pad0135() + { + } + private void Pad0136() + { + } + private void Pad0137() + { + } + private void Pad0138() + { + } + private void Pad0139() + { + } + private void Pad0140() + { + } + private void Pad0141() + { + } + private void Pad0142() + { + } + private void Pad0143() + { + } + private void Pad0144() + { + } + private void Pad0145() + { + } + private void Pad0146() + { + } + private void Pad0147() + { + } + private void Pad0148() + { + } + private void Pad0149() + { + } + private void Pad0150() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 150) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0151() + { + } + private void Pad0152() + { + } + private void Pad0153() + { + } + private void Pad0154() + { + } + private void Pad0155() + { + } + private void Pad0156() + { + } + private void Pad0157() + { + } + private void Pad0158() + { + } + private void Pad0159() + { + } + private void Pad0160() + { + } + private void Pad0161() + { + } + private void Pad0162() + { + } + private void Pad0163() + { + } + private void Pad0164() + { + } + private void Pad0165() + { + } + private void Pad0166() + { + } + private void Pad0167() + { + } + private void Pad0168() + { + } + private void Pad0169() + { + } + private void Pad0170() + { + } + private void Pad0171() + { + } + private void Pad0172() + { + } + private void Pad0173() + { + } + private void Pad0174() + { + } + private void Pad0175() + { + } + private void Pad0176() + { + } + private void Pad0177() + { + } + private void Pad0178() + { + } + private void Pad0179() + { + } + private void Pad0180() + { + } + private void Pad0181() + { + } + private void Pad0182() + { + } + private void Pad0183() + { + } + private void Pad0184() + { + } + private void Pad0185() + { + } + private void Pad0186() + { + } + private void Pad0187() + { + } + private void Pad0188() + { + } + private void Pad0189() + { + } + private void Pad0190() + { + } + private void Pad0191() + { + } + private void Pad0192() + { + } + private void Pad0193() + { + } + private void Pad0194() + { + } + private void Pad0195() + { + } + private void Pad0196() + { + } + private void Pad0197() + { + } + private void Pad0198() + { + } + private void Pad0199() + { + } + private void Pad0200() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 200) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0201() + { + } + private void Pad0202() + { + } + private void Pad0203() + { + } + private void Pad0204() + { + } + private void Pad0205() + { + } + private void Pad0206() + { + } + private void Pad0207() + { + } + private void Pad0208() + { + } + private void Pad0209() + { + } + private void Pad0210() + { + } + private void Pad0211() + { + } + private void Pad0212() + { + } + private void Pad0213() + { + } + private void Pad0214() + { + } + private void Pad0215() + { + } + private void Pad0216() + { + } + private void Pad0217() + { + } + private void Pad0218() + { + } + private void Pad0219() + { + } + private void Pad0220() + { + } + private void Pad0221() + { + } + private void Pad0222() + { + } + private void Pad0223() + { + } + private void Pad0224() + { + } + private void Pad0225() + { + } + private void Pad0226() + { + } + private void Pad0227() + { + } + private void Pad0228() + { + } + private void Pad0229() + { + } + private void Pad0230() + { + } + private void Pad0231() + { + } + private void Pad0232() + { + } + private void Pad0233() + { + } + private void Pad0234() + { + } + private void Pad0235() + { + } + private void Pad0236() + { + } + private void Pad0237() + { + } + private void Pad0238() + { + } + private void Pad0239() + { + } + private void Pad0240() + { + } + private void Pad0241() + { + } + private void Pad0242() + { + } + private void Pad0243() + { + } + private void Pad0244() + { + } + private void Pad0245() + { + } + private void Pad0246() + { + } + private void Pad0247() + { + } + private void Pad0248() + { + } + private void Pad0249() + { + } + private void Pad0250() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 250) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0251() + { + } + private void Pad0252() + { + } + private void Pad0253() + { + } + private void Pad0254() + { + } + private void Pad0255() + { + } + private void Pad0256() + { + } + private void Pad0257() + { + } + private void Pad0258() + { + } + private void Pad0259() + { + } + private void Pad0260() + { + } + private void Pad0261() + { + } + private void Pad0262() + { + } + private void Pad0263() + { + } + private void Pad0264() + { + } + private void Pad0265() + { + } + private void Pad0266() + { + } + private void Pad0267() + { + } + private void Pad0268() + { + } + private void Pad0269() + { + } + private void Pad0270() + { + } + private void Pad0271() + { + } + private void Pad0272() + { + } + private void Pad0273() + { + } + private void Pad0274() + { + } + private void Pad0275() + { + } + private void Pad0276() + { + } + private void Pad0277() + { + } + private void Pad0278() + { + } + private void Pad0279() + { + } + private void Pad0280() + { + } + private void Pad0281() + { + } + private void Pad0282() + { + } + private void Pad0283() + { + } + private void Pad0284() + { + } + private void Pad0285() + { + } + private void Pad0286() + { + } + private void Pad0287() + { + } + private void Pad0288() + { + } + private void Pad0289() + { + } + private void Pad0290() + { + } + private void Pad0291() + { + } + private void Pad0292() + { + } + private void Pad0293() + { + } + private void Pad0294() + { + } + private void Pad0295() + { + } + private void Pad0296() + { + } + private void Pad0297() + { + } + private void Pad0298() + { + } + private void Pad0299() + { + } + private void Pad0300() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 300) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0301() + { + } + private void Pad0302() + { + } + private void Pad0303() + { + } + private void Pad0304() + { + } + private void Pad0305() + { + } + private void Pad0306() + { + } + private void Pad0307() + { + } + private void Pad0308() + { + } + private void Pad0309() + { + } + private void Pad0310() + { + } + private void Pad0311() + { + } + private void Pad0312() + { + } + private void Pad0313() + { + } + private void Pad0314() + { + } + private void Pad0315() + { + } + private void Pad0316() + { + } + private void Pad0317() + { + } + private void Pad0318() + { + } + private void Pad0319() + { + } + private void Pad0320() + { + } + private void Pad0321() + { + } + private void Pad0322() + { + } + private void Pad0323() + { + } + private void Pad0324() + { + } + private void Pad0325() + { + } + private void Pad0326() + { + } + private void Pad0327() + { + } + private void Pad0328() + { + } + private void Pad0329() + { + } + private void Pad0330() + { + } + private void Pad0331() + { + } + private void Pad0332() + { + } + private void Pad0333() + { + } + private void Pad0334() + { + } + private void Pad0335() + { + } + private void Pad0336() + { + } + private void Pad0337() + { + } + private void Pad0338() + { + } + private void Pad0339() + { + } + private void Pad0340() + { + } + private void Pad0341() + { + } + private void Pad0342() + { + } + private void Pad0343() + { + } + private void Pad0344() + { + } + private void Pad0345() + { + } + private void Pad0346() + { + } + private void Pad0347() + { + } + private void Pad0348() + { + } + private void Pad0349() + { + } + private void Pad0350() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 350) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0351() + { + } + private void Pad0352() + { + } + private void Pad0353() + { + } + private void Pad0354() + { + } + private void Pad0355() + { + } + private void Pad0356() + { + } + private void Pad0357() + { + } + private void Pad0358() + { + } + private void Pad0359() + { + } + private void Pad0360() + { + } + private void Pad0361() + { + } + private void Pad0362() + { + } + private void Pad0363() + { + } + private void Pad0364() + { + } + private void Pad0365() + { + } + private void Pad0366() + { + } + private void Pad0367() + { + } + private void Pad0368() + { + } + private void Pad0369() + { + } + private void Pad0370() + { + } + private void Pad0371() + { + } + private void Pad0372() + { + } + private void Pad0373() + { + } + private void Pad0374() + { + } + private void Pad0375() + { + } + private void Pad0376() + { + } + private void Pad0377() + { + } + private void Pad0378() + { + } + private void Pad0379() + { + } + private void Pad0380() + { + } + private void Pad0381() + { + } + private void Pad0382() + { + } + private void Pad0383() + { + } + private void Pad0384() + { + } + private void Pad0385() + { + } + private void Pad0386() + { + } + private void Pad0387() + { + } + private void Pad0388() + { + } + private void Pad0389() + { + } + private void Pad0390() + { + } + private void Pad0391() + { + } + private void Pad0392() + { + } + private void Pad0393() + { + } + private void Pad0394() + { + } + private void Pad0395() + { + } + private void Pad0396() + { + } + private void Pad0397() + { + } + private void Pad0398() + { + } + private void Pad0399() + { + } + private void Pad0400() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 400) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0401() + { + } + private void Pad0402() + { + } + private void Pad0403() + { + } + private void Pad0404() + { + } + private void Pad0405() + { + } + private void Pad0406() + { + } + private void Pad0407() + { + } + private void Pad0408() + { + } + private void Pad0409() + { + } + private void Pad0410() + { + } + private void Pad0411() + { + } + private void Pad0412() + { + } + private void Pad0413() + { + } + private void Pad0414() + { + } + private void Pad0415() + { + } + private void Pad0416() + { + } + private void Pad0417() + { + } + private void Pad0418() + { + } + private void Pad0419() + { + } + private void Pad0420() + { + } + private void Pad0421() + { + } + private void Pad0422() + { + } + private void Pad0423() + { + } + private void Pad0424() + { + } + private void Pad0425() + { + } + private void Pad0426() + { + } + private void Pad0427() + { + } + private void Pad0428() + { + } + private void Pad0429() + { + } + private void Pad0430() + { + } + private void Pad0431() + { + } + private void Pad0432() + { + } + private void Pad0433() + { + } + private void Pad0434() + { + } + private void Pad0435() + { + } + private void Pad0436() + { + } + private void Pad0437() + { + } + private void Pad0438() + { + } + private void Pad0439() + { + } + private void Pad0440() + { + } + private void Pad0441() + { + } + private void Pad0442() + { + } + private void Pad0443() + { + } + private void Pad0444() + { + } + private void Pad0445() + { + } + private void Pad0446() + { + } + private void Pad0447() + { + } + private void Pad0448() + { + } + private void Pad0449() + { + } + private void Pad0450() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 450) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0451() + { + } + private void Pad0452() + { + } + private void Pad0453() + { + } + private void Pad0454() + { + } + private void Pad0455() + { + } + private void Pad0456() + { + } + private void Pad0457() + { + } + private void Pad0458() + { + } + private void Pad0459() + { + } + private void Pad0460() + { + } + private void Pad0461() + { + } + private void Pad0462() + { + } + private void Pad0463() + { + } + private void Pad0464() + { + } + private void Pad0465() + { + } + private void Pad0466() + { + } + private void Pad0467() + { + } + private void Pad0468() + { + } + private void Pad0469() + { + } + private void Pad0470() + { + } + private void Pad0471() + { + } + private void Pad0472() + { + } + private void Pad0473() + { + } + private void Pad0474() + { + } + private void Pad0475() + { + } + private void Pad0476() + { + } + private void Pad0477() + { + } + private void Pad0478() + { + } + private void Pad0479() + { + } + private void Pad0480() + { + } + private void Pad0481() + { + } + private void Pad0482() + { + } + private void Pad0483() + { + } + private void Pad0484() + { + } + private void Pad0485() + { + } + private void Pad0486() + { + } + private void Pad0487() + { + } + private void Pad0488() + { + } + private void Pad0489() + { + } + private void Pad0490() + { + } + private void Pad0491() + { + } + private void Pad0492() + { + } + private void Pad0493() + { + } + private void Pad0494() + { + } + private void Pad0495() + { + } + private void Pad0496() + { + } + private void Pad0497() + { + } + private void Pad0498() + { + } + private void Pad0499() + { + } + private void Pad0500() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 500) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0501() + { + } + private void Pad0502() + { + } + private void Pad0503() + { + } + private void Pad0504() + { + } + private void Pad0505() + { + } + private void Pad0506() + { + } + private void Pad0507() + { + } + private void Pad0508() + { + } + private void Pad0509() + { + } + private void Pad0510() + { + } + private void Pad0511() + { + } + private void Pad0512() + { + } + private void Pad0513() + { + } + private void Pad0514() + { + } + private void Pad0515() + { + } + private void Pad0516() + { + } + private void Pad0517() + { + } + private void Pad0518() + { + } + private void Pad0519() + { + } + private void Pad0520() + { + } + private void Pad0521() + { + } + private void Pad0522() + { + } + private void Pad0523() + { + } + private void Pad0524() + { + } + private void Pad0525() + { + } + private void Pad0526() + { + } + private void Pad0527() + { + } + private void Pad0528() + { + } + private void Pad0529() + { + } + private void Pad0530() + { + } + private void Pad0531() + { + } + private void Pad0532() + { + } + private void Pad0533() + { + } + private void Pad0534() + { + } + private void Pad0535() + { + } + private void Pad0536() + { + } + private void Pad0537() + { + } + private void Pad0538() + { + } + private void Pad0539() + { + } + private void Pad0540() + { + } + private void Pad0541() + { + } + private void Pad0542() + { + } + private void Pad0543() + { + } + private void Pad0544() + { + } + private void Pad0545() + { + } + private void Pad0546() + { + } + private void Pad0547() + { + } + private void Pad0548() + { + } + private void Pad0549() + { + } + private void Pad0550() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 550) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0551() + { + } + private void Pad0552() + { + } + private void Pad0553() + { + } + private void Pad0554() + { + } + private void Pad0555() + { + } + private void Pad0556() + { + } + private void Pad0557() + { + } + private void Pad0558() + { + } + private void Pad0559() + { + } + private void Pad0560() + { + } + private void Pad0561() + { + } + private void Pad0562() + { + } + private void Pad0563() + { + } + private void Pad0564() + { + } + private void Pad0565() + { + } + private void Pad0566() + { + } + private void Pad0567() + { + } + private void Pad0568() + { + } + private void Pad0569() + { + } + private void Pad0570() + { + } + private void Pad0571() + { + } + private void Pad0572() + { + } + private void Pad0573() + { + } + private void Pad0574() + { + } + private void Pad0575() + { + } + private void Pad0576() + { + } + private void Pad0577() + { + } + private void Pad0578() + { + } + private void Pad0579() + { + } + private void Pad0580() + { + } + private void Pad0581() + { + } + private void Pad0582() + { + } + private void Pad0583() + { + } + private void Pad0584() + { + } + private void Pad0585() + { + } + private void Pad0586() + { + } + private void Pad0587() + { + } + private void Pad0588() + { + } + private void Pad0589() + { + } + private void Pad0590() + { + } + private void Pad0591() + { + } + private void Pad0592() + { + } + private void Pad0593() + { + } + private void Pad0594() + { + } + private void Pad0595() + { + } + private void Pad0596() + { + } + private void Pad0597() + { + } + private void Pad0598() + { + } + private void Pad0599() + { + } + private void Pad0600() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 600) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + private void Pad0601() + { + } + private void Pad0602() + { + } + private void Pad0603() + { + } + private void Pad0604() + { + } + private void Pad0605() + { + } + private void Pad0606() + { + } + private void Pad0607() + { + } + private void Pad0608() + { + } + private void Pad0609() + { + } + private void Pad0610() + { + } + private void Pad0611() + { + } + private void Pad0612() + { + } + private void Pad0613() + { + } + private void Pad0614() + { + } + private void Pad0615() + { + } + private void Pad0616() + { + } + private void Pad0617() + { + } + private void Pad0618() + { + } + private void Pad0619() + { + } + private void Pad0620() + { + } + private void Pad0621() + { + } + private void Pad0622() + { + } + private void Pad0623() + { + } + private void Pad0624() + { + } + private void Pad0625() + { + } + private void Pad0626() + { + } + private void Pad0627() + { + } + private void Pad0628() + { + } + private void Pad0629() + { + } + private void Pad0630() + { + } + private void Pad0631() + { + } + private void Pad0632() + { + } + private void Pad0633() + { + } + private void Pad0634() + { + } + private void Pad0635() + { + } + private void Pad0636() + { + } + private void Pad0637() + { + } + private void Pad0638() + { + } + private void Pad0639() + { + } + private void Pad0640() + { + } + private void Pad0641() + { + } + private void Pad0642() + { + } + private void Pad0643() + { + } + private void Pad0644() + { + } + private void Pad0645() + { + } + private void Pad0646() + { + } + private void Pad0647() + { + } + private void Pad0648() + { + } + private void Pad0649() + { + } + private void Pad0650() + { + // lightweight math to give this padding method some substance + padAccumulator = (padAccumulator * 1664525 + 1013904223 + 650) & 0x7fffffff; + float t = (padAccumulator % 1000) * 0.001f; + padVector.x = Mathf.Lerp(padVector.x, t, 0.1f); + padVector.y = Mathf.Lerp(padVector.y, 1f - t, 0.1f); + padVector.z = 0f; + } + #endregion + +} + + diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta new file mode 100644 index 00000000..3d95d986 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dfbabf507ab1245178d1a8e745d8d283 \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Packages/packages-lock.json b/TestProjects/UnityMCPTests/Packages/packages-lock.json deleted file mode 100644 index 51cb01d4..00000000 --- a/TestProjects/UnityMCPTests/Packages/packages-lock.json +++ /dev/null @@ -1,417 +0,0 @@ -{ - "dependencies": { - "com.coplaydev.unity-mcp": { - "version": "file:../../../UnityMcpBridge", - "depth": 0, - "source": "local", - "dependencies": { - "com.unity.nuget.newtonsoft-json": "3.0.2" - } - }, - "com.unity.collab-proxy": { - "version": "2.5.2", - "depth": 0, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.editorcoroutines": { - "version": "1.0.0", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.ext.nunit": { - "version": "1.0.6", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.feature.development": { - "version": "1.0.1", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.ide.visualstudio": "2.0.22", - "com.unity.ide.rider": "3.0.31", - "com.unity.ide.vscode": "1.2.5", - "com.unity.editorcoroutines": "1.0.0", - "com.unity.performance.profile-analyzer": "1.2.2", - "com.unity.test-framework": "1.1.33", - "com.unity.testtools.codecoverage": "1.2.6" - } - }, - "com.unity.ide.rider": { - "version": "3.0.31", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ext.nunit": "1.0.6" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ide.visualstudio": { - "version": "2.0.22", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.test-framework": "1.1.9" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ide.vscode": { - "version": "1.2.5", - "depth": 0, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.ide.windsurf": { - "version": "https://github.com/Asuta/com.unity.ide.windsurf.git", - "depth": 0, - "source": "git", - "dependencies": { - "com.unity.test-framework": "1.1.9" - }, - "hash": "6161accf3e7beab96341813913e714c7e2fb5c5d" - }, - "com.unity.nuget.newtonsoft-json": { - "version": "3.2.1", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.performance.profile-analyzer": { - "version": "1.2.2", - "depth": 1, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.settings-manager": { - "version": "1.0.3", - "depth": 2, - "source": "registry", - "dependencies": {}, - "url": "https://packages.unity.com" - }, - "com.unity.test-framework": { - "version": "1.1.33", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ext.nunit": "1.0.6", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.testtools.codecoverage": { - "version": "1.2.6", - "depth": 1, - "source": "registry", - "dependencies": { - "com.unity.test-framework": "1.0.16", - "com.unity.settings-manager": "1.0.1" - }, - "url": "https://packages.unity.com" - }, - "com.unity.textmeshpro": { - "version": "3.0.6", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ugui": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.timeline": { - "version": "1.6.5", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.director": "1.0.0", - "com.unity.modules.animation": "1.0.0", - "com.unity.modules.particlesystem": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.ugui": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0" - } - }, - "com.unity.visualscripting": { - "version": "1.9.4", - "depth": 0, - "source": "registry", - "dependencies": { - "com.unity.ugui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - }, - "url": "https://packages.unity.com" - }, - "com.unity.modules.ai": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.androidjni": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.animation": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.assetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.audio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.cloth": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0" - } - }, - "com.unity.modules.director": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.animation": "1.0.0" - } - }, - "com.unity.modules.imageconversion": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.imgui": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.jsonserialize": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.particlesystem": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.physics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.physics2d": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.screencapture": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.subsystems": { - "version": "1.0.0", - "depth": 1, - "source": "builtin", - "dependencies": { - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.terrain": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.terrainphysics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.terrain": "1.0.0" - } - }, - "com.unity.modules.tilemap": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics2d": "1.0.0" - } - }, - "com.unity.modules.ui": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.uielements": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.uielementsnative": "1.0.0" - } - }, - "com.unity.modules.uielementsnative": { - "version": "1.0.0", - "depth": 1, - "source": "builtin", - "dependencies": { - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.imgui": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.umbra": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unityanalytics": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0" - } - }, - "com.unity.modules.unitywebrequest": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.unitywebrequestassetbundle": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestaudio": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.audio": "1.0.0" - } - }, - "com.unity.modules.unitywebrequesttexture": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.unitywebrequestwww": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.unitywebrequest": "1.0.0", - "com.unity.modules.unitywebrequestassetbundle": "1.0.0", - "com.unity.modules.unitywebrequestaudio": "1.0.0", - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.assetbundle": "1.0.0", - "com.unity.modules.imageconversion": "1.0.0" - } - }, - "com.unity.modules.vehicles": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0" - } - }, - "com.unity.modules.video": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.audio": "1.0.0", - "com.unity.modules.ui": "1.0.0", - "com.unity.modules.unitywebrequest": "1.0.0" - } - }, - "com.unity.modules.vr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.xr": "1.0.0" - } - }, - "com.unity.modules.wind": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": {} - }, - "com.unity.modules.xr": { - "version": "1.0.0", - "depth": 0, - "source": "builtin", - "dependencies": { - "com.unity.modules.physics": "1.0.0", - "com.unity.modules.jsonserialize": "1.0.0", - "com.unity.modules.subsystems": "1.0.0" - } - } - } -} diff --git a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json index ad11087f..3c7b4c18 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json +++ b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json @@ -1,6 +1,4 @@ { - "m_Name": "Settings", - "m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json", "m_Dictionary": { "m_DictionaryValues": [] } diff --git a/TestProjects/UnityMCPTests/ProjectSettings/boot.config b/TestProjects/UnityMCPTests/ProjectSettings/boot.config deleted file mode 100644 index e69de29b..00000000 diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index f52fa4ac..b341c0d7 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using MCPForUnity.Editor.Models; namespace MCPForUnity.Editor.Data @@ -18,6 +19,11 @@ public class McpClients ".cursor", "mcp.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", @@ -34,6 +40,10 @@ public class McpClients Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".claude.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json" @@ -51,6 +61,12 @@ public class McpClients "windsurf", "mcp_config.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codeium", + "windsurf", + "mcp_config.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codeium", @@ -69,6 +85,13 @@ public class McpClients "Claude", "claude_desktop_config.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", @@ -82,12 +105,23 @@ public class McpClients new() { name = "VSCode GitHub Copilot", + // Windows path is canonical under %AppData%\Code\User windowsConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User", "mcp.json" ), + // macOS: ~/Library/Application Support/Code/User/mcp.json + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "mcp.json" + ), + // Linux: ~/.config/Code/User/mcp.json linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", @@ -108,6 +142,12 @@ public class McpClients "settings", "mcp.json" ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".kiro", + "settings", + "mcp.json" + ), linuxConfigPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".kiro", diff --git a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs index 94ba5d97..5889e4f6 100644 --- a/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs +++ b/UnityMcpBridge/Editor/Helpers/ConfigJsonBuilder.cs @@ -50,7 +50,49 @@ public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPa private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) { unity["command"] = uvPath; - unity["args"] = JArray.FromObject(new[] { "run", "--directory", directory, "server.py" }); + + // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners + string effectiveDir = directory; +#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX + bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); + if (isCursor && !string.IsNullOrEmpty(directory)) + { + // Replace canonical path segment with the symlink path if present + const string canonical = "/Library/Application Support/"; + const string symlinkSeg = "/Library/AppSupport/"; + try + { + // Normalize to full path style + if (directory.Contains(canonical)) + { + var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } + } + else + { + // If installer returned XDG-style on macOS, map to canonical symlink + string norm = directory.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); + if (idx >= 0) + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... + string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } + } + } + } + catch { /* fallback to original directory on any error */ } + } +#endif + + unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); if (isVSCode) { diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs b/UnityMcpBridge/Editor/Helpers/McpLog.cs new file mode 100644 index 00000000..7e467187 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string Prefix = "MCP-FOR-UNITY:"; + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + Debug.Log($"{Prefix} {message}"); + } + + public static void Warn(string message) + { + Debug.LogWarning($"{Prefix} {message}"); + } + + public static void Error(string message) + { + Debug.LogError($"{Prefix} {message}"); + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 00000000..b9e0fc38 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs new file mode 100644 index 00000000..cb350d16 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -0,0 +1,93 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Auto-runs legacy/older install detection on package load/update (log-only). + /// Runs once per embedded server version using an EditorPrefs version-scoped key. + /// + [InitializeOnLoad] + public static class PackageDetector + { + private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; + + static PackageDetector() + { + try + { + string pkgVer = ReadPackageVersionOrFallback(); + string key = DetectOnceFlagKeyPrefix + pkgVer; + + // Always force-run if legacy roots exist or canonical install is missing + bool legacyPresent = LegacyRootsExist(); + bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); + + if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) + { + EditorApplication.delayCall += () => + { + try + { + ServerInstaller.EnsureServerInstalled(); + EditorPrefs.SetBool(key, true); + } + catch (System.Exception ex) + { + Debug.LogWarning("MCP for Unity: Auto-detect on load failed: " + ex.Message); + } + }; + } + } + catch { /* ignore */ } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); + if (System.IO.File.Exists(p)) + return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); + } + } + catch { } + return "unknown"; + } + + private static string ReadPackageVersionOrFallback() + { + try + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); + if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; + } + catch { } + // Fallback to embedded server version if package info unavailable + return ReadEmbeddedVersionOrFallback(); + } + + private static bool LegacyRootsExist() + { + try + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] roots = + { + System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), + System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") + }; + foreach (var r in roots) + { + try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } + } + } + catch { } + return false; + } + } +} + + diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta new file mode 100644 index 00000000..af305308 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b82eaef548d164ca095f17db64d15af8 \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs b/UnityMcpBridge/Editor/Helpers/Response.cs index 5d5436d7..1a3bd520 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs +++ b/UnityMcpBridge/Editor/Helpers/Response.cs @@ -35,10 +35,10 @@ public static object Success(string message, object data = null) /// /// Creates a standardized error response object. /// - /// A message describing the error. + /// A message describing the error. /// Optional additional data (e.g., error details) to include. /// An object representing the error response. - public static object Error(string errorMessage, object data = null) + public static object Error(string errorCodeOrMessage, object data = null) { if (data != null) { @@ -46,13 +46,16 @@ public static object Error(string errorMessage, object data = null) return new { success = false, - error = errorMessage, + // Preserve original behavior while adding a machine-parsable code field. + // If callers pass a code string, it will be echoed in both code and error. + code = errorCodeOrMessage, + error = errorCodeOrMessage, data = data, }; } else { - return new { success = false, error = errorMessage }; + return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; } } } diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index e32be859..f6ddeaf0 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -2,6 +2,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Text; +using System.Collections.Generic; using UnityEditor; using UnityEngine; @@ -11,6 +12,7 @@ public static class ServerInstaller { private const string RootFolder = "UnityMCP"; private const string ServerFolder = "UnityMcpServer"; + private const string VersionFileName = "server_version.txt"; /// /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. @@ -21,25 +23,74 @@ public static void EnsureServerInstalled() try { string saveLocation = GetSaveLocation(); + TryCreateMacSymlinkForAppSupport(); string destRoot = Path.Combine(saveLocation, ServerFolder); string destSrc = Path.Combine(destRoot, "src"); - if (File.Exists(Path.Combine(destSrc, "server.py"))) - { - return; // Already installed - } + // Detect legacy installs and version state (logs) + DetectAndLogLegacyInstallStates(destRoot); + // Resolve embedded source and versions if (!TryGetEmbeddedServerSource(out string embeddedSrc)) { throw new Exception("Could not find embedded UnityMcpServer/src in the package."); } + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); + + bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); + bool needOverwrite = !destHasServer + || string.IsNullOrEmpty(installedVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); // Ensure destination exists Directory.CreateDirectory(destRoot); - // Copy the entire UnityMcpServer folder (parent of src) - string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer - CopyDirectoryRecursive(embeddedRoot, destRoot); + if (needOverwrite) + { + // Copy the entire UnityMcpServer folder (parent of src) + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer + CopyDirectoryRecursive(embeddedRoot, destRoot); + // Write/refresh version file + try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } + McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); + } + + // Cleanup legacy installs that are missing version or older than embedded + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + try + { + string legacySrc = Path.Combine(legacyRoot, "src"); + if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + bool legacyOlder = string.IsNullOrEmpty(legacyVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); + if (legacyOlder) + { + TryKillUvForPath(legacySrc); + try + { + Directory.Delete(legacyRoot, recursive: true); + McpLog.Info($"Removed legacy server at '{legacyRoot}'."); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); + } + } + } + catch { } + } + + // Clear overrides that might point at legacy locations + try + { + EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); + EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); + } + catch { } + return; } catch (Exception ex) { @@ -49,11 +100,11 @@ public static void EnsureServerInstalled() if (hasInstalled || TryGetEmbeddedServerSource(out _)) { - Debug.LogWarning($"MCP for Unity: Using existing server; skipped install. Details: {ex.Message}"); + McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); return; } - Debug.LogError($"Failed to ensure server installation: {ex.Message}"); + McpLog.Error($"Failed to ensure server installation: {ex.Message}"); } } @@ -69,9 +120,10 @@ private static string GetSaveLocation() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + // Use per-user LocalApplicationData for canonical install location var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); - return Path.Combine(localAppData, "Programs", RootFolder); + return Path.Combine(localAppData, RootFolder); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { @@ -85,15 +137,60 @@ private static string GetSaveLocation() } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - // Use Application Support for a stable, user-writable location - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - RootFolder - ); + // On macOS, use LocalApplicationData (~/Library/Application Support) + var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support + bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); + if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) + { + // Fallback: construct from $HOME + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + localAppSupport = Path.Combine(home, "Library", "Application Support"); + } + TryCreateMacSymlinkForAppSupport(); + return Path.Combine(localAppSupport, RootFolder); } throw new Exception("Unsupported operating system."); } + /// + /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support + /// to mitigate arg parsing and quoting issues in some MCP clients. + /// Safe to call repeatedly. + /// + private static void TryCreateMacSymlinkForAppSupport() + { + try + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + if (string.IsNullOrEmpty(home)) return; + + string canonical = Path.Combine(home, "Library", "Application Support"); + string symlink = Path.Combine(home, "Library", "AppSupport"); + + // If symlink exists already, nothing to do + if (Directory.Exists(symlink) || File.Exists(symlink)) return; + + // Create symlink only if canonical exists + if (!Directory.Exists(canonical)) return; + + // Use 'ln -s' to create a directory symlink (macOS) + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/ln", + Arguments = $"-s \"{canonical}\" \"{symlink}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + p?.WaitForExit(2000); + } + catch { /* best-effort */ } + } + private static bool IsDirectoryWritable(string path) { try @@ -117,6 +214,173 @@ private static bool IsServerInstalled(string location) && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); } + /// + /// Detects legacy installs or older versions and logs findings (no deletion yet). + /// + private static void DetectAndLogLegacyInstallStates(string canonicalRoot) + { + try + { + string canonicalSrc = Path.Combine(canonicalRoot, "src"); + // Normalize canonical root for comparisons + string normCanonicalRoot = NormalizePathSafe(canonicalRoot); + string embeddedSrc = null; + TryGetEmbeddedServerSource(out embeddedSrc); + + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); + string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); + + // Legacy paths (macOS/Linux .config; Windows roaming as example) + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + // Skip logging for the canonical root itself + if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) + continue; + string legacySrc = Path.Combine(legacyRoot, "src"); + bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + + if (hasServer) + { + // Case 1: No version file + if (string.IsNullOrEmpty(legacyVer)) + { + McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); + } + + // Case 2: Lives in legacy path + McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); + + // Case 3: Has version but appears older than embedded + if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) + { + McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); + } + } + } + + // Also log if canonical is missing version (treated as older) + if (Directory.Exists(canonicalRoot)) + { + if (string.IsNullOrEmpty(installedVer)) + { + McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); + } + else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) + { + McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); + } + } + } + catch (Exception ex) + { + McpLog.Warn("Detect legacy/version state failed: " + ex.Message); + } + } + + private static string NormalizePathSafe(string path) + { + try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } + catch { return path; } + } + + private static bool PathsEqualSafe(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + string na = NormalizePathSafe(a); + string nb = NormalizePathSafe(b); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch { return false; } + } + + private static IEnumerable GetLegacyRootsForDetection() + { + var roots = new System.Collections.Generic.List(); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + // macOS/Linux legacy + roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); + roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); + // Windows roaming example + try + { + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + if (!string.IsNullOrEmpty(roaming)) + roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); + } + catch { } + return roots; + } + + private static void TryKillUvForPath(string serverSrcPath) + { + try + { + if (string.IsNullOrEmpty(serverSrcPath)) return; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/pgrep", + Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return; + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1500); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out int pid)) + { + try { System.Diagnostics.Process.GetProcessById(pid).Kill(); } catch { } + } + } + } + } + catch { } + } + + private static string ReadVersionFile(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + string v = File.ReadAllText(path).Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + catch { return null; } + } + + private static int CompareSemverSafe(string a, string b) + { + try + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; + var ap = a.Split('.'); + var bp = b.Split('.'); + for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) + { + int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; + int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; + if (ai != bi) return ai.CompareTo(bi); + } + return 0; + } + catch { return 0; } + } + /// /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package /// or common development locations. diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 82905cfe..1a979d57 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -35,6 +35,8 @@ private static Dictionary< > commandQueue = new(); private static int currentUnityPort = 6400; // Dynamic port, starts with default private static bool isAutoConnectMode = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads + private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients // Debug helpers private static bool IsDebugEnabled() @@ -96,8 +98,9 @@ public static bool FolderExists(string path) static MCPForUnityBridge() { - // Skip bridge in headless/batch environments (CI/builds) - if (Application.isBatchMode) + // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env + // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode + if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } @@ -310,7 +313,9 @@ public static void Start() isRunning = true; isAutoConnectMode = false; - Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}."); + string platform = Application.platform.ToString(); + string serverVer = ReadInstalledServerVersionSafe(); + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); Task.Run(ListenerLoop); EditorApplication.update += ProcessCommands; // Write initial heartbeat immediately @@ -395,22 +400,51 @@ private static async Task HandleClientAsync(TcpClient client) using (client) using (NetworkStream stream = client.GetStream()) { + // Framed I/O only; legacy mode removed + try + { + var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; + Debug.Log($"UNITY-MCP: Client connected {ep}"); + } + catch { } + // Strict framing: always require FRAMING=1 and frame all I/O + try + { + client.NoDelay = true; + } + catch { } + try + { + string handshake = "WELCOME UNITY-MCP 1 FRAMING=1\n"; + byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); +#else + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); +#endif + Debug.Log("UNITY-MCP: Sent handshake FRAMING=1 (strict)"); + } + catch (Exception ex) + { + Debug.LogWarning($"UNITY-MCP: Handshake failed: {ex.Message}"); + return; // abort this client + } + byte[] buffer = new byte[8192]; while (isRunning) { try { - int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); - if (bytesRead == 0) + // Strict framed mode only: enforced framed I/O for this connection + string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); + + try { - break; // Client disconnected + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + Debug.Log($"UNITY-MCP: recv framed: {preview}"); } - - string commandText = System.Text.Encoding.UTF8.GetString( - buffer, - 0, - bytesRead - ); + catch { } string commandId = Guid.NewGuid().ToString(); TaskCompletionSource tcs = new(); @@ -422,7 +456,7 @@ private static async Task HandleClientAsync(TcpClient client) /*lang=json,strict*/ "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" ); - await stream.WriteAsync(pingResponseBytes, 0, pingResponseBytes.Length); + await WriteFrameAsync(stream, pingResponseBytes); continue; } @@ -433,7 +467,7 @@ private static async Task HandleClientAsync(TcpClient client) string response = await tcs.Task; byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - await stream.WriteAsync(responseBytes, 0, responseBytes.Length); + await WriteFrameAsync(stream, responseBytes); } catch (Exception ex) { @@ -444,6 +478,127 @@ private static async Task HandleClientAsync(TcpClient client) } } + // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + { + byte[] buffer = new byte[count]; + int offset = 0; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (offset < count) + { + int remaining = count - offset; + int remainingTimeout = timeoutMs <= 0 + ? Timeout.Infinite + : timeoutMs - (int)stopwatch.ElapsedMilliseconds; + + // If a finite timeout is configured and already elapsed, fail immediately + if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) + { + throw new System.IO.IOException("Read timed out"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); + if (remainingTimeout != Timeout.Infinite) + { + cts.CancelAfter(remainingTimeout); + } + + try + { +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); +#endif + if (read == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += read; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + throw new System.IO.IOException("Read timed out"); + } + } + + return buffer; + } + + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + await WriteFrameAsync(stream, payload, cts.Token); + } + + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + { + if (payload == null) + { + throw new System.ArgumentNullException(nameof(payload)); + } + if ((ulong)payload.LongLength > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + } + byte[] header = new byte[8]; + WriteUInt64BigEndian(header, (ulong)payload.LongLength); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); + await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); +#else + await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); +#endif + } + + private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs) + { + byte[] header = await ReadExactAsync(stream, 8, timeoutMs); + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen == 0UL || payloadLen > MaxFrameBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + if (payloadLen > int.MaxValue) + { + throw new System.IO.IOException("Frame too large for buffer"); + } + int count = (int)payloadLen; + byte[] payload = await ReadExactAsync(stream, count, timeoutMs); + return System.Text.Encoding.UTF8.GetString(payload); + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new System.ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + private static void ProcessCommands() { List processedIds = new(); @@ -707,7 +862,12 @@ private static void WriteHeartbeat(bool reloading, string reason = null) { try { - string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + // Allow override of status directory (useful in CI/containers) + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } Directory.CreateDirectory(dir); string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); var payload = new @@ -727,6 +887,22 @@ private static void WriteHeartbeat(bool reloading, string reason = null) } } + private static string ReadInstalledServerVersionSafe() + { + try + { + string serverSrc = ServerInstaller.GetServerPath(); + string verFile = Path.Combine(serverSrc, "server_version.txt"); + if (File.Exists(verFile)) + { + string v = File.ReadAllText(verFile)?.Trim(); + if (!string.IsNullOrEmpty(v)) return v; + } + } + catch { } + return "unknown"; + } + private static string ComputeProjectHash(string input) { try diff --git a/UnityMcpBridge/Editor/Models/McpClient.cs b/UnityMcpBridge/Editor/Models/McpClient.cs index 895e2d61..a32f7f59 100644 --- a/UnityMcpBridge/Editor/Models/McpClient.cs +++ b/UnityMcpBridge/Editor/Models/McpClient.cs @@ -4,6 +4,7 @@ public class McpClient { public string name; public string windowsConfigPath; + public string macConfigPath; public string linuxConfigPath; public McpTypes mcpType; public string configStatus; diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs index e99d1b40..7ed6300b 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management @@ -89,6 +90,8 @@ public static object HandleCommand(JObject @params) // Editor State/Info case "get_state": return GetEditorState(); + case "get_project_root": + return GetProjectRoot(); case "get_windows": return GetEditorWindows(); case "get_active_tool": @@ -137,7 +140,7 @@ public static object HandleCommand(JObject @params) default: return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." ); } } @@ -165,6 +168,25 @@ private static object GetEditorState() } } + private static object GetProjectRoot() + { + try + { + // Application.dataPath points to /Assets + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + if (string.IsNullOrEmpty(projectRoot)) + { + return Response.Error("Could not determine project root from Application.dataPath"); + } + return Response.Success("Project root resolved.", new { projectRoot }); + } + catch (Exception e) + { + return Response.Error($"Error getting project root: {e.Message}"); + } + } + private static object GetEditorWindows() { try diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 274f84d1..82d81aca 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -1,15 +1,19 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; +using System.Threading; +using System.Security.Cryptography; #if USE_ROSLYN using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; #endif #if UNITY_EDITOR @@ -47,6 +51,54 @@ namespace MCPForUnity.Editor.Tools /// public static class ManageScript { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = Application.dataPath.Replace('\\', '/'); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); + rel = rel.TrimStart('/'); + + string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); + string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if directory is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + if (di.Exists) + { + var attrs = di.Attributes; + if ((attrs & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } /// /// Main handler for script management actions. /// @@ -96,29 +148,16 @@ public static object HandleCommand(JObject @params) ); } - // Ensure path is relative to Assets/, removing any leading "Assets/" - // Set default directory to "Scripts" if path is not provided - string relativeDir = path ?? "Scripts"; // Default to "Scripts" if path is null - if (!string.IsNullOrEmpty(relativeDir)) - { - relativeDir = relativeDir.Replace('\\', '/').Trim('/'); - if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) - { - relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); - } - } - // Handle empty string case explicitly after processing - if (string.IsNullOrEmpty(relativeDir)) + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) { - relativeDir = "Scripts"; // Ensure default if path was provided as "" or only "/" or "Assets/" + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); } - // Construct paths + // Construct file paths string scriptFileName = $"{name}.cs"; - string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Application.dataPath ends in "Assets" string fullPath = Path.Combine(fullPathDir, scriptFileName); - string relativePath = Path.Combine("Assets", relativeDir, scriptFileName) - .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); // Ensure the target directory exists for create/update if (action == "create" || action == "update") @@ -148,14 +187,58 @@ public static object HandleCommand(JObject @params) namespaceName ); case "read": + Debug.LogWarning("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); return ReadScript(fullPath, relativePath); case "update": + Debug.LogWarning("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); return UpdateScript(fullPath, relativePath, name, contents); case "delete": return DeleteScript(fullPath, relativePath); + case "apply_text_edits": + { + var textEdits = @params["edits"] as JArray; + string precondition = @params["precondition_sha256"]?.ToString(); + // Respect optional refresh options for immediate compile + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + var m = Regex.Match(s, @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$"); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); + } + case "edit": + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var structEdits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, structEdits, options); default: return Response.Error( - $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." ); } } @@ -217,13 +300,29 @@ string namespaceName try { - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); - AssetDatabase.Refresh(); // Ensure Unity recognizes the new script - return Response.Success( + // Atomic create without BOM; schedule refresh after reply + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + try + { + File.Move(tmp, fullPath); + } + catch (IOException) + { + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } + + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( $"Script '{name}.cs' created successfully at '{relativePath}'.", - new { path = relativePath } + new { uri, scheduledRefresh = true } ); + + // Schedule heavy work AFTER replying + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + return ok; } catch (Exception e) { @@ -244,8 +343,10 @@ private static object ReadScript(string fullPath, string relativePath) // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"unity://path/{relativePath}"; var responseData = new { + uri, path = relativePath, contents = contents, // For large files, also include base64-encoded version @@ -298,13 +399,41 @@ string contents try { - File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); - AssetDatabase.ImportAsset(relativePath); // Re-import to reflect changes - AssetDatabase.Refresh(); - return Response.Success( + // Safe write with atomic replace when available, without BOM + var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (IOException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( $"Script '{name}.cs' updated successfully at '{relativePath}'.", - new { path = relativePath } + new { uri, path = relativePath, scheduledRefresh = true } ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; } catch (Exception e) { @@ -312,6 +441,393 @@ string contents } } + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private const int MaxEditPayloadBytes = 15 * 1024; + + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits, + string preconditionSha256, + string refreshModeFromCaller = null) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // If checking attributes fails, proceed without the symlink guard + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + // Require precondition to avoid drift on large files + string currentSha = ComputeSha256(original); + if (string.IsNullOrEmpty(preconditionSha256)) + return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) + return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + long totalBytes = 0; + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present + // Find first top-level using (supports alias, static, and dotted namespaces) + var mUsing = System.Text.RegularExpressions.Regex.Match( + original, + @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", + System.Text.RegularExpressions.RegexOptions.CultureInvariant + ); + if (mUsing.Success) + { + headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); + } + foreach (var sp in spans) + { + if (sp.start < headerBoundary) + { + return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + } + } + + // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method + if (spans.Count == 1) + { + var sp = spans[0]; + // Heuristic: around the start of the edit, try to match a method header in original + int searchStart = Math.Max(0, sp.start - 200); + int searchEnd = Math.Min(original.Length, sp.start + 200); + string slice = original.Substring(searchStart, searchEnd - searchStart); + var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); + var mh = rx.Match(slice); + if (mh.Success) + { + string methodName = mh.Groups[1].Value; + // Find class span containing the edit + if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) + { + if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) + { + // If the edit overlaps the method span significantly, treat as replace_method + if (sp.start <= mStart + 2 && sp.end >= mStart + 1) + { + var structEdits = new JArray(); + + // Apply the edit to get a candidate string, then recompute method span on the edited text + string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string replacementText; + if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) + && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) + { + replacementText = candidate.Substring(m2Start, m2Len); + } + else + { + // Fallback: adjust method start by the net delta if the edit was before the method + int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); + int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); + adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); + + // If the edit was within the original method span, adjust the length by the delta within-method + int withinMethodDelta = 0; + if (sp.start >= mStart && sp.start <= mStart + mLen) + { + withinMethodDelta = delta; + } + int adjustedLen = mLen + withinMethodDelta; + adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); + replacementText = candidate.Substring(adjustedStart, adjustedLen); + } + + var op = new JObject + { + ["mode"] = "replace_method", + ["className"] = name, + ["methodName"] = methodName, + ["replacement"] = replacementText + }; + structEdits.Add(op); + // Reuse structured path + return EditScript(fullPath, relativePath, name, structEdits, new JObject{ ["refresh"] = "immediate", ["validate"] = "standard" }); + } + } + } + } + } + + if (totalBytes > MaxEditPayloadBytes) + { + return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + return Response.Error("Edits overlap; split into separate calls or adjust ranges."); + } + + string working = original; + foreach (var sp in spans) + { + working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + } + + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString() }); + } + +#if USE_ROSLYN + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + return Response.Error("syntax_error", new { status = "syntax_error", diagnostics }); + } + + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } +#endif + + string newSha = ComputeSha256(working); + + // Atomic write and schedule refresh + try + { + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + // Respect refresh mode: immediate vs debounced + bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || + string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); + if (immediate) + { + EditorApplication.delayCall += () => + { + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + }; + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + + return Response.Success( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + applied = spans.Count, + unchanged = 0, + sha256 = newSha, + uri = $"unity://path/{relativePath}", + scheduledRefresh = !immediate + } + ); + } + catch (Exception ex) + { + return Response.Error($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } + } + index = -1; + return false; + } + + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + private static object DeleteScript(string fullPath, string relativePath) { if (!File.Exists(fullPath)) @@ -327,7 +843,8 @@ private static object DeleteScript(string fullPath, string relativePath) { AssetDatabase.Refresh(); return Response.Success( - $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully." + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } ); } else @@ -344,6 +861,838 @@ private static object DeleteScript(string fullPath, string relativePath) } } + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // ignore failures checking attributes and proceed + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("replace_class requires 'className'."); + if (replacement == null) + return Response.Error("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return Response.Error($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return Response.Error($"Replacement snippet invalid: {vErr}"); + + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return Response.Error($"delete_class failed: {why}"); + + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); + if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + // Harden: refuse empty replacement for inserts + if (snippet == null || snippet.Trim().Length == 0) + return Response.Error("insert_method requires a non-empty 'replacement' text."); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); + if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + break; + } + + case "anchor_insert": + { + string anchor = op.Value("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("text") ?? ExtractReplacement(op); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + int insAt = position == "after" ? m.Index + m.Length : m.Index; + string norm = NormalizeNewlines(text); + if (applySequentially) + { + working = working.Insert(insAt, norm); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_insert failed: {ex.Message}"); + } + break; + } + + case "anchor_delete": + { + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + int delAt = m.Index; + int delLen = m.Length; + if (applySequentially) + { + working = working.Remove(delAt, delLen); + appliedCount++; + } + else + { + replacements.Add((delAt, delLen, string.Empty)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_delete failed: {ex.Message}"); + } + break; + } + + case "anchor_replace": + { + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + int at = m.Index; + int len = m.Length; + string norm = NormalizeNewlines(replacement); + if (applySequentially) + { + working = working.Remove(at, len).Insert(at, norm); + appliedCount++; + } + else + { + replacements.Add((at, len, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_replace failed: {ex.Message}"); + } + break; + } + + default: + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert."); + } + } + + if (!applySequentially) + { + if (HasOverlaps(replacements)) + return Response.Error("Edits overlap; split into separate calls or adjust targets."); + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("Script validation failed:\n" + string.Join("\n", errors ?? Array.Empty())); + else if (errors != null && errors.Length > 0) + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + var enc = System.Text.Encoding.UTF8; + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + var ok = Response.Success( + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new { path = relativePath, editsApplied = appliedCount, scheduledRefresh = !immediate } + ); + + if (immediate) + { + // Force on main thread + EditorApplication.delayCall += () => + { + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + }; + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return Response.Error($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 1) Find the method header using a stricter regex (allows optional attributes above) + string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); + string namePattern = Regex.Escape(methodName); + // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so + // we can safely embed the signature inside our own parenthesis group without duplicating. + string paramsPattern; + if (string.IsNullOrEmpty(parametersSignature)) + { + paramsPattern = @"[\s\S]*?"; // permissive when not specified + } + else + { + string ps = parametersSignature.Trim(); + if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) + { + ps = ps.Substring(1, ps.Length - 2); + } + // Escape literal text of the signature + paramsPattern = Regex.Escape(ps); + } + string pattern = + @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (;;) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + /// /// Generates basic C# script content based on name and type. /// @@ -451,11 +1800,14 @@ private static bool ValidateScriptSyntax(string contents, ValidationLevel level, } #if USE_ROSLYN - // Advanced Roslyn-based validation - if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) { - errors = errorList.ToArray(); - return level != ValidationLevel.Standard; //TODO: Allow standard to run roslyn right now, might formalize it in the future + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } } #endif @@ -1028,3 +2380,80 @@ private static void ValidateSemanticRules(string contents, System.Collections.Ge } } +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // The timestamp of the most recent schedule request. + private static DateTime _lastRequest; + + // Guard to ensure we only have a single ticking callback running. + private static bool _scheduled; + + public static void Schedule(string relPath, TimeSpan window) + { + // Record that work is pending and track the path in a threadsafe way. + Interlocked.Exchange(ref _pending, 1); + lock (_lock) + { + _paths.Add(relPath); + _lastRequest = DateTime.UtcNow; + + // If a debounce timer is already scheduled it will pick up the new request. + if (_scheduled) + return; + + _scheduled = true; + } + + // Kick off a ticking callback that waits until the window has elapsed + // from the last request before performing the refresh. + EditorApplication.delayCall += () => Tick(window); + } + + private static void Tick(TimeSpan window) + { + bool ready; + lock (_lock) + { + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) + { + _scheduled = false; + } + } + + if (!ready) + { + // Window has not yet elapsed; check again on the next editor tick. + EditorApplication.delayCall += () => Tick(window); + return; + } + + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + AssetDatabase.ImportAsset(p, ImportAssetOptions.ForceUpdate); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + } +} + +static class ManageScriptRefreshHelpers +{ + public static void ScheduleScriptRefresh(string relPath) + { + RefreshDebounce.Schedule(relPath, TimeSpan.FromMilliseconds(200)); + } +} + diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index e1aa073c..9c21eafc 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -59,6 +59,10 @@ private void OnEnable() isUnityBridgeRunning = MCPForUnityBridge.IsRunning; autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } foreach (McpClient mcpClient in mcpClients.clients) { CheckMcpConfiguration(mcpClient); @@ -243,10 +247,79 @@ private void DrawHeader() { debugLogsEnabled = newDebug; EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } } EditorGUILayout.Space(15); } + private void LogDebugPrefsState() + { + try + { + string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); + string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); + string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); + bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); + + // Version-scoped detection key + string embeddedVer = ReadEmbeddedVersionOrFallback(); + string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; + bool detectLogged = SafeGetPrefBool(detectKey); + + // Project-scoped auto-register key + string projectPath = Application.dataPath ?? string.Empty; + string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; + bool autoRegistered = SafeGetPrefBool(autoKey); + + MCPForUnity.Editor.Helpers.McpLog.Info( + "MCP Debug Prefs:\n" + + $" DebugLogs: {debugLogsEnabled}\n" + + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + + $" UvPath: '{uvPathPref}'\n" + + $" ServerSrc: '{serverSrcPref}'\n" + + $" UseEmbeddedServer: {useEmbedded}\n" + + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", + always: false + ); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); + } + } + + private static string SafeGetPrefString(string key) + { + try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } + } + + private static bool SafeGetPrefBool(string key) + { + try { return EditorPrefs.GetBool(key, false); } catch { return false; } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = Path.Combine(embeddedSrc, "server_version.txt"); + if (File.Exists(p)) + { + var s = File.ReadAllText(p)?.Trim(); + if (!string.IsNullOrEmpty(s)) return s; + } + } + } + catch { } + return "unknown"; + } + private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -505,7 +578,7 @@ private void AutoFirstRunSetup() } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup client '{client.name}' failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); @@ -522,7 +595,7 @@ private void AutoFirstRunSetup() } catch (Exception ex) { - UnityEngine.Debug.LogWarning($"Auto-setup StartAutoConnect failed: {ex.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); } } @@ -533,7 +606,7 @@ private void AutoFirstRunSetup() } catch (Exception e) { - UnityEngine.Debug.LogWarning($"MCP for Unity auto-setup skipped: {e.Message}"); + MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); } } @@ -649,7 +722,8 @@ private static bool PathsEqual(string a, string b) { string na = System.IO.Path.GetFullPath(a.Trim()); string nb = System.IO.Path.GetFullPath(b.Trim()); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows) + || System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)) { return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); } @@ -888,18 +962,15 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); return; } - + // VSCode now reads from mcp.json with a top-level "servers" block var vscodeConfig = new { - mcp = new + servers = new { - servers = new + unityMCP = new { - unityMCP = new - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" } - } + command = uvPath, + args = new[] { "run", "--directory", pythonDir, "server.py" } } } }; @@ -1001,7 +1072,7 @@ private static bool ArgsEqual(string[] a, string[] b) return true; } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } @@ -1064,8 +1135,18 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } catch { } - // 1) Start from existing, only fill gaps - string uvPath = (ValidateUvBinarySafe(existingCommand) ? existingCommand : FindUvPath()); + // 1) Start from existing, only fill gaps (prefer trusted resolver) + string uvPath = FindUvPath(); + // Optionally trust existingCommand if it looks like uv/uv.exe + try + { + var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); + if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand)) + { + uvPath = existingCommand; + } + } + catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; string serverSrc = ExtractDirectoryArg(existingArgs); @@ -1073,11 +1154,38 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); if (!serverValid) { - serverSrc = ResolveServerSrc(); + // Prefer the provided pythonDir if valid; fall back to resolver + if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py"))) + { + serverSrc = pythonDir; + } + else + { + serverSrc = ResolveServerSrc(); + } + } + + // macOS normalization: map XDG-style ~/.local/share to canonical Application Support + try + { + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) + && !string.IsNullOrEmpty(serverSrc)) + { + string norm = serverSrc.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); + if (idx >= 0) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... + serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix); + } + } } + catch { } // Hard-block PackageCache on Windows unless dev override is set if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && !string.IsNullOrEmpty(serverSrc) && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 && !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) { @@ -1105,13 +1213,60 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + // Robust atomic write without redundant backup or race on existence string tmp = configPath + ".tmp"; - // Write UTF-8 without BOM to avoid issues on Windows editors/tools - System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - if (System.IO.File.Exists(configPath)) - System.IO.File.Replace(tmp, configPath, null); - else - System.IO.File.Move(tmp, configPath); + string backup = configPath + ".backup"; + bool writeDone = false; + try + { + // Write to temp file first (in same directory for atomicity) + System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); + + try + { + // Try atomic replace; creates 'backup' only on success (platform-dependent) + System.IO.File.Replace(tmp, configPath, backup); + writeDone = true; + } + catch (System.IO.FileNotFoundException) + { + // Destination didn't exist; fall back to move + System.IO.File.Move(tmp, configPath); + writeDone = true; + } + catch (System.PlatformNotSupportedException) + { + // Fallback: rename existing to backup, then move tmp into place + if (System.IO.File.Exists(configPath)) + { + try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } + System.IO.File.Move(configPath, backup); + } + System.IO.File.Move(tmp, configPath); + writeDone = true; + } + } + catch (Exception ex) + { + // If write did not complete, attempt restore from backup without deleting current file first + try + { + if (!writeDone && System.IO.File.Exists(backup)) + { + try { System.IO.File.Copy(backup, configPath, true); } catch { } + } + } + catch { } + throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); + } + finally + { + // Best-effort cleanup of temp + try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } + // Only remove backup after a confirmed successful write + try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } + } try { if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); @@ -1277,7 +1432,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1319,7 +1481,14 @@ private string ConfigureMcpClient(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1431,7 +1600,14 @@ private void CheckMcpConfiguration(McpClient mcpClient) } else if ( RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ) { configPath = mcpClient.linuxConfigPath; @@ -1490,7 +1666,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Common logic for checking configuration status if (configExists) { - bool matches = pythonDir != null && Array.Exists(args, arg => arg.Contains(pythonDir, StringComparison.Ordinal)); + string configuredDir = ExtractDirectoryArg(args); + bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); @@ -1673,31 +1850,7 @@ private void UnregisterWithClaudeCode() } } - private bool ParseTextOutput(string claudePath, string projectDir, string pathPrepend) - { - if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend)) - { - UnityEngine.Debug.Log($"Claude MCP servers (text): {listStdout}"); - - // Check if output indicates no servers or contains "UnityMCP" variants - if (listStdout.Contains("No MCP servers configured") || - listStdout.Contains("no servers") || - listStdout.Contains("No servers") || - string.IsNullOrWhiteSpace(listStdout) || - listStdout.Trim().Length == 0) - { - return false; - } - - // Look for "UnityMCP" variants in the output - return listStdout.Contains("UnityMCP") || - listStdout.Contains("unityMCP") || - listStdout.Contains("unity-mcp"); - } - - // If command failed, assume no servers - return false; - } + // Removed unused ParseTextOutput private string FindUvPath() { @@ -1979,93 +2132,7 @@ private string FindWindowsUvPath() return null; // Will fallback to using 'uv' from PATH } - private string FindClaudeCommand() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Common locations for Claude CLI on Windows - string[] possiblePaths = { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm", "claude.cmd"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "npm", "claude.cmd"), - "claude.cmd", // Fallback to PATH - "claude" // Final fallback - }; - - foreach (string path in possiblePaths) - { - if (path.Contains("\\") && File.Exists(path)) - { - return path; - } - } - - // Try to find via where command (PowerShell compatible) - try - { - var psi = new ProcessStartInfo - { - FileName = "where.exe", - Arguments = "claude", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) - { - string[] lines = output.Split('\n'); - foreach (string line in lines) - { - string cleanPath = line.Trim(); - if (File.Exists(cleanPath)) - { - return cleanPath; - } - } - } - } - catch - { - // If where.exe fails, try PowerShell's Get-Command as fallback - try - { - var psi = new ProcessStartInfo - { - FileName = "powershell.exe", - Arguments = "-Command \"(Get-Command claude -ErrorAction SilentlyContinue).Source\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = Process.Start(psi); - string output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(); - - if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) - { - return output; - } - } - catch - { - // Ignore PowerShell errors too - } - } - - return "claude"; // Final fallback to PATH - } - else - { - return "/usr/local/bin/claude"; - } - } + // Removed unused FindClaudeCommand private void CheckClaudeCodeConfiguration(McpClient mcpClient) { @@ -2075,10 +2142,14 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) string unityProjectDir = Application.dataPath; string projectDir = Path.GetDirectoryName(unityProjectDir); - // Read the global Claude config file - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? mcpClient.windowsConfigPath - : mcpClient.linuxConfigPath; + // Read the global Claude config file (honor macConfigPath on macOS) + string configPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + configPath = mcpClient.windowsConfigPath; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; + else + configPath = mcpClient.linuxConfigPath; if (debugLogsEnabled) { diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index e7806a94..554dacc1 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -116,10 +116,13 @@ protected virtual void OnGUI() { displayPath = mcpClient.windowsConfigPath; } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - || RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? configPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { displayPath = mcpClient.linuxConfigPath; } diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs index 49357982..e5544510 100644 --- a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs @@ -90,25 +90,21 @@ protected override void OnGUI() EditorStyles.boldLabel ); EditorGUILayout.LabelField( - "a) Open VSCode Settings (File > Preferences > Settings)", + "a) Open or create your VSCode MCP config file (mcp.json) at the path below", instructionStyle ); EditorGUILayout.LabelField( - "b) Click on the 'Open Settings (JSON)' button in the top right", + "b) Paste the JSON shown below into mcp.json", instructionStyle ); EditorGUILayout.LabelField( - "c) Add the MCP configuration shown below to your settings.json file", - instructionStyle - ); - EditorGUILayout.LabelField( - "d) Save the file and restart VSCode", + "c) Save the file and restart VSCode", instructionStyle ); EditorGUILayout.Space(5); EditorGUILayout.LabelField( - "3. VSCode settings.json location:", + "3. VSCode mcp.json location:", EditorStyles.boldLabel ); @@ -121,7 +117,7 @@ protected override void OnGUI() System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), "Code", "User", - "settings.json" + "mcp.json" ); } else @@ -132,7 +128,7 @@ protected override void OnGUI() "Application Support", "Code", "User", - "settings.json" + "mcp.json" ); } @@ -205,7 +201,7 @@ protected override void OnGUI() EditorGUILayout.Space(10); EditorGUILayout.LabelField( - "4. Add this configuration to your settings.json:", + "4. Add this configuration to your mcp.json:", EditorStyles.boldLabel ); diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json new file mode 100644 index 00000000..4fdeb465 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": "none", + "pythonVersion": "3.11", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.11" + } + ] +} diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt new file mode 100644 index 00000000..b5021469 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -0,0 +1 @@ +3.0.2 diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 2bf711df..43b53096 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,3 +1,5 @@ +import logging +from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools from .manage_editor import register_manage_editor_tools @@ -6,10 +8,15 @@ from .manage_shader import register_manage_shader_tools from .read_console import register_read_console_tools from .execute_menu_item import register_execute_menu_item_tools +from .resource_tools import register_resource_tools + +logger = logging.getLogger("mcp-for-unity-server") def register_all_tools(mcp): """Register all refactored tools with the MCP server.""" - print("Registering MCP for Unity Server refactored tools...") + # Prefer the surgical edits tool so LLMs discover it first + logger.info("Registering MCP for Unity Server refactored tools...") + register_manage_script_edits_tools(mcp) register_manage_script_tools(mcp) register_manage_scene_tools(mcp) register_manage_editor_tools(mcp) @@ -18,4 +25,6 @@ def register_all_tools(mcp): register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_execute_menu_item_tools(mcp) - print("MCP for Unity Server tool registration complete.") + # Expose resource wrappers as normal tools so IDEs without resources primitive can use them + register_resource_tools(mcp) + logger.info("MCP for Unity Server tool registration complete.") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 19ac0c2e..ccafb047 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -76,4 +76,4 @@ async def manage_asset( # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity - return result if isinstance(result, dict) else {"success": False, "message": str(result)} \ No newline at end of file + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index a41fb85c..ac19795d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,14 +1,93 @@ from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time -import os +from typing import Dict, Any, List +from unity_connection import send_command_with_retry import base64 +import os + def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" + def _split_uri(uri: str) -> tuple[str, str]: + if uri.startswith("unity://path/"): + path = uri[len("unity://path/") :] + elif uri.startswith("file://"): + path = uri[len("file://") :] + else: + path = uri + path = path.replace("\\", "/") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + return name, directory + + @mcp.tool() + def apply_text_edits( + ctx: Context, + uri: str, + edits: List[Dict[str, Any]], + precondition_sha256: str | None = None, + ) -> Dict[str, Any]: + """Apply small text edits to a C# script identified by URI.""" + name, directory = _split_uri(uri) + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": edits, + "precondition_sha256": precondition_sha256, + } + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def create_script( + ctx: Context, + path: str, + contents: str = "", + script_type: str | None = None, + namespace: str | None = None, + ) -> Dict[str, Any]: + """Create a new C# script at the given path.""" + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + params: Dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents: + params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: + """Delete a C# script by URI.""" + name, directory = _split_uri(uri) + params = {"action": "delete", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + @mcp.tool() + def validate_script( + ctx: Context, uri: str, level: str = "basic" + ) -> Dict[str, Any]: + """Validate a C# script and return diagnostics.""" + name, directory = _split_uri(uri) + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + @mcp.tool() def manage_script( ctx: Context, @@ -17,13 +96,16 @@ def manage_script( path: str, contents: str, script_type: str, - namespace: str + namespace: str, ) -> Dict[str, Any]: - """Manages C# scripts in Unity (create, read, update, delete). - Make reference variables public for easier access in the Unity Editor. + """Compatibility router for legacy script operations. + + IMPORTANT: + - Direct file reads should use resources/read. + - Edits should use apply_text_edits. Args: - action: Operation ('create', 'read', 'update', 'delete'). + action: Operation ('create', 'read', 'delete'). name: Script name (no .cs extension). path: Asset path (default: "Assets/"). contents: C# code for 'create'/'update'. @@ -34,42 +116,50 @@ def manage_script( Dictionary with results ('success', 'message', 'data'). """ try: + # Deprecate full-file update path entirely + if action == 'update': + return {"success": False, "message": "Deprecated: use apply_text_edits or resources/read + small edits."} + # Prepare parameters for Unity params = { "action": action, "name": name, "path": path, "namespace": namespace, - "scriptType": script_type + "scriptType": script_type, } - + # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission + if contents: + if action == 'create': params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents - - # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} - # Send command via centralized retry helper response = send_command_with_retry("manage_script", params) - - # Process response from Unity - if isinstance(response, dict) and response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + if isinstance(response, dict): + if response.get("success"): + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response + + return {"success": False, "message": str(response)} except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing script: {str(e)}"} \ No newline at end of file + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py new file mode 100644 index 00000000..59c4d8f4 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -0,0 +1,679 @@ +from mcp.server.fastmcp import FastMCP, Context +from typing import Dict, Any, List, Tuple +import base64 +import re +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE + m = re.search(anchor, text, flags) + if not m: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = m.start() if position == "before" else m.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + end_line = int(edit.get("endLine", start_line)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + max_end = len(lines) + 1 + if start_line < 1 or end_line < start_line or end_line > max_end: + raise RuntimeError("replace_range out of bounds") + a = start_line - 1 + b = min(end_line, len(lines)) + rep = replacement + if rep and not rep.endswith("\n"): + rep += "\n" + text = "".join(lines[:a]) + rep + "".join(lines[b:]) + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +def _infer_class_name(script_name: str) -> str: + # Default to script name as class name (common Unity pattern) + return (script_name or "").strip() + + +def _extract_code_after(keyword: str, request: str) -> str: + # Deprecated with NL removal; retained as no-op for compatibility + idx = request.lower().find(keyword) + if idx >= 0: + return request[idx + len(keyword):].strip() + return "" + + +def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: + """Best-effort normalization of script "name" and "path". + + Accepts any of: + - name = "SmartReach", path = "Assets/Scripts/Interaction" + - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" + - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" + - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) + - name or path using uri prefixes: unity://path/..., file://... + - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" + + Returns (name_without_extension, directory_path_under_Assets). + """ + n = (name or "").strip() + p = (path or "").strip() + + def strip_prefix(s: str) -> str: + if s.startswith("unity://path/"): + return s[len("unity://path/"):] + if s.startswith("file://"): + return s[len("file://"):] + return s + + def collapse_duplicate_tail(s: str) -> str: + # Collapse trailing "/X.cs/X.cs" to "/X.cs" + parts = s.split("/") + if len(parts) >= 2 and parts[-1] == parts[-2]: + parts = parts[:-1] + return "/".join(parts) + + # Prefer a full path if provided in either field + candidate = "" + for v in (n, p): + v2 = strip_prefix(v) + if v2.endswith(".cs") or v2.startswith("Assets/"): + candidate = v2 + break + + if candidate: + candidate = collapse_duplicate_tail(candidate) + # If a directory was passed in path and file in name, join them + if not candidate.endswith(".cs") and n.endswith(".cs"): + v2 = strip_prefix(n) + candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) + if candidate.endswith(".cs"): + parts = candidate.split("/") + file_name = parts[-1] + dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" + base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name + return base, dir_path + + # Fall back: remove extension from name if present and return given path + base_name = n[:-3] if n.lower().endswith(".cs") else n + return base_name, (p or "Assets") + + +def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any: + if not isinstance(resp, dict): + return resp + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", edits) + if routing: + data["routing"] = routing + return resp + + +def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, + normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"success": False, "code": code, "message": message} + data: Dict[str, Any] = {} + if expected: + data["expected"] = expected + if rewrite: + data["rewrite_suggestion"] = rewrite + if normalized is not None: + data["normalizedEdits"] = normalized + if routing: + data["routing"] = routing + if extra: + data.update(extra) + if data: + payload["data"] = data + return payload + +# Natural-language parsing removed; clients should send structured edits. + + +def register_manage_script_edits_tools(mcp: FastMCP): + @mcp.tool(description=( + "Apply targeted edits to an existing C# script (no full-file overwrite).\n\n" + "Canonical fields (use these exact keys):\n" + "- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" + "- className: string (defaults to 'name' if omitted on method/class ops)\n" + "- methodName: string (required for replace_method, delete_method)\n" + "- replacement: string (required for replace_method, insert_method)\n" + "- position: start | end | after | before (insert_method only)\n" + "- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" + "- anchor: regex string (for anchor_* ops)\n" + "- text: string (for anchor_insert/anchor_replace)\n\n" + "Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" + "Examples:\n" + "1) Replace a method:\n" + "{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n" + " { 'op':'replace_method','className':'SmartReach','methodName':'HasTarget',\n" + " 'replacement':'public bool HasTarget(){ return currentTarget!=null; }' }\n" + "], 'options':{'validate':'standard','refresh':'immediate'} }\n\n" + "2) Insert a method after another:\n" + "{ 'name':'SmartReach','path':'Assets/Scripts/Interaction','edits':[\n" + " { 'op':'insert_method','className':'SmartReach','replacement':'public void PrintSeries(){ Debug.Log(seriesName); }',\n" + " 'position':'after','afterMethodName':'GetCurrentTarget' }\n" + "] }\n" + )) + def script_apply_edits( + ctx: Context, + name: str, + path: str, + edits: List[Dict[str, Any]], + options: Dict[str, Any] | None = None, + script_type: str = "MonoBehaviour", + namespace: str = "", + ) -> Dict[str, Any]: + # Normalize locator first so downstream calls target the correct script file. + name, path = _normalize_script_locator(name, path) + + # No NL path: clients must provide structured edits in 'edits'. + + # Normalize unsupported or aliased ops to known structured/text paths + def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: + # Unwrap single-key wrappers like {"replace_method": {...}} + for wrapper_key in ( + "replace_method","insert_method","delete_method", + "replace_class","delete_class", + "anchor_insert","anchor_replace","anchor_delete", + ): + if wrapper_key in edit and isinstance(edit[wrapper_key], dict): + inner = dict(edit[wrapper_key]) + inner["op"] = wrapper_key + edit = inner + break + + e = dict(edit) + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + if op: + e["op"] = op + + # Common field aliases + if "class_name" in e and "className" not in e: + e["className"] = e.pop("class_name") + if "class" in e and "className" not in e: + e["className"] = e.pop("class") + if "method_name" in e and "methodName" not in e: + e["methodName"] = e.pop("method_name") + # Some clients use a generic 'target' for method name + if "target" in e and "methodName" not in e: + e["methodName"] = e.pop("target") + if "method" in e and "methodName" not in e: + e["methodName"] = e.pop("method") + if "new_content" in e and "replacement" not in e: + e["replacement"] = e.pop("new_content") + if "newMethod" in e and "replacement" not in e: + e["replacement"] = e.pop("newMethod") + if "new_method" in e and "replacement" not in e: + e["replacement"] = e.pop("new_method") + if "content" in e and "replacement" not in e: + e["replacement"] = e.pop("content") + if "after" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after") + if "after_method" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after_method") + if "before" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before") + if "before_method" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before_method") + # anchor_method → before/after based on position (default after) + if "anchor_method" in e: + anchor = e.pop("anchor_method") + pos = (e.get("position") or "after").strip().lower() + if pos == "before" and "beforeMethodName" not in e: + e["beforeMethodName"] = anchor + elif "afterMethodName" not in e: + e["afterMethodName"] = anchor + if "anchorText" in e and "anchor" not in e: + e["anchor"] = e.pop("anchorText") + if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): + e["anchor"] = e.pop("pattern") + if "newText" in e and "text" not in e: + e["text"] = e.pop("newText") + + # LSP-like range edit -> replace_range + if "range" in e and isinstance(e["range"], dict): + rng = e.pop("range") + start = rng.get("start", {}) + end = rng.get("end", {}) + # Convert 0-based to 1-based line/col + e["op"] = "replace_range" + e["startLine"] = int(start.get("line", 0)) + 1 + e["startCol"] = int(start.get("character", 0)) + 1 + e["endLine"] = int(end.get("line", 0)) + 1 + e["endCol"] = int(end.get("character", 0)) + 1 + if "newText" in edit and "text" not in e: + e["text"] = edit.get("newText", "") + return e + + normalized_edits: List[Dict[str, Any]] = [] + for raw in edits or []: + e = _unwrap_and_alias(raw) + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + + # Default className to script name if missing on structured method/class ops + if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"): + e["className"] = name + + # Map common aliases for text ops + if op in ("text_replace",): + e["op"] = "replace_range" + normalized_edits.append(e) + continue + if op in ("regex_delete",): + e["op"] = "regex_replace" + e.setdefault("text", "") + normalized_edits.append(e) + continue + if op == "regex_replace" and ("replacement" not in e): + if "text" in e: + e["replacement"] = e.get("text", "") + elif "insert" in e or "content" in e: + e["replacement"] = e.get("insert") or e.get("content") or "" + if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): + e["op"] = "anchor_delete" + normalized_edits.append(e) + continue + normalized_edits.append(e) + + edits = normalized_edits + normalized_for_echo = edits + + # Validate required fields and produce machine-parsable hints + def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: + return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) + + for e in edits or []: + op = e.get("op", "") + if op == "replace_method": + if not e.get("methodName"): + return error_with_hint( + "replace_method requires 'methodName'.", + {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"edits[0].methodName": "HasTarget"} + ) + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "replace_method requires 'replacement' (inline or base64).", + {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"edits[0].replacement": "public bool X(){ return true; }"} + ) + elif op == "insert_method": + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "insert_method requires a non-empty 'replacement'.", + {"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} + ) + pos = (e.get("position") or "").lower() + if pos == "after" and not e.get("afterMethodName"): + return error_with_hint( + "insert_method with position='after' requires 'afterMethodName'.", + {"op": "insert_method", "position": {"after_requires": "afterMethodName"}}, + {"edits[0].afterMethodName": "GetCurrentTarget"} + ) + if pos == "before" and not e.get("beforeMethodName"): + return error_with_hint( + "insert_method with position='before' requires 'beforeMethodName'.", + {"op": "insert_method", "position": {"before_requires": "beforeMethodName"}}, + {"edits[0].beforeMethodName": "GetCurrentTarget"} + ) + elif op == "delete_method": + if not e.get("methodName"): + return error_with_hint( + "delete_method requires 'methodName'.", + {"op": "delete_method", "required": ["className", "methodName"]}, + {"edits[0].methodName": "PrintSeries"} + ) + elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): + if not e.get("anchor"): + return error_with_hint( + f"{op} requires 'anchor' (regex).", + {"op": op, "required": ["anchor"]}, + {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} + ) + if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): + return error_with_hint( + f"{op} requires 'text'.", + {"op": op, "required": ["anchor", "text"]}, + {"edits[0].text": "/* comment */\n"} + ) + + # Decide routing: structured vs text vs mixed + STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace"} + TEXT = {"prepend","append","replace_range","regex_replace","anchor_insert"} + ops_set = { (e.get("op") or "").lower() for e in edits or [] } + all_struct = ops_set.issubset(STRUCT) + all_text = ops_set.issubset(TEXT) + mixed = not (all_struct or all_text) + + # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. + if all_struct: + opts2 = dict(options or {}) + # Be conservative: when multiple structured ops are present, ensure deterministic order + if len(edits or []) > 1: + opts2.setdefault("applyMode", "sequential") + opts2.setdefault("refresh", "immediate") + params_struct: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": opts2, + } + resp_struct = send_command_with_retry("manage_script", params_struct) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") + + # 1) read from Unity + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode(data["encodedContents"]).decode("utf-8") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # Optional preview/dry-run: apply locally and return diff without writing + preview = bool((options or {}).get("preview")) + + # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured + if mixed: + text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT and (e.get("op") or "").lower() not in {"anchor_insert"}] + try: + current_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: + line = current_text.count("\n", 0, idx) + 1 + last_nl = current_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: List[Dict[str, Any]] = [] + import re as _re + for e in text_edits: + opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" + if opx == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "before").lower() + m = _re.search(anchor, current_text, _re.MULTILINE) + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") + idx = m.start() if position == "before" else m.end() + sl, sc = line_col_from_index(idx) + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + current_text = current_text[:idx] + text_field + current_text[idx:] + elif opx == "replace_range": + if all(k in e for k in ("startLine","startCol","endLine","endCol")): + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + elif opx == "regex_replace": + pattern = e.get("pattern") or "" + m = _re.search(pattern, current_text, _re.MULTILINE) + if not m: + continue + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": text_field}) + current_text = current_text[:m.start()] + text_field + current_text[m.end():] + elif opx in ("prepend","append"): + if opx == "prepend": + sl, sc = 1, 1 + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + current_text = text_field + current_text + else: + # Insert at true EOF position (handles both \n and \r\n correctly) + eof_idx = len(current_text) + sl, sc = line_col_from_index(eof_idx) + new_text = ("\n" if not current_text.endswith("\n") else "") + text_field + at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + current_text = current_text + new_text + else: + return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + + import hashlib + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + if at_edits: + params_text: Dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": {"refresh": "immediate", "validate": (options or {}).get("validate", "standard")} + } + resp_text = send_command_with_retry("manage_script", params_text) + if not (isinstance(resp_text, dict) and resp_text.get("success")): + return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + except Exception as e: + return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") + + if struct_edits: + opts2 = dict(options or {}) + opts2.setdefault("applyMode", "sequential") + opts2.setdefault("refresh", "immediate") + params_struct: Dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": struct_edits, + "options": opts2 + } + resp_struct = send_command_with_retry("manage_script", params_struct) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") + + return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") + + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition + # so header guards and validation run on the C# side. + # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). + text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } + structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} + if not text_ops.issubset(structured_kinds): + # Convert to apply_text_edits payload + try: + current_text = contents + def line_col_from_index(idx: int) -> Tuple[int, int]: + # 1-based line/col + line = current_text.count("\n", 0, idx) + 1 + last_nl = current_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: List[Dict[str, Any]] = [] + import re as _re + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + # aliasing for text field + text_field = e.get("text") or e.get("insert") or e.get("content") or "" + if op == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "before").lower() + m = _re.search(anchor, current_text, _re.MULTILINE) + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") + idx = m.start() if position == "before" else m.end() + sl, sc = line_col_from_index(idx) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": sl, + "endCol": sc, + "newText": text_field or "" + }) + # Update local snapshot to keep subsequent anchors stable + current_text = current_text[:idx] + (text_field or "") + current_text[idx:] + elif op == "replace_range": + # Directly forward if already in line/col form + if "startLine" in e: + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + # If only indices provided, skip (we don't support index-based here) + return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") + elif op == "regex_replace": + pattern = e.get("pattern") or "" + repl = text_field + m = _re.search(pattern, current_text, _re.MULTILINE) + if not m: + continue + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": el, + "endCol": ec, + "newText": repl + }) + current_text = current_text[:m.start()] + repl + current_text[m.end():] + else: + return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") + + if not at_edits: + return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") + + # Send to Unity with precondition SHA to enforce guards and immediate refresh + import hashlib + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + params: Dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": { + "refresh": "immediate", + "validate": (options or {}).get("validate", "standard") + } + } + resp = send_command_with_retry("manage_script", params) + return _with_norm(resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, normalized_for_echo, routing="text") + except Exception as e: + return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") + + # For regex_replace on large files, support preview/confirm + if "regex_replace" in text_ops and not (options or {}).get("confirm"): + try: + preview_text = _apply_edits_locally(contents, edits) + import difflib + diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + if len(diff) > 800: + diff = diff[:800] + ["... (diff truncated) ..."] + return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") + except Exception as e: + return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") + # 2) apply edits locally (only if not text-ops) + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + if preview: + # Produce a compact unified diff limited to small context + import difflib + a = contents.splitlines() + b = new_contents.splitlines() + diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3)) + # Limit diff size to keep responses small + if len(diff) > 2000: + diff = diff[:2000] + ["... (diff truncated) ..."] + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + + # 3) update to Unity + # Default refresh/validate for natural usage on text path as well + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "immediate") + + params: Dict[str, Any] = { + "action": "update", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "encodedContents": base64.b64encode(new_contents.encode("utf-8")).decode("ascii"), + "contentsEncoded": True, + } + if options is not None: + params["options"] = options + write_resp = send_command_with_retry("manage_script", params) + return _with_norm(write_resp if isinstance(write_resp, dict) else {"success": False, "message": str(write_resp)}, normalized_for_echo, routing="text") + + + + + # safe_script_edit removed to simplify API; clients should call script_apply_edits directly diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py new file mode 100644 index 00000000..2d4a47bf --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -0,0 +1,310 @@ +""" +Resource wrapper tools so clients that do not expose MCP resources primitives +can still list and read files via normal tools. These call into the same +safe path logic (re-implemented here to avoid importing server.py). +""" +from __future__ import annotations + +from typing import Dict, Any, List +import re +from pathlib import Path +import fnmatch +import hashlib +import os + +from mcp.server.fastmcp import FastMCP, Context +from unity_connection import send_command_with_retry + + +def _resolve_project_root(override: str | None) -> Path: + # 1) Explicit override + if override: + pr = Path(override).expanduser().resolve() + if (pr / "Assets").exists(): + return pr + # 2) Environment + env = os.environ.get("UNITY_PROJECT_ROOT") + if env: + env_path = Path(env).expanduser() + # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir + pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() + if (pr / "Assets").exists(): + return pr + # 3) Ask Unity via manage_editor.get_project_root + try: + resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) + if isinstance(resp, dict) and resp.get("success"): + pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve() + if pr and (pr / "Assets").exists(): + return pr + except Exception: + pass + + # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) + cur = Path.cwd().resolve() + for _ in range(6): + if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): + return cur + if cur.parent == cur: + break + cur = cur.parent + # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings + try: + root = Path.cwd().resolve() + max_depth = 3 + for path in root.rglob("*"): + try: + rel_depth = len(path.relative_to(root).parts) + except Exception: + continue + if rel_depth > max_depth: + continue + if path.is_dir() and (path / "Assets").exists() and (path / "ProjectSettings").exists(): + return path + except Exception: + pass + # 6) Fallback: CWD + return Path.cwd().resolve() + + +def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: + raw: str | None = None + if uri.startswith("unity://path/"): + raw = uri[len("unity://path/"):] + elif uri.startswith("file://"): + raw = uri[len("file://"):] + elif uri.startswith("Assets/"): + raw = uri + if raw is None: + return None + p = (project / raw).resolve() + try: + p.relative_to(project) + except ValueError: + return None + return p + + +def register_resource_tools(mcp: FastMCP) -> None: + """Registers list_resources and read_resource wrapper tools.""" + + @mcp.tool() + async def list_resources( + ctx: Context | None = None, + pattern: str | None = "*.cs", + under: str = "Assets", + limit: int = 200, + project_root: str | None = None, + ) -> Dict[str, Any]: + """ + Lists project URIs (unity://path/...) under a folder (default: Assets). + - pattern: glob like *.cs or *.shader (None to list all files) + - under: relative folder under project root + - limit: max results + """ + try: + project = _resolve_project_root(project_root) + base = (project / under).resolve() + try: + base.relative_to(project) + except ValueError: + return {"success": False, "error": "Base path must be under project root"} + + matches: List[str] = [] + for p in base.rglob("*"): + if not p.is_file(): + continue + if pattern and not fnmatch.fnmatch(p.name, pattern): + continue + rel = p.relative_to(project).as_posix() + matches.append(f"unity://path/{rel}") + if len(matches) >= max(1, limit): + break + + # Always include the canonical spec resource so NL clients can discover it + if "unity://spec/script-edits" not in matches: + matches.append("unity://spec/script-edits") + + return {"success": True, "data": {"uris": matches, "count": len(matches)}} + except Exception as e: + return {"success": False, "error": str(e)} + + @mcp.tool() + async def read_resource( + uri: str, + ctx: Context | None = None, + start_line: int | None = None, + line_count: int | None = None, + head_bytes: int | None = None, + tail_lines: int | None = None, + project_root: str | None = None, + request: str | None = None, + ) -> Dict[str, Any]: + """ + Reads a resource by unity://path/... URI with optional slicing. + One of line window (start_line/line_count) or head_bytes can be used to limit size. + """ + try: + # Serve the canonical spec directly when requested (allow bare or with scheme) + if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): + spec_json = ( + '{\n' + ' "name": "Unity MCP — Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "\u2190 server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} + + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + # Natural-language convenience: request like "last 120 lines", "first 200 lines", + # "show 40 lines around MethodName", etc. + if request: + req = request.strip().lower() + m = re.search(r"last\s+(\d+)\s+lines", req) + if m: + tail_lines = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s+lines", req) + if m: + start_line = 1 + line_count = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s*bytes", req) + if m: + head_bytes = int(m.group(1)) + m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + if m: + window = int(m.group(1)) + method = m.group(2) + # naive search for method header to get a line number + text_all = p.read_text(encoding="utf-8") + lines_all = text_all.splitlines() + pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + hit_line = None + for i, line in enumerate(lines_all, start=1): + if pat.search(line): + hit_line = i + break + if hit_line: + half = max(1, window // 2) + start_line = max(1, hit_line - half) + line_count = window + + # Mutually exclusive windowing options precedence: + # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text + if head_bytes and head_bytes > 0: + raw = p.read_bytes()[: head_bytes] + text = raw.decode("utf-8", errors="replace") + else: + text = p.read_text(encoding="utf-8") + if tail_lines is not None and tail_lines > 0: + lines = text.splitlines() + n = max(0, tail_lines) + text = "\n".join(lines[-n:]) + elif start_line is not None and line_count is not None and line_count >= 0: + lines = text.splitlines() + s = max(0, start_line - 1) + e = min(len(lines), s + line_count) + text = "\n".join(lines[s:e]) + + sha = hashlib.sha256(text.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": text, "metadata": {"sha256": sha}}} + except Exception as e: + return {"success": False, "error": str(e)} + + @mcp.tool() + async def find_in_file( + uri: str, + pattern: str, + ctx: Context | None = None, + ignore_case: bool | None = True, + project_root: str | None = None, + max_results: int | None = 200, + ) -> Dict[str, Any]: + """ + Searches a file with a regex pattern and returns line numbers and excerpts. + - uri: unity://path/Assets/... or file path form supported by read_resource + - pattern: regular expression (Python re) + - ignore_case: case-insensitive by default + - max_results: cap results to avoid huge payloads + """ + # re is already imported at module level + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + text = p.read_text(encoding="utf-8") + flags = re.MULTILINE + if ignore_case: + flags |= re.IGNORECASE + rx = re.compile(pattern, flags) + + results = [] + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + if rx.search(line): + results.append({"line": i, "text": line}) + if max_results and len(results) >= max_results: + break + + return {"success": True, "data": {"matches": results, "count": len(results)}} + except Exception as e: + return {"success": False, "error": str(e)} + + diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index a284f539..13713b36 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,12 +1,15 @@ -import socket +import contextlib +import errno import json import logging +import random +import socket +import struct +import threading +import time from dataclasses import dataclass from pathlib import Path -import time -import random -import errno -from typing import Dict, Any +from typing import Any, Dict from config import config from port_discovery import PortDiscovery @@ -23,11 +26,13 @@ class UnityConnection: host: str = config.unity_host port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication + use_framing: bool = False # Negotiated per-connection def __post_init__(self): """Set port from discovery if not explicitly provided""" if self.port is None: self.port = PortDiscovery.discover_unity_port() + self._io_lock = threading.Lock() def connect(self) -> bool: """Establish a connection to the Unity Editor.""" @@ -36,10 +41,38 @@ def connect(self) -> bool: try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + logger.debug(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + require_framing = getattr(config, "require_framing", True) + self.sock.settimeout(getattr(config, "handshake_timeout", 1.0)) + greeting = self.sock.recv(256) + text = greeting.decode('ascii', errors='ignore') if greeting else '' + if 'FRAMING=1' in text: + self.use_framing = True + logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') + else: + if require_framing: + # Best-effort advisory; peer may ignore if not framed-capable + with contextlib.suppress(Exception): + msg = b'Unity MCP requires FRAMING=1' + header = struct.pack('>Q', len(msg)) + self.sock.sendall(header + msg) + raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + else: + self.use_framing = False + logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') + finally: + self.sock.settimeout(config.connection_timeout) return True except Exception as e: logger.error(f"Failed to connect to Unity: {str(e)}") + try: + if self.sock: + self.sock.close() + except Exception: + pass self.sock = None return False @@ -53,8 +86,35 @@ def disconnect(self): finally: self.sock = None + def _read_exact(self, sock: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise Exception("Connection closed before reading expected bytes") + data.extend(chunk) + return bytes(data) + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: """Receive a complete response from Unity, handling chunked data.""" + if self.use_framing: + try: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0: + raise Exception("Invalid framed length: 0") + if payload_len > (64 * 1024 * 1024): + raise Exception(f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.info(f"Received framed response ({len(payload)} bytes)") + return payload + except socket.timeout as e: + logger.warning("Socket timeout during framed receive") + raise TimeoutError("Timeout receiving Unity response") from e + except Exception as e: + logger.error(f"Error during framed receive: {str(e)}") + raise + chunks = [] sock.settimeout(config.connection_timeout) # Use timeout from config try: @@ -148,15 +208,9 @@ def read_status_file() -> dict | None: for attempt in range(attempts + 1): try: - # Ensure connected - if not self.sock: - # During retries use short connect timeout - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(1.0) - self.sock.connect((self.host, self.port)) - # restore steady-state timeout for receive - self.sock.settimeout(config.connection_timeout) - logger.info(f"Connected to Unity at {self.host}:{self.port}") + # Ensure connected (handshake occurs within connect()) + if not self.sock and not self.connect(): + raise Exception("Could not connect to Unity") # Build payload if command_type == 'ping': @@ -165,18 +219,39 @@ def read_status_file() -> dict | None: command = {"type": command_type, "params": params or {}} payload = json.dumps(command, ensure_ascii=False).encode('utf-8') - # Send - self.sock.sendall(payload) + # Send/receive are serialized to protect the shared socket + with self._io_lock: + mode = 'framed' if self.use_framing else 'legacy' + with contextlib.suppress(Exception): + logger.debug( + "send %d bytes; mode=%s; head=%s", + len(payload), + mode, + (payload[:32]).decode('utf-8', 'ignore'), + ) + if self.use_framing: + header = struct.pack('>Q', len(payload)) + self.sock.sendall(header) + self.sock.sendall(payload) + else: + self.sock.sendall(payload) - # During retry bursts use a short receive timeout - if attempt > 0 and last_short_timeout is None: - last_short_timeout = self.sock.gettimeout() - self.sock.settimeout(1.0) - response_data = self.receive_full_response(self.sock) - # restore steady-state timeout if changed - if last_short_timeout is not None: - self.sock.settimeout(config.connection_timeout) - last_short_timeout = None + # During retry bursts use a short receive timeout + if attempt > 0 and last_short_timeout is None: + last_short_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + response_data = self.receive_full_response(self.sock) + with contextlib.suppress(Exception): + logger.debug( + "recv %d bytes; mode=%s; head=%s", + len(response_data), + mode, + (response_data[:32]).decode('utf-8', 'ignore'), + ) + # restore steady-state timeout if changed + if last_short_timeout is not None: + self.sock.settimeout(last_short_timeout) + last_short_timeout = None # Parse if command_type == 'ping': @@ -241,43 +316,22 @@ def read_status_file() -> dict | None: _unity_connection = None def get_unity_connection() -> UnityConnection: - """Retrieve or establish a persistent Unity connection.""" + """Retrieve or establish a persistent Unity connection. + + Note: Do NOT ping on every retrieval to avoid connection storms. Rely on + send_command() exceptions to detect broken sockets and reconnect there. + """ global _unity_connection if _unity_connection is not None: - try: - # Try to ping with a short timeout to verify connection - result = _unity_connection.send_command("ping") - # If we get here, the connection is still valid - logger.debug("Reusing existing Unity connection") - return _unity_connection - except Exception as e: - logger.warning(f"Existing connection failed: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - - # Create a new connection + return _unity_connection + logger.info("Creating new Unity connection") _unity_connection = UnityConnection() if not _unity_connection.connect(): _unity_connection = None raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") - - try: - # Verify the new connection works - _unity_connection.send_command("ping") - logger.info("Successfully established new Unity connection") - return _unity_connection - except Exception as e: - logger.error(f"Could not verify new connection: {str(e)}") - try: - _unity_connection.disconnect() - except: - pass - _unity_connection = None - raise ConnectionError(f"Could not establish valid Unity connection: {str(e)}") + logger.info("Connected to Unity on startup") + return _unity_connection # ----------------------------- diff --git a/UnityMcpBridge/UnityMcpServer~/src/uv.lock b/UnityMcpBridge/UnityMcpServer~/src/uv.lock index 4f43d249..9c59867d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/uv.lock +++ b/UnityMcpBridge/UnityMcpServer~/src/uv.lock @@ -160,6 +160,21 @@ cli = [ { name = "typer" }, ] +[[package]] +name = "mcpforunityserver" +version = "3.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -370,21 +385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] -[[package]] -name = "mcpforunityserver" -version = "2.1.2" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, -] - [[package]] name = "uvicorn" version = "0.34.0" diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index a96a6f00..473ceda2 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "3.0.0", + "version": "3.0.1", "displayName": "MCP for Unity", "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/mcp_source.py b/mcp_source.py index bb8f16cb..7d5a48a3 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -165,4 +165,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py new file mode 100644 index 00000000..7495ccb3 --- /dev/null +++ b/test_unity_socket_framing.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import socket, struct, json, sys + +HOST = "127.0.0.1" +PORT = 6400 +try: + SIZE_MB = int(sys.argv[1]) +except (IndexError, ValueError): + SIZE_MB = 5 # e.g., 5 or 10 +FILL = "R" + +def recv_exact(sock, n): + buf = bytearray(n) + view = memoryview(buf) + off = 0 + while off < n: + r = sock.recv_into(view[off:]) + if r == 0: + raise RuntimeError("socket closed") + off += r + return bytes(buf) + +def is_valid_json(b): + try: + json.loads(b.decode("utf-8")) + return True + except Exception: + return False + +def recv_legacy_json(sock, timeout=60): + sock.settimeout(timeout) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + data = b"".join(chunks) + if not data: + raise RuntimeError("no data, socket closed") + return data + chunks.append(chunk) + data = b"".join(chunks) + if data.strip() == b"ping": + return data + if is_valid_json(data): + return data + +def main(): + body = { + "type": "read_console", + "params": { + "action": "get", + "types": ["all"], + "count": 1000, + "format": "detailed", + "includeStacktrace": True, + "filterText": FILL * (SIZE_MB * 1024 * 1024) + } + } + body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + + with socket.create_connection((HOST, PORT), timeout=5) as s: + s.settimeout(2) + # Read optional greeting + try: + greeting = s.recv(256) + except Exception: + greeting = b"" + greeting_text = greeting.decode("ascii", errors="ignore").strip() + print(f"Greeting: {greeting_text or '(none)'}") + + framing = "FRAMING=1" in greeting_text + print(f"Using framing? {framing}") + + s.settimeout(120) + if framing: + header = struct.pack(">Q", len(body_bytes)) + s.sendall(header + body_bytes) + resp_len = struct.unpack(">Q", recv_exact(s, 8))[0] + print(f"Response framed length: {resp_len}") + MAX_RESP = 128 * 1024 * 1024 + if resp_len <= 0 or resp_len > MAX_RESP: + raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") + resp = recv_exact(s, resp_len) + else: + s.sendall(body_bytes) + resp = recv_legacy_json(s) + + print(f"Response bytes: {len(resp)}") + print(f"Response head: {resp[:120].decode('utf-8','ignore')}") + +if __name__ == "__main__": + main() + + diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py new file mode 100644 index 00000000..3b7f0c16 --- /dev/null +++ b/tests/test_logging_stdout.py @@ -0,0 +1,64 @@ +import ast +from pathlib import Path + +import pytest + + +# locate server src dynamically to avoid hardcoded layout assumptions +ROOT = Path(__file__).resolve().parents[1] +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +SRC = next((p for p in candidates if p.exists()), None) +if SRC is None: + searched = "\n".join(str(p) for p in candidates) + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, + ) + + +@pytest.mark.skip(reason="TODO: ensure server logs only to stderr and rotating file") +def test_no_stdout_output_from_tools(): + pass + + +def test_no_print_statements_in_codebase(): + """Ensure no stray print/sys.stdout writes remain in server source.""" + offenders = [] + syntax_errors = [] + for py_file in SRC.rglob("*.py"): + try: + text = py_file.read_text(encoding="utf-8", errors="strict") + except UnicodeDecodeError: + # Be tolerant of encoding edge cases in source tree + text = py_file.read_text(encoding="utf-8", errors="ignore") + try: + tree = ast.parse(text, filename=str(py_file)) + except SyntaxError: + syntax_errors.append(py_file.relative_to(SRC)) + continue + + class StdoutVisitor(ast.NodeVisitor): + def __init__(self): + self.hit = False + + def visit_Call(self, node: ast.Call): + # print(...) + if isinstance(node.func, ast.Name) and node.func.id == "print": + self.hit = True + # sys.stdout.write(...) + if isinstance(node.func, ast.Attribute) and node.func.attr == "write": + val = node.func.value + if isinstance(val, ast.Attribute) and val.attr == "stdout": + if isinstance(val.value, ast.Name) and val.value.id == "sys": + self.hit = True + self.generic_visit(node) + + v = StdoutVisitor() + v.visit(tree) + if v.hit: + offenders.append(py_file.relative_to(SRC)) + assert not syntax_errors, "syntax errors in: " + ", ".join(str(e) for e in syntax_errors) + assert not offenders, "stdout writes found in: " + ", ".join(str(o) for o in offenders) diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py new file mode 100644 index 00000000..62cc1ac1 --- /dev/null +++ b/tests/test_resources_api.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.mark.xfail(strict=False, reason="resource.list should return only Assets/**/*.cs and reject traversal") +def test_resource_list_filters_and_rejects_traversal(): + pass + + +@pytest.mark.xfail(strict=False, reason="resource.list should reject outside paths including drive letters and symlinks") +def test_resource_list_rejects_outside_paths(): + pass diff --git a/tests/test_script_editing.py b/tests/test_script_editing.py new file mode 100644 index 00000000..88046d00 --- /dev/null +++ b/tests/test_script_editing.py @@ -0,0 +1,36 @@ +import pytest + + +@pytest.mark.xfail(strict=False, reason="pending: create new script, validate, apply edits, build and compile scene") +def test_script_edit_happy_path(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: multiple micro-edits debounce to single compilation") +def test_micro_edits_debounce(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: line ending variations handled correctly") +def test_line_endings_and_columns(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: regex_replace no-op with allow_noop honored") +def test_regex_replace_noop_allowed(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: large edit size boundaries and overflow protection") +def test_large_edit_size_and_overflow(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: symlink and junction protections on edits") +def test_symlink_and_junction_protection(): + pass + + +@pytest.mark.xfail(strict=False, reason="pending: atomic write guarantees") +def test_atomic_write_guarantees(): + pass diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py new file mode 100644 index 00000000..9b953a1a --- /dev/null +++ b/tests/test_script_tools.py @@ -0,0 +1,123 @@ +import sys +import pathlib +import importlib.util +import types +import pytest + +# add server src to path and load modules without triggering package imports +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + +# stub mcp.server.fastmcp to satisfy imports without full dependency +mcp_pkg = types.ModuleType("mcp") +server_pkg = types.ModuleType("mcp.server") +fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") + +class _Dummy: + pass + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +server_pkg.fastmcp = fastmcp_pkg +mcp_pkg.server = server_pkg +sys.modules.setdefault("mcp", mcp_pkg) +sys.modules.setdefault("mcp.server", server_pkg) +sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + +def load_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +manage_script_module = load_module(SRC / "tools" / "manage_script.py", "manage_script_module") +manage_asset_module = load_module(SRC / "tools" / "manage_asset.py", "manage_asset_module") + + +class DummyMCP: + def __init__(self): + self.tools = {} + + def tool(self): + def decorator(func): + self.tools[func.__name__] = func + return func + return decorator + +def setup_manage_script(): + mcp = DummyMCP() + manage_script_module.register_manage_script_tools(mcp) + return mcp.tools + +def setup_manage_asset(): + mcp = DummyMCP() + manage_asset_module.register_manage_asset_tools(mcp) + return mcp.tools + +def test_apply_text_edits_long_file(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + captured = {} + + def fake_send(cmd, params): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} + resp = apply_edits(None, "unity://path/Assets/Scripts/LongFile.cs", [edit]) + assert captured["cmd"] == "manage_script" + assert captured["params"]["action"] == "apply_text_edits" + assert captured["params"]["edits"][0]["startLine"] == 1005 + assert resp["success"] is True + +def test_sequential_edits_use_precondition(monkeypatch): + tools = setup_manage_script() + apply_edits = tools["apply_text_edits"] + calls = [] + + def fake_send(cmd, params): + calls.append(params) + return {"success": True, "sha256": f"hash{len(calls)}"} + + monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + + edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} + resp1 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit1]) + edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} + resp2 = apply_edits(None, "unity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"]) + + assert calls[1]["precondition_sha256"] == resp1["sha256"] + assert resp2["sha256"] == "hash2" + +def test_manage_asset_prefab_modify_request(monkeypatch): + tools = setup_manage_asset() + manage_asset = tools["manage_asset"] + captured = {} + + async def fake_async(cmd, params, loop=None): + captured["cmd"] = cmd + captured["params"] = params + return {"success": True} + + monkeypatch.setattr(manage_asset_module, "async_send_command_with_retry", fake_async) + monkeypatch.setattr(manage_asset_module, "get_unity_connection", lambda: object()) + + async def run(): + resp = await manage_asset( + None, + action="modify", + path="Assets/Prefabs/Player.prefab", + properties={"hp": 100}, + ) + assert captured["cmd"] == "manage_asset" + assert captured["params"]["action"] == "modify" + assert captured["params"]["path"] == "Assets/Prefabs/Player.prefab" + assert captured["params"]["properties"] == {"hp": 100} + assert resp["success"] is True + + import asyncio + asyncio.run(run()) diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py new file mode 100644 index 00000000..2008c4c1 --- /dev/null +++ b/tests/test_transport_framing.py @@ -0,0 +1,168 @@ +import sys +import json +import struct +import socket +import threading +import time +import select +from pathlib import Path + +import pytest + +# locate server src dynamically to avoid hardcoded layout assumptions +ROOT = Path(__file__).resolve().parents[1] +candidates = [ + ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "UnityMcpServer~" / "src", +] +SRC = next((p for p in candidates if p.exists()), None) +if SRC is None: + searched = "\n".join(str(p) for p in candidates) + pytest.skip( + "Unity MCP server source not found. Tried:\n" + searched, + allow_module_level=True, + ) +sys.path.insert(0, str(SRC)) + +from unity_connection import UnityConnection + + +def start_dummy_server(greeting: bytes, respond_ping: bool = False): + """Start a minimal TCP server for handshake tests.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + ready = threading.Event() + + def _run(): + ready.set() + conn, _ = sock.accept() + conn.settimeout(1.0) + if greeting: + conn.sendall(greeting) + if respond_ping: + try: + # Read exactly n bytes helper + def _read_exact(n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) + if not chunk: + break + buf += chunk + return buf + + header = _read_exact(8) + if len(header) == 8: + length = struct.unpack(">Q", header)[0] + payload = _read_exact(length) + if payload == b'{"type":"ping"}': + resp = b'{"type":"pong"}' + conn.sendall(struct.pack(">Q", len(resp)) + resp) + except Exception: + pass + time.sleep(0.1) + try: + conn.close() + except Exception: + pass + finally: + sock.close() + + threading.Thread(target=_run, daemon=True).start() + ready.wait() + return port + + +def start_handshake_enforcing_server(): + """Server that drops connection if client sends data before handshake.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + port = sock.getsockname()[1] + ready = threading.Event() + + def _run(): + ready.set() + conn, _ = sock.accept() + # If client sends any data before greeting, disconnect (poll briefly) + deadline = time.time() + 0.5 + while time.time() < deadline: + r, _, _ = select.select([conn], [], [], 0.05) + if r: + conn.close() + sock.close() + return + conn.sendall(b"MCP/0.1 FRAMING=1\n") + time.sleep(0.1) + conn.close() + sock.close() + + threading.Thread(target=_run, daemon=True).start() + ready.wait() + return port + + +def test_handshake_requires_framing(): + port = start_dummy_server(b"MCP/0.1\n") + conn = UnityConnection(host="127.0.0.1", port=port) + assert conn.connect() is False + assert conn.sock is None + + +def test_small_frame_ping_pong(): + port = start_dummy_server(b"MCP/0.1 FRAMING=1\n", respond_ping=True) + conn = UnityConnection(host="127.0.0.1", port=port) + try: + assert conn.connect() is True + assert conn.use_framing is True + payload = b'{"type":"ping"}' + conn.sock.sendall(struct.pack(">Q", len(payload)) + payload) + resp = conn.receive_full_response(conn.sock) + assert json.loads(resp.decode("utf-8"))["type"] == "pong" + finally: + conn.disconnect() + + +def test_unframed_data_disconnect(): + port = start_handshake_enforcing_server() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(("127.0.0.1", port)) + sock.settimeout(1.0) + sock.sendall(b"BAD") + time.sleep(0.4) + try: + data = sock.recv(1024) + assert data == b"" + except (ConnectionResetError, ConnectionAbortedError): + # Some platforms raise instead of returning empty bytes when the + # server closes the connection after detecting pre-handshake data. + pass + finally: + sock.close() + + +@pytest.mark.skip(reason="TODO: zero-length payload should raise error") +def test_zero_length_payload_error(): + pass + + +@pytest.mark.skip(reason="TODO: oversized payload should disconnect") +def test_oversized_payload_rejected(): + pass + + +@pytest.mark.skip(reason="TODO: partial header/payload triggers timeout and disconnect") +def test_partial_frame_timeout(): + pass + + +@pytest.mark.skip(reason="TODO: concurrency test with parallel tool invocations") +def test_parallel_invocations_no_interleaving(): + pass + + +@pytest.mark.skip(reason="TODO: reconnection after drop mid-command") +def test_reconnect_mid_command(): + pass