From c8230d13823ca24e5e74ca106da1a970427b4556 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Tue, 30 Sep 2025 15:59:09 -0400 Subject: [PATCH] Autoformat --- .github/scripts/mark_skipped.py | 10 +- .github/workflows/bump-version.yml | 2 +- .github/workflows/claude-nl-suite.yml | 1865 ++++++++--------- .github/workflows/github-repo-stats.yml | 1 - .github/workflows/unity-tests.yml | 2 +- .../UnityMCPTests/Assets/Scripts/Hello.cs | 5 - .../Scripts/LongUnityScriptClaudeTest.cs | 2 - .../Scripts/TestAsmdef/CustomComponent.cs | 4 +- .../EditMode/Tools/AIPropertyMatchingTests.cs | 38 +- .../EditMode/Tools/ComponentResolverTests.cs | 38 +- .../EditMode/Tools/ManageGameObjectTests.cs | 72 +- .../Tools/ManageScriptValidationTests.cs | 48 +- UnityMcpBridge/Editor/AssemblyInfo.cs | 2 +- .../Editor/Data/DefaultServerConfig.cs | 1 - UnityMcpBridge/Editor/External/Tommy.cs | 160 +- UnityMcpBridge/Editor/Helpers/ExecPath.cs | 4 +- .../Editor/Helpers/GameObjectSerializer.cs | 71 +- .../Editor/Helpers/McpConfigFileHelper.cs | 1 - UnityMcpBridge/Editor/Helpers/McpLog.cs | 2 - .../Editor/Helpers/PackageDetector.cs | 2 - .../Editor/Helpers/PackageInstaller.cs | 8 +- UnityMcpBridge/Editor/Helpers/PortManager.cs | 10 +- UnityMcpBridge/Editor/Helpers/Response.cs | 1 - .../Editor/Helpers/ServerInstaller.cs | 6 +- .../Editor/Helpers/ServerPathResolver.cs | 2 - .../Editor/Helpers/TelemetryHelper.cs | 56 +- .../Editor/Helpers/Vector3Helper.cs | 1 - UnityMcpBridge/Editor/MCPForUnityBridge.cs | 437 ++-- .../Editor/Tools/CommandRegistry.cs | 1 - .../Editor/Tools/ManageGameObject.cs | 327 +-- UnityMcpBridge/Editor/Tools/ManageScene.cs | 1 - UnityMcpBridge/Editor/Tools/ManageScript.cs | 1 - UnityMcpBridge/Editor/Tools/ManageShader.cs | 2 +- UnityMcpBridge/Editor/Tools/ReadConsole.cs | 11 +- .../Editor/Windows/MCPForUnityEditorWindow.cs | 992 ++++----- .../Editor/Windows/VSCodeManualSetupWindow.cs | 10 +- .../Serialization/UnityTypeConverters.cs | 6 +- UnityMcpBridge/UnityMcpServer~/src/server.py | 34 +- mcp_source.py | 24 +- test_unity_socket_framing.py | 17 +- tests/conftest.py | 1 - tests/test_edit_normalization_and_noop.py | 38 +- tests/test_edit_strict_and_warnings.py | 25 +- tests/test_find_in_file_minimal.py | 10 +- tests/test_get_sha.py | 6 +- tests/test_improved_anchor_matching.py | 65 +- tests/test_logging_stdout.py | 6 +- tests/test_manage_script_uri.py | 24 +- tests/test_read_console_truncate.py | 19 +- tests/test_read_resource_minimal.py | 8 +- tests/test_resources_api.py | 13 +- tests/test_script_tools.py | 54 +- tests/test_telemetry_endpoint_validation.py | 19 +- tests/test_telemetry_queue_worker.py | 10 +- tests/test_telemetry_subaction.py | 5 +- tests/test_transport_framing.py | 14 +- tests/test_validate_script_summary.py | 10 +- tools/stress_mcp.py | 59 +- 58 files changed, 2405 insertions(+), 2258 deletions(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index d2e7ca7b..22999c49 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -29,6 +29,7 @@ r"validation error .* ctx", ] + def should_skip(msg: str) -> bool: if not msg: return False @@ -38,6 +39,7 @@ def should_skip(msg: str) -> bool: return True return False + def summarize_counts(ts: ET.Element): tests = 0 failures = 0 @@ -53,6 +55,7 @@ def summarize_counts(ts: ET.Element): 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.") @@ -79,7 +82,8 @@ def main(path: str) -> int: for n in nodes: msg = (n.get("message") or "") + "\n" + (n.text or "") if should_skip(msg): - first_match_text = (n.text or "").strip() or first_match_text + first_match_text = ( + n.text or "").strip() or first_match_text to_skip = True if to_skip: for n in nodes: @@ -98,12 +102,14 @@ def main(path: str) -> int: if changed: tree.write(path, encoding="utf-8", xml_declaration=True) - print(f"[mark_skipped] Updated {path}: converted environmental failures to skipped.") + 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] diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 7ce2a99e..8f562c30 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -12,7 +12,7 @@ on: - major default: patch required: true - + jobs: bump: name: "Bump version and tag" diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 539263d6..09176a3a 100644 --- a/.github/workflows/claude-nl-suite.yml +++ b/.github/workflows/claude-nl-suite.yml @@ -1,969 +1,966 @@ name: Claude NL/T Full Suite (Unity live) on: [workflow_dispatch] - + permissions: - contents: read - checks: 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_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 - + UNITY_IMAGE: unityci/editor:ubuntu-2021.3.45f1-linux-il2cpp-3 + jobs: - nl-suite: - 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" ]; }; 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 + nl-suite: + 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" ]; }; 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 + + # --- Licensing: allow both ULF and EBL when available --- + - name: Decide license sources + id: lic + shell: bash + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -eu + use_ulf=false; use_ebl=false + [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true + [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true + echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" + echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" + echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Stage Unity .ulf license (from secret) + if: steps.lic.outputs.use_ulf == 'true' + id: ulf + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + shell: bash + run: | + set -eu + mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" + f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" + if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then + printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" + else + printf "%s" "$UNITY_LICENSE" > "$f" + fi + chmod 600 "$f" || true + # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: + if head -c 100 "$f" | grep -qi '<\?xml'; then + mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" + mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" + echo "ok=false" >> "$GITHUB_OUTPUT" + elif grep -qi '' "$f"; then + # provide it in the standard local-share path too + cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" + echo "ok=true" >> "$GITHUB_OUTPUT" + else + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + # --- Activate via EBL inside the same Unity image (writes host-side entitlement) --- + - name: Activate Unity (EBL via container - host-mount) + if: steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + run: | + set -euxo pipefail + # host dirs to receive the full Unity config and local-share + mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" + + # Try Pro first if serial is present, otherwise named-user EBL. + docker run --rm --network host \ + -e HOME=/root \ + -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" bash -lc ' + set -euxo pipefail + if [[ -n "${UNITY_SERIAL:-}" ]]; then + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true + else + /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true + fi + ls -la /root/.config/unity3d/Unity/licenses || true + ' - # --- Licensing: allow both ULF and EBL when available --- - - name: Decide license sources - id: lic - shell: bash - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -eu - use_ulf=false; use_ebl=false - [[ -n "${UNITY_LICENSE:-}" ]] && use_ulf=true - [[ -n "${UNITY_EMAIL:-}" && -n "${UNITY_PASSWORD:-}" ]] && use_ebl=true - echo "use_ulf=$use_ulf" >> "$GITHUB_OUTPUT" - echo "use_ebl=$use_ebl" >> "$GITHUB_OUTPUT" - echo "has_serial=$([[ -n "${UNITY_SERIAL:-}" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" - - - name: Stage Unity .ulf license (from secret) - if: steps.lic.outputs.use_ulf == 'true' - id: ulf - env: - UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} - shell: bash - run: | - set -eu - mkdir -p "$RUNNER_TEMP/unity-license-ulf" "$RUNNER_TEMP/unity-local/Unity" - f="$RUNNER_TEMP/unity-license-ulf/Unity_lic.ulf" - if printf "%s" "$UNITY_LICENSE" | base64 -d - >/dev/null 2>&1; then - printf "%s" "$UNITY_LICENSE" | base64 -d - > "$f" + # Verify entitlement written to host mount; allow ULF-only runs to proceed + if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then + if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then + echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." else - printf "%s" "$UNITY_LICENSE" > "$f" + echo "No entitlement produced and no valid ULF; cannot continue." >&2 + exit 1 fi - chmod 600 "$f" || true - # If someone pasted an entitlement XML into UNITY_LICENSE by mistake, re-home it: - if head -c 100 "$f" | grep -qi '<\?xml'; then - mkdir -p "$RUNNER_TEMP/unity-config/Unity/licenses" - mv "$f" "$RUNNER_TEMP/unity-config/Unity/licenses/UnityEntitlementLicense.xml" - echo "ok=false" >> "$GITHUB_OUTPUT" - elif grep -qi '' "$f"; then - # provide it in the standard local-share path too - cp -f "$f" "$RUNNER_TEMP/unity-local/Unity/Unity_lic.ulf" - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step + + # ---------- Warm up project (import Library once) ---------- + - name: Warm up project (import Library once) + if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + docker run --rm --network host \ + -e HOME=/root \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${manual_args[@]}" \ + -quit + + # ---------- Clean old MCP status ---------- + - name: Clean old MCP status + 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.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' + shell: bash + env: + UNITY_IMAGE: ${{ env.UNITY_IMAGE }} + ULF_OK: ${{ steps.ulf.outputs.ok }} + run: | + set -euxo pipefail + manual_args=() + if [[ "${ULF_OK:-false}" == "true" ]]; then + manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + fi + + mkdir -p "$RUNNER_TEMP/unity-status" + docker rm -f unity-mcp >/dev/null 2>&1 || true + docker run -d --name unity-mcp --network host \ + -e HOME=/root \ + -e UNITY_MCP_ALLOW_BATCH=1 \ + -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ + -e UNITY_MCP_BIND_HOST=127.0.0.1 \ + -v "${{ github.workspace }}:/workspace" -w /workspace \ + -v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \ + -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ + -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \ + "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ + -stackTraceLogType Full \ + -projectPath /workspace/TestProjects/UnityMCPTests \ + "${manual_args[@]}" \ + -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect + + # ---------- Wait for Unity bridge ---------- + - name: Wait for Unity bridge (robust) + shell: bash + run: | + set -euo pipefail + deadline=$((SECONDS+900)) # 15 min max + fatal_after=$((SECONDS+120)) # give licensing 2 min to settle + + # Fail fast only if container actually died + st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)" + case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac + + # Patterns + ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' + # Only truly fatal signals; allow transient "Licensing::..." chatter + license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' + + while [ $SECONDS -lt $deadline ]; do + logs="$(docker logs unity-mcp 2>&1 || true)" + + # 1) Primary: status JSON exposes TCP port + port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" + if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then + echo "Bridge ready on port $port" + exit 0 fi - # --- Activate via EBL inside the same Unity image (writes host-side entitlement) --- - - name: Activate Unity (EBL via container - host-mount) - if: steps.lic.outputs.use_ebl == 'true' - shell: bash - env: - UNITY_IMAGE: ${{ env.UNITY_IMAGE }} - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - run: | - set -euxo pipefail - # host dirs to receive the full Unity config and local-share - mkdir -p "$RUNNER_TEMP/unity-config" "$RUNNER_TEMP/unity-local" - - # Try Pro first if serial is present, otherwise named-user EBL. - docker run --rm --network host \ - -e HOME=/root \ - -e UNITY_EMAIL -e UNITY_PASSWORD -e UNITY_SERIAL \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ - "$UNITY_IMAGE" bash -lc ' - set -euxo pipefail - if [[ -n "${UNITY_SERIAL:-}" ]]; then - /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -serial "$UNITY_SERIAL" -quit || true - else - /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -username "$UNITY_EMAIL" -password "$UNITY_PASSWORD" -quit || true - fi - ls -la /root/.config/unity3d/Unity/licenses || true - ' - - # Verify entitlement written to host mount; allow ULF-only runs to proceed - if ! find "$RUNNER_TEMP/unity-config" -type f -iname "*.xml" | grep -q .; then - if [[ "${{ steps.ulf.outputs.ok }}" == "true" ]]; then - echo "EBL entitlement not found; proceeding with ULF-only (ok=true)." - else - echo "No entitlement produced and no valid ULF; cannot continue." >&2 - exit 1 - fi + # 2) Secondary: log markers + if echo "$logs" | grep -qiE "$ok_pat"; then + echo "Bridge ready (log markers)" + exit 0 fi - # EBL entitlement is already written directly to $RUNNER_TEMP/unity-config by the activation step - - # ---------- Warm up project (import Library once) ---------- - - name: Warm up project (import Library once) - if: steps.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' - shell: bash - env: - UNITY_IMAGE: ${{ env.UNITY_IMAGE }} - ULF_OK: ${{ steps.ulf.outputs.ok }} - run: | - set -euxo pipefail - manual_args=() - if [[ "${ULF_OK:-false}" == "true" ]]; then - manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") - fi - docker run --rm --network host \ - -e HOME=/root \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d" \ - "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - "${manual_args[@]}" \ - -quit - - # ---------- Clean old MCP status ---------- - - name: Clean old MCP status - 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.lic.outputs.use_ulf == 'true' || steps.lic.outputs.use_ebl == 'true' - shell: bash - env: - UNITY_IMAGE: ${{ env.UNITY_IMAGE }} - ULF_OK: ${{ steps.ulf.outputs.ok }} - run: | - set -euxo pipefail - manual_args=() - if [[ "${ULF_OK:-false}" == "true" ]]; then - manual_args=(-manualLicenseFile "/root/.local/share/unity3d/Unity/Unity_lic.ulf") + # Only treat license failures as fatal *after* warm-up + if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then + echo "::error::Fatal licensing signal detected after warm-up" + echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' + exit 1 fi - mkdir -p "$RUNNER_TEMP/unity-status" - docker rm -f unity-mcp >/dev/null 2>&1 || true - docker run -d --name unity-mcp --network host \ - -e HOME=/root \ - -e UNITY_MCP_ALLOW_BATCH=1 \ - -e UNITY_MCP_STATUS_DIR=/root/.unity-mcp \ - -e UNITY_MCP_BIND_HOST=127.0.0.1 \ - -v "${{ github.workspace }}:/workspace" -w /workspace \ - -v "$RUNNER_TEMP/unity-status:/root/.unity-mcp" \ - -v "$RUNNER_TEMP/unity-config:/root/.config/unity3d:ro" \ - -v "$RUNNER_TEMP/unity-local:/root/.local/share/unity3d:ro" \ - "$UNITY_IMAGE" /opt/unity/Editor/Unity -batchmode -nographics -logFile - \ - -stackTraceLogType Full \ - -projectPath /workspace/TestProjects/UnityMCPTests \ - "${manual_args[@]}" \ - -executeMethod MCPForUnity.Editor.MCPForUnityBridge.StartAutoConnect - - # ---------- Wait for Unity bridge ---------- - - name: Wait for Unity bridge (robust) - shell: bash - run: | - set -euo pipefail - deadline=$((SECONDS+900)) # 15 min max - fatal_after=$((SECONDS+120)) # give licensing 2 min to settle - - # Fail fast only if container actually died - st="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' unity-mcp 2>/dev/null || true)" - case "$st" in exited*|dead*) docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig'; exit 1;; esac - - # Patterns - ok_pat='(Bridge|MCP(For)?Unity|AutoConnect).*(listening|ready|started|port|bound)' - # Only truly fatal signals; allow transient "Licensing::..." chatter - license_fatal='No valid Unity|License is not active|cannot load ULF|Signature element not found|Token not found|0 entitlement|Entitlement.*(failed|denied)|License (activation|return|renewal).*(failed|expired|denied)' - - while [ $SECONDS -lt $deadline ]; do - logs="$(docker logs unity-mcp 2>&1 || true)" - - # 1) Primary: status JSON exposes TCP port - port="$(jq -r '.unity_port // empty' "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json 2>/dev/null | head -n1 || true)" - if [[ -n "${port:-}" ]] && timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port"; then - echo "Bridge ready on port $port" - exit 0 - fi - - # 2) Secondary: log markers - if echo "$logs" | grep -qiE "$ok_pat"; then - echo "Bridge ready (log markers)" - exit 0 - fi - - # Only treat license failures as fatal *after* warm-up - if [ $SECONDS -ge $fatal_after ] && echo "$logs" | grep -qiE "$license_fatal"; then - echo "::error::Fatal licensing signal detected after warm-up" - echo "$logs" | tail -n 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' - exit 1 - fi - - # If the container dies mid-wait, bail - st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" - if [[ "$st" != "running" ]]; then - echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' - exit 1 - fi - - sleep 2 - done - - echo "::error::Bridge not ready before deadline" - docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' - exit 1 + # If the container dies mid-wait, bail + st="$(docker inspect -f '{{.State.Status}}' unity-mcp 2>/dev/null || true)" + if [[ "$st" != "running" ]]; then + echo "::error::Unity container exited during wait"; docker logs unity-mcp --tail 200 | sed -E 's/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/Ig' + exit 1 + fi - # (moved) — return license after Unity is stopped - - # ---------- MCP client config ---------- - - name: Write MCP config (.claude/mcp.json) - run: | - set -eux - mkdir -p .claude - cat > .claude/mcp.json < .claude/mcp.json < .claude/settings.json <<'JSON' - { - "permissions": { - "allow": [ - "mcp__unity", - "Edit(reports/**)" - ], - "deny": [ - "Bash", - "MultiEdit", - "WebFetch", - "WebSearch", - "Task", - "TodoWrite", - "NotebookEdit", - "NotebookRead" - ] - } + } + JSON + + - name: Pin Claude tool permissions (.claude/settings.json) + run: | + set -eux + mkdir -p .claude + cat > .claude/settings.json <<'JSON' + { + "permissions": { + "allow": [ + "mcp__unity", + "Edit(reports/**)" + ], + "deny": [ + "Bash", + "MultiEdit", + "WebFetch", + "WebSearch", + "Task", + "TodoWrite", + "NotebookEdit", + "NotebookRead" + ] } - JSON - - # ---------- Reports & helper ---------- - - name: Prepare reports and dirs - run: | - set -eux - rm -f reports/*.xml reports/*.md || true - mkdir -p reports reports/_snapshots reports/_staging - - - 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: Verify Unity bridge status/port - run: | - set -euxo pipefail - ls -la "$RUNNER_TEMP/unity-status" || true - jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true - - shopt -s nullglob - status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json) - if ((${#status_files[@]})); then - port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \ - | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)" - else - port="" - fi - - echo "unity_port=$port" - if [[ -n "$port" ]]; then - timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" - fi - - # (removed) Revert helper and baseline snapshot are no longer used - - - # ---------- Run suite in two passes ---------- - - name: Run Claude NL pass - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' - continue-on-error: true - with: - use_node_cache: false - prompt_file: .claude/prompts/nl-unity-suite-nl.md - mcp_config: .claude/mcp.json - settings: .claude/settings.json - allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" - disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 - append_system_prompt: | - You are running the NL pass only. - - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. - - Write each to reports/${ID}_results.xml. - - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests. - - Stop after NL-4_results.xml is written. - timeout_minutes: "30" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - - - name: Run Claude T pass A-J - uses: anthropics/claude-code-base-action@beta - if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' - continue-on-error: true - with: - use_node_cache: false - prompt_file: .claude/prompts/nl-unity-suite-t.md - mcp_config: .claude/mcp.json - settings: .claude/settings.json - allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" - disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-5-haiku-20241022 - append_system_prompt: | - You are running the T pass (A–J) only. - Output requirements: - - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. - - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). - - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. - - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. - - Do not emit any NL-* fragments. - Stop condition: - - After T-J_results.xml is written, stop. - timeout_minutes: "30" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # (moved) Assert T coverage after staged fragments are promoted - - - name: Check T coverage incomplete (pre-retry) - id: t_cov - if: always() - shell: bash - run: | - set -euo pipefail - missing=() - for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do - if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then - missing+=("$id") - fi - done - echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" - if (( ${#missing[@]} )); then - echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" - fi - - - name: Retry T pass (Sonnet) if incomplete - if: steps.t_cov.outputs.missing != '0' - uses: anthropics/claude-code-base-action@beta - with: - use_node_cache: false - prompt_file: .claude/prompts/nl-unity-suite-t.md - mcp_config: .claude/mcp.json - settings: .claude/settings.json - allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" - disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" - model: claude-3-7-sonnet-20250219 - fallback_model: claude-3-5-haiku-20241022 - append_system_prompt: | - You are running the T pass only. - Output requirements: - - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. - - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). - - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. - - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. - - Do not emit any NL-* fragments. - Stop condition: - - After T-J_results.xml is written, stop. - timeout_minutes: "30" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - - name: Re-assert T coverage (post-retry) - if: always() - shell: bash - run: | - set -euo pipefail - missing=() - for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do - [[ -s "reports/${id}_results.xml" ]] || missing+=("$id") - done - if (( ${#missing[@]} )); then - echo "::error::Still missing T fragments: ${missing[*]}" - exit 1 + } + JSON + + # ---------- Reports & helper ---------- + - name: Prepare reports and dirs + run: | + set -eux + rm -f reports/*.xml reports/*.md || true + mkdir -p reports reports/_snapshots reports/_staging + + - 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: Verify Unity bridge status/port + run: | + set -euxo pipefail + ls -la "$RUNNER_TEMP/unity-status" || true + jq -r . "$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json | sed -n '1,80p' || true + + shopt -s nullglob + status_files=("$RUNNER_TEMP"/unity-status/unity-mcp-status-*.json) + if ((${#status_files[@]})); then + port="$(grep -hEo '"unity_port"[[:space:]]*:[[:space:]]*[0-9]+' "${status_files[@]}" \ + | sed -E 's/.*: *([0-9]+).*/\1/' | head -n1 || true)" + else + port="" + fi + + echo "unity_port=$port" + if [[ -n "$port" ]]; then + timeout 1 bash -lc "exec 3<>/dev/tcp/127.0.0.1/$port" && echo "TCP OK" + fi + + # (removed) Revert helper and baseline snapshot are no longer used + + # ---------- Run suite in two passes ---------- + - name: Run Claude NL pass + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + with: + use_node_cache: false + prompt_file: .claude/prompts/nl-unity-suite-nl.md + mcp_config: .claude/mcp.json + settings: .claude/settings.json + allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" + disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" + model: claude-3-7-sonnet-20250219 + append_system_prompt: | + You are running the NL pass only. + - Emit exactly NL-0, NL-1, NL-2, NL-3, NL-4. + - Write each to reports/${ID}_results.xml. + - Prefer a single MultiEdit(reports/**) batch. Do not emit any T-* tests. + - Stop after NL-4_results.xml is written. + timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Run Claude T pass A-J + uses: anthropics/claude-code-base-action@beta + if: steps.detect.outputs.anthropic_ok == 'true' && steps.detect.outputs.unity_ok == 'true' + continue-on-error: true + with: + use_node_cache: false + prompt_file: .claude/prompts/nl-unity-suite-t.md + mcp_config: .claude/mcp.json + settings: .claude/settings.json + allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" + disallowed_tools: "Bash,WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" + model: claude-3-5-haiku-20241022 + append_system_prompt: | + You are running the T pass (A–J) only. + Output requirements: + - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. + - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. + - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. + - Do not emit any NL-* fragments. + Stop condition: + - After T-J_results.xml is written, stop. + timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # (moved) Assert T coverage after staged fragments are promoted + + - name: Check T coverage incomplete (pre-retry) + id: t_cov + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + if [[ ! -s "reports/${id}_results.xml" && ! -s "reports/_staging/${id}_results.xml" ]]; then + missing+=("$id") fi - - # (kept) Finalize staged report fragments (promote to reports/) - - # (removed duplicate) Finalize staged report fragments - - - name: Assert T coverage (after promotion) - if: always() - shell: bash - run: | - set -euo pipefail - missing=() - for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do - if [[ ! -s "reports/${id}_results.xml" ]]; then - # Accept staged fragment as present - [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id") - fi - done - if (( ${#missing[@]} )); then - echo "::error::Missing T fragments: ${missing[*]}" - exit 1 + done + echo "missing=${#missing[@]}" >> "$GITHUB_OUTPUT" + if (( ${#missing[@]} )); then + echo "list=${missing[*]}" >> "$GITHUB_OUTPUT" + fi + + - name: Retry T pass (Sonnet) if incomplete + if: steps.t_cov.outputs.missing != '0' + uses: anthropics/claude-code-base-action@beta + with: + use_node_cache: false + prompt_file: .claude/prompts/nl-unity-suite-t.md + mcp_config: .claude/mcp.json + settings: .claude/settings.json + allowed_tools: "mcp__unity,Edit(reports/**),MultiEdit(reports/**)" + disallowed_tools: "Bash,MultiEdit(/!(reports/**)),WebFetch,WebSearch,Task,TodoWrite,NotebookEdit,NotebookRead" + model: claude-3-7-sonnet-20250219 + fallback_model: claude-3-5-haiku-20241022 + append_system_prompt: | + You are running the T pass only. + Output requirements: + - Emit exactly 10 test fragments: T-A, T-B, T-C, T-D, T-E, T-F, T-G, T-H, T-I, T-J. + - Write each fragment to reports/${ID}_results.xml (e.g., T-A_results.xml). + - Prefer a single MultiEdit(reports/**) call that writes all ten files in one batch. + - If MultiEdit is not used, emit individual writes for any missing IDs until all ten exist. + - Do not emit any NL-* fragments. + Stop condition: + - After T-J_results.xml is written, stop. + timeout_minutes: "30" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Re-assert T coverage (post-retry) + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + [[ -s "reports/${id}_results.xml" ]] || missing+=("$id") + done + if (( ${#missing[@]} )); then + echo "::error::Still missing T fragments: ${missing[*]}" + exit 1 + fi + + # (kept) Finalize staged report fragments (promote to reports/) + + # (removed duplicate) Finalize staged report fragments + + - name: Assert T coverage (after promotion) + if: always() + shell: bash + run: | + set -euo pipefail + missing=() + for id in T-A T-B T-C T-D T-E T-F T-G T-H T-I T-J; do + if [[ ! -s "reports/${id}_results.xml" ]]; then + # Accept staged fragment as present + [[ -s "reports/_staging/${id}_results.xml" ]] || missing+=("$id") fi - - - name: Canonicalize testcase names (NL/T prefixes) - if: always() - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - import xml.etree.ElementTree as ET, re, os - - RULES = [ - ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"), - ("NL-1", r"\b(NL-1|Core\s*Method)\b"), - ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"), - ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"), - ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"), - ("T-A", r"\b(T-?A|Temporary\s*Helper)\b"), - ("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"), - ("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"), - ("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"), - ("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"), - ("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"), - ("T-G", r"\b(T-?G|Path\s*Normalization)\b"), - ("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"), - ("T-I", r"\b(T-?I|Failure\s*Surface)\b"), - ("T-J", r"\b(T-?J|Idempotenc(y|e))\b"), - ] - - def canon_name(name: str) -> str: - n = name or "" - for tid, pat in RULES: - if re.search(pat, n, flags=re.I): - # If it already starts with the correct format, leave it alone - if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I): - return n.strip() - # If it has a different separator, extract title and reformat - title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I) - if title_match: - title = title_match.group(1).strip() - return f"{tid} — {title}" - # Otherwise, just return the canonical ID - return tid - return n - - def id_from_filename(p: Path): + done + if (( ${#missing[@]} )); then + echo "::error::Missing T fragments: ${missing[*]}" + exit 1 + fi + + - name: Canonicalize testcase names (NL/T prefixes) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET, re, os + + RULES = [ + ("NL-0", r"\b(NL-0|Baseline|State\s*Capture)\b"), + ("NL-1", r"\b(NL-1|Core\s*Method)\b"), + ("NL-2", r"\b(NL-2|Anchor|Build\s*marker)\b"), + ("NL-3", r"\b(NL-3|End[-\s]*of[-\s]*Class\s*Content|Tail\s*test\s*[ABC])\b"), + ("NL-4", r"\b(NL-4|Console|Unity\s*console)\b"), + ("T-A", r"\b(T-?A|Temporary\s*Helper)\b"), + ("T-B", r"\b(T-?B|Method\s*Body\s*Interior)\b"), + ("T-C", r"\b(T-?C|Different\s*Method\s*Interior|ApplyBlend)\b"), + ("T-D", r"\b(T-?D|End[-\s]*of[-\s]*Class\s*Helper|TestHelper)\b"), + ("T-E", r"\b(T-?E|Method\s*Evolution|Counter|IncrementCounter)\b"), + ("T-F", r"\b(T-?F|Atomic\s*Multi[-\s]*Edit)\b"), + ("T-G", r"\b(T-?G|Path\s*Normalization)\b"), + ("T-H", r"\b(T-?H|Validation\s*on\s*Modified)\b"), + ("T-I", r"\b(T-?I|Failure\s*Surface)\b"), + ("T-J", r"\b(T-?J|Idempotenc(y|e))\b"), + ] + + def canon_name(name: str) -> str: + n = name or "" + for tid, pat in RULES: + if re.search(pat, n, flags=re.I): + # If it already starts with the correct format, leave it alone + if re.match(rf'^\s*{re.escape(tid)}\s*[—–-]', n, flags=re.I): + return n.strip() + # If it has a different separator, extract title and reformat + title_match = re.search(rf'{re.escape(tid)}\s*[:.\-–—]\s*(.+)', n, flags=re.I) + if title_match: + title = title_match.group(1).strip() + return f"{tid} — {title}" + # Otherwise, just return the canonical ID + return tid + return n + + def id_from_filename(p: Path): + n = p.name + m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + if m: + return f"NL-{int(m.group(1))}" + m = re.match(r'T([A-J])_results\.xml$', n, re.I) + if m: + return f"T-{m.group(1).upper()}" + return None + + frags = list(sorted(Path("reports").glob("*_results.xml"))) + for frag in frags: + try: + tree = ET.parse(frag); root = tree.getroot() + except Exception: + continue + if root.tag != "testcase": + continue + file_id = id_from_filename(frag) + old = root.get("name") or "" + # Prefer filename-derived ID; if name doesn't start with it, override + if file_id: + # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns) + title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip() + new = f"{file_id} — {title}" if title else file_id + else: + new = canon_name(old) + if new != old and new: + root.set("name", new) + tree.write(frag, encoding="utf-8", xml_declaration=False) + print(f'canon: {frag.name}: "{old}" -> "{new}"') + + # Note: Do not auto-relable fragments. We rely on per-test strict emission + # and the backfill step to surface missing tests explicitly. + PY + + - name: Backfill missing NL/T tests (fail placeholders) + if: always() + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + import re + + DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"] + seen = set() + def id_from_filename(p: Path): + n = p.name + m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + if m: + return f"NL-{int(m.group(1))}" + m = re.match(r'T([A-J])_results\.xml$', n, re.I) + if m: + return f"T-{m.group(1).upper()}" + return None + + for p in Path("reports").glob("*_results.xml"): + try: + r = ET.parse(p).getroot() + except Exception: + continue + # Count by filename id primarily; fall back to testcase name if needed + fid = id_from_filename(p) + if fid in DESIRED: + seen.add(fid) + continue + if r.tag == "testcase": + name = (r.get("name") or "").strip() + for d in DESIRED: + if name.startswith(d): + seen.add(d) + break + + Path("reports").mkdir(parents=True, exist_ok=True) + for d in DESIRED: + if d in seen: + continue + frag = Path(f"reports/{d}_results.xml") + tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d}) + fail = ET.SubElement(tc, "failure", {"message":"not produced"}) + fail.text = "The agent did not emit a fragment for this test." + ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) + print(f"backfill: {d}") + PY + + - name: "Debug: list testcase names" + if: always() + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + for p in sorted(Path('reports').glob('*_results.xml')): + try: + r = ET.parse(p).getroot() + if r.tag == 'testcase': + print(f"{p.name}: {(r.get('name') or '').strip()}") + except Exception: + pass + PY + + # ---------- Merge testcase fragments into JUnit ---------- + - name: Normalize/assemble JUnit in-place (single file) + if: always() + 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) + + def id_from_filename(p: Path): n = p.name m = re.match(r'NL(\d+)_results\.xml$', n, re.I) if m: - return f"NL-{int(m.group(1))}" + return f"NL-{int(m.group(1))}" m = re.match(r'T([A-J])_results\.xml$', n, re.I) if m: - return f"T-{m.group(1).upper()}" + return f"T-{m.group(1).upper()}" + return None + + def id_from_system_out(tc): + so = tc.find('system-out') + if so is not None and so.text: + m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) + if m: + return m.group(1) return None - frags = list(sorted(Path("reports").glob("*_results.xml"))) - for frag in frags: + fragments = sorted(Path('reports').glob('*_results.xml')) + added = 0 + renamed = 0 + + for frag in fragments: + tcs = [] try: - tree = ET.parse(frag); root = tree.getroot() + froot = ET.parse(frag).getroot() + if localname(froot.tag) == 'testcase': + tcs = [froot] + else: + tcs = list(froot.findall('.//testcase')) except Exception: - continue - if root.tag != "testcase": - continue - file_id = id_from_filename(frag) - old = root.get("name") or "" - # Prefer filename-derived ID; if name doesn't start with it, override - if file_id: - # Respect file's ID (prevents T-D being renamed to NL-3 by loose patterns) - title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', old).strip() - new = f"{file_id} — {title}" if title else file_id - else: - new = canon_name(old) - if new != old and new: - root.set("name", new) - tree.write(frag, encoding="utf-8", xml_declaration=False) - print(f'canon: {frag.name}: "{old}" -> "{new}"') - - # Note: Do not auto-relable fragments. We rely on per-test strict emission - # and the backfill step to surface missing tests explicitly. - PY - - - name: Backfill missing NL/T tests (fail placeholders) - if: always() - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - import xml.etree.ElementTree as ET - import re - - DESIRED = ["NL-0","NL-1","NL-2","NL-3","NL-4","T-A","T-B","T-C","T-D","T-E","T-F","T-G","T-H","T-I","T-J"] - seen = set() - def id_from_filename(p: Path): - n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) + txt = Path(frag).read_text(encoding='utf-8', errors='replace') + # Extract all testcase nodes from raw text + nodes = re.findall(r'', txt, flags=re.DOTALL) + for m in nodes: + try: + tcs.append(ET.fromstring(m)) + except Exception: + pass + + # Guard: keep only the first testcase from each fragment + if len(tcs) > 1: + tcs = tcs[:1] + + test_id = id_from_filename(frag) + + for tc in tcs: + current_name = tc.get('name') or '' + tid = test_id or id_from_system_out(tc) + # Enforce filename-derived ID as prefix; repair names if needed + if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name): + title = current_name.strip() + new_name = f'{tid} — {title}' if title else tid + tc.set('name', new_name) + elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name): + # Replace any wrong leading ID with the correct one + title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip() + new_name = f'{tid} — {title}' if title else tid + tc.set('name', new_name) + renamed += 1 + suite.append(tc) + added += 1 + + if added: + # Drop bootstrap placeholder and recompute counts + for tc in list(suite.findall('.//testcase')): + if (tc.get('name') or '') == 'NL-Suite.Bootstrap': + suite.remove(tc) + testcases = suite.findall('.//testcase') + failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) + suite.set('tests', str(len(testcases))) + suite.set('failures', str(failures_cnt)) + suite.set('errors', '0') + suite.set('skipped', '0') + tree.write(src, encoding='utf-8', xml_declaration=True) + print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") + PY + + # ---------- Markdown summary from JUnit ---------- + - name: Build markdown summary from JUnit + if: always() + shell: bash + run: | + python3 - <<'PY' + import xml.etree.ElementTree as ET + from pathlib import Path + import os, html, re + + def localname(tag: str) -> str: + return tag.rsplit('}', 1)[-1] if '}' in tag else tag + + src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) + md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) + md_out.parent.mkdir(parents=True, exist_ok=True) + + if not src.exists(): + md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') + raise SystemExit(0) + + tree = ET.parse(src) + root = tree.getroot() + suite = root.find('./*') if localname(root.tag) == 'testsuites' else root + cases = [] if suite is None else list(suite.findall('.//testcase')) + + def id_from_case(tc): + n = (tc.get('name') or '') + m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n) if m: - return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) - if m: - return f"T-{m.group(1).upper()}" + return m.group(1) + so = tc.find('system-out') + if so is not None and so.text: + m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) + if m: + return m.group(1) return None - for p in Path("reports").glob("*_results.xml"): - try: - r = ET.parse(p).getroot() - except Exception: - continue - # Count by filename id primarily; fall back to testcase name if needed - fid = id_from_filename(p) - if fid in DESIRED: - seen.add(fid) - continue - if r.tag == "testcase": - name = (r.get("name") or "").strip() - for d in DESIRED: - if name.startswith(d): - seen.add(d) - break - - Path("reports").mkdir(parents=True, exist_ok=True) - for d in DESIRED: - if d in seen: - continue - frag = Path(f"reports/{d}_results.xml") - tc = ET.Element("testcase", {"classname":"UnityMCP.NL-T", "name": d}) - fail = ET.SubElement(tc, "failure", {"message":"not produced"}) - fail.text = "The agent did not emit a fragment for this test." - ET.ElementTree(tc).write(frag, encoding="utf-8", xml_declaration=False) - print(f"backfill: {d}") - PY - - - name: "Debug: list testcase names" - if: always() - run: | - python3 - <<'PY' - from pathlib import Path - import xml.etree.ElementTree as ET - for p in sorted(Path('reports').glob('*_results.xml')): - try: - r = ET.parse(p).getroot() - if r.tag == 'testcase': - print(f"{p.name}: {(r.get('name') or '').strip()}") - except Exception: - pass - PY - - # ---------- Merge testcase fragments into JUnit ---------- - - name: Normalize/assemble JUnit in-place (single file) - if: always() - 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) - - def id_from_filename(p: Path): - n = p.name - m = re.match(r'NL(\d+)_results\.xml$', n, re.I) - if m: - return f"NL-{int(m.group(1))}" - m = re.match(r'T([A-J])_results\.xml$', n, re.I) - if m: - return f"T-{m.group(1).upper()}" - return None - - def id_from_system_out(tc): - so = tc.find('system-out') - if so is not None and so.text: - m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) - if m: - return m.group(1) - return None - - fragments = sorted(Path('reports').glob('*_results.xml')) - added = 0 - renamed = 0 - - for frag in fragments: - tcs = [] - try: - froot = ET.parse(frag).getroot() - if localname(froot.tag) == 'testcase': - tcs = [froot] - else: - tcs = list(froot.findall('.//testcase')) - except Exception: - txt = Path(frag).read_text(encoding='utf-8', errors='replace') - # Extract all testcase nodes from raw text - nodes = re.findall(r'', txt, flags=re.DOTALL) - for m in nodes: - try: - tcs.append(ET.fromstring(m)) - except Exception: - pass - - # Guard: keep only the first testcase from each fragment - if len(tcs) > 1: - tcs = tcs[:1] - - test_id = id_from_filename(frag) - - for tc in tcs: - current_name = tc.get('name') or '' - tid = test_id or id_from_system_out(tc) - # Enforce filename-derived ID as prefix; repair names if needed - if tid and not re.match(r'^\s*(NL-\d+|T-[A-Z])\b', current_name): - title = current_name.strip() - new_name = f'{tid} — {title}' if title else tid - tc.set('name', new_name) - elif tid and not re.match(rf'^\s*{re.escape(tid)}\b', current_name): - # Replace any wrong leading ID with the correct one - title = re.sub(r'^\s*(NL-\d+|T-[A-Z])\s*[—–:\-]\s*', '', current_name).strip() - new_name = f'{tid} — {title}' if title else tid - tc.set('name', new_name) - renamed += 1 - suite.append(tc) - added += 1 - - if added: - # Drop bootstrap placeholder and recompute counts - for tc in list(suite.findall('.//testcase')): - if (tc.get('name') or '') == 'NL-Suite.Bootstrap': - suite.remove(tc) - testcases = suite.findall('.//testcase') - failures_cnt = sum(1 for tc in testcases if (tc.find('failure') is not None or tc.find('error') is not None)) - suite.set('tests', str(len(testcases))) - suite.set('failures', str(failures_cnt)) - suite.set('errors', '0') - suite.set('skipped', '0') - tree.write(src, encoding='utf-8', xml_declaration=True) - print(f"Appended {added} testcase(s); renamed {renamed} to canonical NL/T names.") - PY - - # ---------- Markdown summary from JUnit ---------- - - name: Build markdown summary from JUnit - if: always() - shell: bash - run: | - python3 - <<'PY' - import xml.etree.ElementTree as ET - from pathlib import Path - import os, html, re - - def localname(tag: str) -> str: - return tag.rsplit('}', 1)[-1] if '}' in tag else tag - - src = Path(os.environ.get('JUNIT_OUT', 'reports/junit-nl-suite.xml')) - md_out = Path(os.environ.get('MD_OUT', 'reports/junit-nl-suite.md')) - md_out.parent.mkdir(parents=True, exist_ok=True) - - if not src.exists(): - md_out.write_text("# Unity NL/T Editing Suite Test Results\n\n(No JUnit found)\n", encoding='utf-8') - raise SystemExit(0) - - tree = ET.parse(src) - root = tree.getroot() - suite = root.find('./*') if localname(root.tag) == 'testsuites' else root - cases = [] if suite is None else list(suite.findall('.//testcase')) - - def id_from_case(tc): - n = (tc.get('name') or '') - m = re.match(r'\s*(NL-\d+|T-[A-Z])\b', n) - if m: - return m.group(1) - so = tc.find('system-out') - if so is not None and so.text: - m = re.search(r'\b(NL-\d+|T-[A-Z])\b', so.text) - if m: - return m.group(1) - return None - - id_status = {} - name_map = {} - for tc in cases: - tid = id_from_case(tc) - ok = (tc.find('failure') is None and tc.find('error') is None) - if tid and tid not in id_status: - id_status[tid] = ok - name_map[tid] = (tc.get('name') or tid) - - desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] - - total = len(cases) - failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) - passed = total - failures - - lines = [] - lines += [ - '# Unity NL/T Editing Suite Test Results', - '', - f'Totals: {passed} passed, {failures} failed, {total} total', - '', - '## Test Checklist' - ] - for p in desired: - st = id_status.get(p, None) - lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) - lines.append('') - - lines.append('## Test Details') - - def order_key(n: str): - if n.startswith('NL-'): - try: - return (0, int(n.split('-')[1])) - except: - return (0, 999) - if n.startswith('T-') and len(n) > 2: - return (1, ord(n[2])) - return (2, n) - - MAX_CHARS = 2000 - seen = set() - for tid in sorted(id_status.keys(), key=order_key): - seen.add(tid) - tc = next((c for c in cases if (id_from_case(c) == tid)), None) - if not tc: - continue - title = name_map.get(tid, tid) - status_badge = "PASS" if id_status[tid] else "FAIL" - lines.append(f"### {title} — {status_badge}") - so = tc.find('system-out') - text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n')) - if text.strip(): - t = text.strip() - if len(t) > MAX_CHARS: - t = t[:MAX_CHARS] + "\n…(truncated)" - fence = '```' if '```' not in t else '````' - lines += [fence, t, fence] - else: - lines.append('(no system-out)') - node = tc.find('failure') or tc.find('error') - if node is not None: - msg = (node.get('message') or '').strip() - body = (node.text or '').strip() - if msg: - lines.append(f"- Message: {msg}") - if body: - lines.append(f"- Detail: {body.splitlines()[0][:500]}") - lines.append('') - - for tc in cases: - if id_from_case(tc) in seen: - continue - title = tc.get('name') or '(unnamed)' - status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" - lines.append(f"### {title} — {status_badge}") - lines.append('(unmapped test id)') - lines.append('') - - md_out.write_text('\n'.join(lines), encoding='utf-8') - PY - - - name: "Debug: list report files" - if: always() - shell: bash - run: | - set -eux - ls -la reports || true - shopt -s nullglob - for f in reports/*.xml; do - echo "===== $f =====" - head -n 40 "$f" || true - done - - # ---------- Collect execution transcript (if present) ---------- - - name: Collect action execution transcript - if: always() - shell: bash - run: | - set -eux - if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then - cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json - elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then - cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json - fi - - - name: Sanitize markdown (normalize newlines) - if: always() - run: | - set -eu - python3 - <<'PY' - from pathlib import Path - rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) - for p in rp.glob('*.md'): - b=p.read_bytes().replace(b'\x00', b'') - s=b.decode('utf-8','replace').replace('\r\n','\n') - p.write_text(s, encoding='utf-8', newline='\n') - PY - - - name: NL/T details -> Job Summary - if: always() - run: | - echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY - python3 - <<'PY' >> $GITHUB_STEP_SUMMARY - from pathlib import Path - p = Path('reports/junit-nl-suite.md') - if p.exists(): - text = p.read_bytes().decode('utf-8', 'replace') - MAX = 65000 - print(text[:MAX]) - if len(text) > MAX: - print("\n\n_…truncated; full report in artifacts._") - else: - print("_No markdown report found._") - PY - - - name: Fallback JUnit if missing - if: always() - run: | - set -eu - mkdir -p reports - if [ ! -f "$JUNIT_OUT" ]; then - printf '%s\n' \ - '' \ - '' \ - ' ' \ - ' ' \ - ' ' \ - '' \ - > "$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/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true - docker rm -f unity-mcp || true - - - name: Return Pro license (if used) - if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' - uses: game-ci/unity-return-license@v2 - continue-on-error: true - env: - UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} - UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} - UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} - \ No newline at end of file + id_status = {} + name_map = {} + for tc in cases: + tid = id_from_case(tc) + ok = (tc.find('failure') is None and tc.find('error') is None) + if tid and tid not in id_status: + id_status[tid] = ok + name_map[tid] = (tc.get('name') or tid) + + desired = ['NL-0','NL-1','NL-2','NL-3','NL-4','T-A','T-B','T-C','T-D','T-E','T-F','T-G','T-H','T-I','T-J'] + + total = len(cases) + failures = sum(1 for tc in cases if (tc.find('failure') is not None or tc.find('error') is not None)) + passed = total - failures + + lines = [] + lines += [ + '# Unity NL/T Editing Suite Test Results', + '', + f'Totals: {passed} passed, {failures} failed, {total} total', + '', + '## Test Checklist' + ] + for p in desired: + st = id_status.get(p, None) + lines.append(f"- [x] {p}" if st is True else (f"- [ ] {p} (fail)" if st is False else f"- [ ] {p} (not run)")) + lines.append('') + + lines.append('## Test Details') + + def order_key(n: str): + if n.startswith('NL-'): + try: + return (0, int(n.split('-')[1])) + except: + return (0, 999) + if n.startswith('T-') and len(n) > 2: + return (1, ord(n[2])) + return (2, n) + + MAX_CHARS = 2000 + seen = set() + for tid in sorted(id_status.keys(), key=order_key): + seen.add(tid) + tc = next((c for c in cases if (id_from_case(c) == tid)), None) + if not tc: + continue + title = name_map.get(tid, tid) + status_badge = "PASS" if id_status[tid] else "FAIL" + lines.append(f"### {title} — {status_badge}") + so = tc.find('system-out') + text = '' if so is None or so.text is None else html.unescape(so.text.replace('\r\n','\n')) + if text.strip(): + t = text.strip() + if len(t) > MAX_CHARS: + t = t[:MAX_CHARS] + "\n…(truncated)" + fence = '```' if '```' not in t else '````' + lines += [fence, t, fence] + else: + lines.append('(no system-out)') + node = tc.find('failure') or tc.find('error') + if node is not None: + msg = (node.get('message') or '').strip() + body = (node.text or '').strip() + if msg: + lines.append(f"- Message: {msg}") + if body: + lines.append(f"- Detail: {body.splitlines()[0][:500]}") + lines.append('') + + for tc in cases: + if id_from_case(tc) in seen: + continue + title = tc.get('name') or '(unnamed)' + status_badge = "PASS" if (tc.find('failure') is None and tc.find('error') is None) else "FAIL" + lines.append(f"### {title} — {status_badge}") + lines.append('(unmapped test id)') + lines.append('') + + md_out.write_text('\n'.join(lines), encoding='utf-8') + PY + + - name: "Debug: list report files" + if: always() + shell: bash + run: | + set -eux + ls -la reports || true + shopt -s nullglob + for f in reports/*.xml; do + echo "===== $f =====" + head -n 40 "$f" || true + done + + # ---------- Collect execution transcript (if present) ---------- + - name: Collect action execution transcript + if: always() + shell: bash + run: | + set -eux + if [ -f "$RUNNER_TEMP/claude-execution-output.json" ]; then + cp "$RUNNER_TEMP/claude-execution-output.json" reports/claude-execution-output.json + elif [ -f "/home/runner/work/_temp/claude-execution-output.json" ]; then + cp "/home/runner/work/_temp/claude-execution-output.json" reports/claude-execution-output.json + fi + + - name: Sanitize markdown (normalize newlines) + if: always() + run: | + set -eu + python3 - <<'PY' + from pathlib import Path + rp=Path('reports'); rp.mkdir(parents=True, exist_ok=True) + for p in rp.glob('*.md'): + b=p.read_bytes().replace(b'\x00', b'') + s=b.decode('utf-8','replace').replace('\r\n','\n') + p.write_text(s, encoding='utf-8', newline='\n') + PY + + - name: NL/T details -> Job Summary + if: always() + run: | + echo "## Unity NL/T Editing Suite — Summary" >> $GITHUB_STEP_SUMMARY + python3 - <<'PY' >> $GITHUB_STEP_SUMMARY + from pathlib import Path + p = Path('reports/junit-nl-suite.md') + if p.exists(): + text = p.read_bytes().decode('utf-8', 'replace') + MAX = 65000 + print(text[:MAX]) + if len(text) > MAX: + print("\n\n_…truncated; full report in artifacts._") + else: + print("_No markdown report found._") + PY + + - name: Fallback JUnit if missing + if: always() + run: | + set -eu + mkdir -p reports + if [ ! -f "$JUNIT_OUT" ]; then + printf '%s\n' \ + '' \ + '' \ + ' ' \ + ' ' \ + ' ' \ + '' \ + > "$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/((email|serial|license|password|token)[^[:space:]]*)/[REDACTED]/ig' || true + docker rm -f unity-mcp || true + + - name: Return Pro license (if used) + if: always() && steps.lic.outputs.use_ebl == 'true' && steps.lic.outputs.has_serial == 'true' + uses: game-ci/unity-return-license@v2 + continue-on-error: true + env: + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml index fda0851b..fb130a1b 100644 --- a/.github/workflows/github-repo-stats.yml +++ b/.github/workflows/github-repo-stats.yml @@ -17,4 +17,3 @@ jobs: uses: jgehrcke/github-repo-stats@RELEASE with: ghtoken: ${{ secrets.ghrs_github_api_token }} - diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml index e1dea5a2..0b230966 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -2,7 +2,7 @@ name: Unity Tests on: push: - branches: [ main ] + branches: [main] paths: - TestProjects/UnityMCPTests/** - UnityMcpBridge/Editor/** diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs index f7fd8f3b..1f083600 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/Hello.cs @@ -3,13 +3,8 @@ public class Hello : MonoBehaviour { - - // Use this for initialization void Start() { Debug.Log("Hello World"); } - - - } diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs index 27fb9348..916a0f94 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs @@ -2035,5 +2035,3 @@ private void Pad0650() #endregion } - - diff --git a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs index b38e5188..b9e4a3b8 100644 --- a/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs +++ b/TestProjects/UnityMCPTests/Assets/Scripts/TestAsmdef/CustomComponent.cs @@ -6,7 +6,7 @@ public class CustomComponent : MonoBehaviour { [SerializeField] private string customText = "Hello from custom asmdef!"; - + [SerializeField] private float customFloat = 42.0f; @@ -15,4 +15,4 @@ void Start() Debug.Log($"CustomComponent started: {customText}, value: {customFloat}"); } } -} \ No newline at end of file +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs index 8354e3f0..e52c7d0b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/AIPropertyMatchingTests.cs @@ -17,7 +17,7 @@ public void SetUp() sampleProperties = new List { "maxReachDistance", - "maxHorizontalDistance", + "maxHorizontalDistance", "maxVerticalDistance", "moveSpeed", "healthPoints", @@ -33,7 +33,7 @@ public void SetUp() public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() { var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); - + Assert.IsNotEmpty(properties, "Transform should have properties"); Assert.Contains("position", properties, "Transform should have position property"); Assert.Contains("rotation", properties, "Transform should have rotation property"); @@ -44,7 +44,7 @@ public void GetAllComponentProperties_ReturnsValidProperties_ForTransform() public void GetAllComponentProperties_ReturnsEmpty_ForNullType() { var properties = ComponentResolver.GetAllComponentProperties(null); - + Assert.IsEmpty(properties, "Null type should return empty list"); } @@ -52,7 +52,7 @@ public void GetAllComponentProperties_ReturnsEmpty_ForNullType() public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() { var suggestions = ComponentResolver.GetAIPropertySuggestions(null, sampleProperties); - + Assert.IsEmpty(suggestions, "Null input should return no suggestions"); } @@ -60,7 +60,7 @@ public void GetAIPropertySuggestions_ReturnsEmpty_ForNullInput() public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() { var suggestions = ComponentResolver.GetAIPropertySuggestions("", sampleProperties); - + Assert.IsEmpty(suggestions, "Empty input should return no suggestions"); } @@ -68,7 +68,7 @@ public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyInput() public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() { var suggestions = ComponentResolver.GetAIPropertySuggestions("test", new List()); - + Assert.IsEmpty(suggestions, "Empty property list should return no suggestions"); } @@ -76,7 +76,7 @@ public void GetAIPropertySuggestions_ReturnsEmpty_ForEmptyPropertyList() public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() { var suggestions = ComponentResolver.GetAIPropertySuggestions("Max Reach Distance", sampleProperties); - + Assert.Contains("maxReachDistance", suggestions, "Should find exact match after cleaning spaces"); Assert.GreaterOrEqual(suggestions.Count, 1, "Should return at least one match for exact match"); } @@ -85,9 +85,9 @@ public void GetAIPropertySuggestions_FindsExactMatch_AfterCleaning() public void GetAIPropertySuggestions_FindsMultipleWordMatches() { var suggestions = ComponentResolver.GetAIPropertySuggestions("max distance", sampleProperties); - + Assert.Contains("maxReachDistance", suggestions, "Should match maxReachDistance"); - Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); + Assert.Contains("maxHorizontalDistance", suggestions, "Should match maxHorizontalDistance"); Assert.Contains("maxVerticalDistance", suggestions, "Should match maxVerticalDistance"); } @@ -95,7 +95,7 @@ public void GetAIPropertySuggestions_FindsMultipleWordMatches() public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() { var suggestions = ComponentResolver.GetAIPropertySuggestions("movespeed", sampleProperties); // missing capital S - + Assert.Contains("moveSpeed", suggestions, "Should find moveSpeed despite missing capital"); } @@ -103,7 +103,7 @@ public void GetAIPropertySuggestions_FindsSimilarStrings_WithTypos() public void GetAIPropertySuggestions_FindsSemanticMatches_ForCommonTerms() { var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", sampleProperties); - + // Note: Current algorithm might not find "mass" but should handle it gracefully Assert.IsNotNull(suggestions, "Should return valid suggestions list"); } @@ -113,7 +113,7 @@ public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber() { // Test with input that might match many properties var suggestions = ComponentResolver.GetAIPropertySuggestions("m", sampleProperties); - + Assert.LessOrEqual(suggestions.Count, 3, "Should limit suggestions to 3 or fewer"); } @@ -121,13 +121,13 @@ public void GetAIPropertySuggestions_LimitsResults_ToReasonableNumber() public void GetAIPropertySuggestions_CachesResults() { var input = "Max Reach Distance"; - + // First call var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); - + // Second call should use cache (tested indirectly by ensuring consistency) var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, sampleProperties); - + Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be consistent"); CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should be identical"); } @@ -136,11 +136,11 @@ public void GetAIPropertySuggestions_CachesResults() public void GetAIPropertySuggestions_HandlesUnityNamingConventions() { var unityStyleProperties = new List { "isKinematic", "useGravity", "maxLinearVelocity" }; - + var suggestions1 = ComponentResolver.GetAIPropertySuggestions("is kinematic", unityStyleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("use gravity", unityStyleProperties); var suggestions3 = ComponentResolver.GetAIPropertySuggestions("max linear velocity", unityStyleProperties); - + Assert.Contains("isKinematic", suggestions1, "Should handle 'is' prefix convention"); Assert.Contains("useGravity", suggestions2, "Should handle 'use' prefix convention"); Assert.Contains("maxLinearVelocity", suggestions3, "Should handle 'max' prefix convention"); @@ -151,7 +151,7 @@ public void GetAIPropertySuggestions_PrioritizesExactMatches() { var properties = new List { "speed", "moveSpeed", "maxSpeed", "speedMultiplier" }; var suggestions = ComponentResolver.GetAIPropertySuggestions("speed", properties); - + Assert.IsNotEmpty(suggestions, "Should find suggestions"); Assert.Contains("speed", suggestions, "Exact match should be included in results"); // Note: Implementation may or may not prioritize exact matches first @@ -162,7 +162,7 @@ public void GetAIPropertySuggestions_HandlesCaseInsensitive() { var suggestions1 = ComponentResolver.GetAIPropertySuggestions("MAXREACHDISTANCE", sampleProperties); var suggestions2 = ComponentResolver.GetAIPropertySuggestions("maxreachdistance", sampleProperties); - + Assert.Contains("maxReachDistance", suggestions1, "Should handle uppercase input"); Assert.Contains("maxReachDistance", suggestions2, "Should handle lowercase input"); } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs index 9b24456b..5ab03e80 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ComponentResolverTests.cs @@ -12,7 +12,7 @@ public class ComponentResolverTests public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() { bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve Transform component"); Assert.AreEqual(typeof(Transform), type, "Should return correct Transform type"); Assert.IsEmpty(error, "Should have no error message"); @@ -22,7 +22,7 @@ public void TryResolve_ReturnsTrue_ForBuiltInComponentShortName() public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() { bool result = ComponentResolver.TryResolve("UnityEngine.Rigidbody", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve UnityEngine.Rigidbody component"); Assert.AreEqual(typeof(Rigidbody), type, "Should return correct Rigidbody type"); Assert.IsEmpty(error, "Should have no error message"); @@ -32,7 +32,7 @@ public void TryResolve_ReturnsTrue_ForBuiltInComponentFullyQualifiedName() public void TryResolve_ReturnsTrue_ForCustomComponentShortName() { bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve CustomComponent"); Assert.IsNotNull(type, "Should return valid type"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); @@ -44,7 +44,7 @@ public void TryResolve_ReturnsTrue_ForCustomComponentShortName() public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() { bool result = ComponentResolver.TryResolve("TestNamespace.CustomComponent", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve TestNamespace.CustomComponent"); Assert.IsNotNull(type, "Should return valid type"); Assert.AreEqual("CustomComponent", type.Name, "Should have correct type name"); @@ -57,7 +57,7 @@ public void TryResolve_ReturnsTrue_ForCustomComponentFullyQualifiedName() public void TryResolve_ReturnsFalse_ForNonExistentComponent() { bool result = ComponentResolver.TryResolve("NonExistentComponent", out Type type, out string error); - + Assert.IsFalse(result, "Should not resolve non-existent component"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); @@ -68,7 +68,7 @@ public void TryResolve_ReturnsFalse_ForNonExistentComponent() public void TryResolve_ReturnsFalse_ForEmptyString() { bool result = ComponentResolver.TryResolve("", out Type type, out string error); - + Assert.IsFalse(result, "Should not resolve empty string"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); @@ -78,7 +78,7 @@ public void TryResolve_ReturnsFalse_ForEmptyString() public void TryResolve_ReturnsFalse_ForNullString() { bool result = ComponentResolver.TryResolve(null, out Type type, out string error); - + Assert.IsFalse(result, "Should not resolve null string"); Assert.IsNull(type, "Should return null type"); Assert.IsNotEmpty(error, "Should have error message"); @@ -90,10 +90,10 @@ public void TryResolve_CachesResolvedTypes() { // First call bool result1 = ComponentResolver.TryResolve("Transform", out Type type1, out string error1); - + // Second call should use cache bool result2 = ComponentResolver.TryResolve("Transform", out Type type2, out string error2); - + Assert.IsTrue(result1, "First call should succeed"); Assert.IsTrue(result2, "Second call should succeed"); Assert.AreSame(type1, type2, "Should return same type instance (cached)"); @@ -106,27 +106,27 @@ public void TryResolve_PrefersPlayerAssemblies() { // Test that custom user scripts (in Player assemblies) are found bool result = ComponentResolver.TryResolve("CustomComponent", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve user script from Player assembly"); Assert.IsNotNull(type, "Should return valid type"); - + // Verify it's not from an Editor assembly by checking the assembly name string assemblyName = type.Assembly.GetName().Name; - Assert.That(assemblyName, Does.Not.Contain("Editor"), + Assert.That(assemblyName, Does.Not.Contain("Editor"), "User script should come from Player assembly, not Editor assembly"); - + // Verify it's from the TestAsmdef assembly (which is a Player assembly) - Assert.AreEqual("TestAsmdef", assemblyName, + Assert.AreEqual("TestAsmdef", assemblyName, "CustomComponent should be resolved from TestAsmdef assembly"); } - [Test] + [Test] public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() { // This test would need duplicate component names to be meaningful // For now, test with a built-in component that should not have duplicates bool result = ComponentResolver.TryResolve("Transform", out Type type, out string error); - + Assert.IsTrue(result, "Transform should resolve uniquely"); Assert.AreEqual(typeof(Transform), type, "Should return correct type"); Assert.IsEmpty(error, "Should have no ambiguity error"); @@ -136,11 +136,11 @@ public void TryResolve_HandlesDuplicateNames_WithAmbiguityError() public void ResolvedType_IsValidComponent() { bool result = ComponentResolver.TryResolve("Rigidbody", out Type type, out string error); - + Assert.IsTrue(result, "Should resolve Rigidbody"); Assert.IsTrue(typeof(Component).IsAssignableFrom(type), "Resolved type should be assignable from Component"); - Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || + Assert.IsTrue(typeof(MonoBehaviour).IsAssignableFrom(type) || typeof(Component).IsAssignableFrom(type), "Should be a valid Unity component"); } } -} \ No newline at end of file +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs index 34138999..536fb681 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -12,7 +12,7 @@ namespace MCPForUnityTests.Editor.Tools public class ManageGameObjectTests { private GameObject testGameObject; - + [SetUp] public void SetUp() { @@ -20,7 +20,7 @@ public void SetUp() testGameObject = new GameObject("TestObject"); } - [TearDown] + [TearDown] public void TearDown() { // Clean up test GameObject @@ -34,17 +34,17 @@ public void TearDown() public void HandleCommand_ReturnsError_ForNullParams() { var result = ManageGameObject.HandleCommand(null); - + Assert.IsNotNull(result, "Should return a result object"); // Note: Actual error checking would need access to Response structure } - [Test] + [Test] public void HandleCommand_ReturnsError_ForEmptyParams() { var emptyParams = new JObject(); var result = ManageGameObject.HandleCommand(emptyParams); - + Assert.IsNotNull(result, "Should return a result object for empty params"); } @@ -56,11 +56,11 @@ public void HandleCommand_ProcessesValidCreateAction() ["action"] = "create", ["name"] = "TestCreateObject" }; - + var result = ManageGameObject.HandleCommand(createParams); - + Assert.IsNotNull(result, "Should return a result for valid create action"); - + // Clean up - find and destroy the created object var createdObject = GameObject.Find("TestCreateObject"); if (createdObject != null) @@ -74,7 +74,7 @@ public void ComponentResolver_Integration_WorksWithRealComponents() { // Test that our ComponentResolver works with actual Unity components var transformResult = ComponentResolver.TryResolve("Transform", out Type transformType, out string error); - + Assert.IsTrue(transformResult, "Should resolve Transform component"); Assert.AreEqual(typeof(Transform), transformType, "Should return correct Transform type"); Assert.IsEmpty(error, "Should have no error for valid component"); @@ -86,7 +86,7 @@ public void ComponentResolver_Integration_WorksWithBuiltInComponents() var components = new[] { ("Rigidbody", typeof(Rigidbody)), - ("Collider", typeof(Collider)), + ("Collider", typeof(Collider)), ("Renderer", typeof(Renderer)), ("Camera", typeof(Camera)), ("Light", typeof(Light)) @@ -95,11 +95,11 @@ public void ComponentResolver_Integration_WorksWithBuiltInComponents() foreach (var (componentName, expectedType) in components) { var result = ComponentResolver.TryResolve(componentName, out Type actualType, out string error); - + // Some components might not resolve (abstract classes), but the method should handle gracefully if (result) { - Assert.IsTrue(expectedType.IsAssignableFrom(actualType), + Assert.IsTrue(expectedType.IsAssignableFrom(actualType), $"{componentName} should resolve to assignable type"); } else @@ -114,13 +114,13 @@ public void PropertyMatching_Integration_WorksWithRealGameObject() { // Add a Rigidbody to test real property matching var rigidbody = testGameObject.AddComponent(); - + var properties = ComponentResolver.GetAllComponentProperties(typeof(Rigidbody)); - + Assert.IsNotEmpty(properties, "Rigidbody should have properties"); Assert.Contains("mass", properties, "Rigidbody should have mass property"); Assert.Contains("useGravity", properties, "Rigidbody should have useGravity property"); - + // Test AI suggestions var suggestions = ComponentResolver.GetAIPropertySuggestions("Use Gravity", properties); Assert.Contains("useGravity", suggestions, "Should suggest useGravity for 'Use Gravity'"); @@ -130,18 +130,18 @@ public void PropertyMatching_Integration_WorksWithRealGameObject() public void PropertyMatching_HandlesMonoBehaviourProperties() { var properties = ComponentResolver.GetAllComponentProperties(typeof(MonoBehaviour)); - + Assert.IsNotEmpty(properties, "MonoBehaviour should have properties"); Assert.Contains("enabled", properties, "MonoBehaviour should have enabled property"); Assert.Contains("name", properties, "MonoBehaviour should have name property"); Assert.Contains("tag", properties, "MonoBehaviour should have tag property"); } - [Test] + [Test] public void PropertyMatching_HandlesCaseVariations() { var testProperties = new List { "maxReachDistance", "playerHealth", "movementSpeed" }; - + var testCases = new[] { ("max reach distance", "maxReachDistance"), @@ -164,10 +164,10 @@ public void ErrorHandling_ReturnsHelpfulMessages() // This test verifies that error messages are helpful and contain suggestions var testProperties = new List { "mass", "velocity", "drag", "useGravity" }; var suggestions = ComponentResolver.GetAIPropertySuggestions("weight", testProperties); - + // Even if no perfect match, should return valid list Assert.IsNotNull(suggestions, "Should return valid suggestions list"); - + // Test with completely invalid input var badSuggestions = ComponentResolver.GetAIPropertySuggestions("xyz123invalid", testProperties); Assert.IsNotNull(badSuggestions, "Should handle invalid input gracefully"); @@ -178,20 +178,20 @@ public void PerformanceTest_CachingWorks() { var properties = ComponentResolver.GetAllComponentProperties(typeof(Transform)); var input = "Test Property Name"; - + // First call - populate cache var startTime = System.DateTime.UtcNow; var suggestions1 = ComponentResolver.GetAIPropertySuggestions(input, properties); var firstCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; - + // Second call - should use cache startTime = System.DateTime.UtcNow; var suggestions2 = ComponentResolver.GetAIPropertySuggestions(input, properties); var secondCallTime = (System.DateTime.UtcNow - startTime).TotalMilliseconds; - + Assert.AreEqual(suggestions1.Count, suggestions2.Count, "Cached results should be identical"); CollectionAssert.AreEqual(suggestions1, suggestions2, "Cached results should match exactly"); - + // Second call should be faster (though this test might be flaky) Assert.LessOrEqual(secondCallTime, firstCallTime * 2, "Cached call should not be significantly slower"); } @@ -202,13 +202,13 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() // Arrange - add Transform and Rigidbody components to test with var transform = testGameObject.transform; var rigidbody = testGameObject.AddComponent(); - + // Create a params object with mixed valid and invalid properties var setPropertiesParams = new JObject { ["action"] = "modify", ["target"] = testGameObject.name, - ["search_method"] = "by_name", + ["search_method"] = "by_name", ["componentProperties"] = new JObject { ["Transform"] = new JObject @@ -217,7 +217,7 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() ["rotatoin"] = new JObject { ["x"] = 0.0f, ["y"] = 90.0f, ["z"] = 0.0f }, // Invalid (typo - should be rotation) ["localScale"] = new JObject { ["x"] = 2.0f, ["y"] = 2.0f, ["z"] = 2.0f } // Valid }, - ["Rigidbody"] = new JObject + ["Rigidbody"] = new JObject { ["mass"] = 5.0f, // Valid ["invalidProp"] = "test", // Invalid - doesn't exist @@ -231,7 +231,7 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() var originalLocalScale = transform.localScale; var originalMass = rigidbody.mass; var originalUseGravity = rigidbody.useGravity; - + Debug.Log($"BEFORE TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); // Expect the warning logs from the invalid properties @@ -240,13 +240,13 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() // Act var result = ManageGameObject.HandleCommand(setPropertiesParams); - + Debug.Log($"AFTER TEST - Mass: {rigidbody.mass}, UseGravity: {rigidbody.useGravity}"); Debug.Log($"AFTER TEST - LocalPosition: {transform.localPosition}"); Debug.Log($"AFTER TEST - LocalScale: {transform.localScale}"); // Assert - verify that valid properties were set despite invalid ones - Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition, + Assert.AreEqual(new Vector3(1.0f, 2.0f, 3.0f), transform.localPosition, "Valid localPosition should be set even with other invalid properties"); Assert.AreEqual(new Vector3(2.0f, 2.0f, 2.0f), transform.localScale, "Valid localScale should be set even with other invalid properties"); @@ -257,7 +257,7 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() // Verify the result indicates errors (since we had invalid properties) Assert.IsNotNull(result, "Should return a result object"); - + // The collect-and-continue behavior means we should get an error response // that contains info about the failed properties, but valid ones were still applied // This proves the collect-and-continue behavior is working @@ -288,16 +288,16 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes() Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property"); } - [Test] + [Test] public void SetComponentProperties_ContinuesAfterException() { // Arrange - create scenario that might cause exceptions var rigidbody = testGameObject.AddComponent(); - + // Set initial values that we'll change rigidbody.mass = 1.0f; rigidbody.useGravity = true; - + var setPropertiesParams = new JObject { ["action"] = "modify", @@ -329,7 +329,7 @@ public void SetComponentProperties_ContinuesAfterException() "UseGravity should be set even if previous property caused exception"); Assert.IsNotNull(result, "Should return a result even with exceptions"); - + // The key test: processing continued after the exception and set useGravity // This proves the collect-and-continue behavior works even with exceptions @@ -356,4 +356,4 @@ public void SetComponentProperties_ContinuesAfterException() Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'"); } } -} \ No newline at end of file +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs index dd379372..37f2f268 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs @@ -29,7 +29,7 @@ public void HandleCommand_InvalidAction_ReturnsError() ["name"] = "TestScript", ["path"] = "Assets/Scripts" }; - + var result = ManageScript.HandleCommand(paramsObj); Assert.IsNotNull(result, "Should return error result for invalid action"); } @@ -38,7 +38,7 @@ public void HandleCommand_InvalidAction_ReturnsError() public void CheckBalancedDelimiters_ValidCode_ReturnsTrue() { string validCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n }\n}"; - + bool result = CallCheckBalancedDelimiters(validCode, out int line, out char expected); Assert.IsTrue(result, "Valid C# code should pass balance check"); } @@ -47,7 +47,7 @@ public void CheckBalancedDelimiters_ValidCode_ReturnsTrue() public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse() { string unbalancedCode = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n void Start()\n {\n Debug.Log(\"test\");\n // Missing closing brace"; - + bool result = CallCheckBalancedDelimiters(unbalancedCode, out int line, out char expected); Assert.IsFalse(result, "Unbalanced code should fail balance check"); } @@ -56,16 +56,16 @@ public void CheckBalancedDelimiters_UnbalancedBraces_ReturnsFalse() public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue() { string codeWithStringBraces = "using UnityEngine;\n\npublic class TestClass : MonoBehaviour\n{\n public string json = \"{key: value}\";\n void Start() { Debug.Log(json); }\n}"; - + bool result = CallCheckBalancedDelimiters(codeWithStringBraces, out int line, out char expected); Assert.IsTrue(result, "Code with braces in strings should pass balance check"); } - [Test] + [Test] public void CheckScopedBalance_ValidCode_ReturnsTrue() { string validCode = "{ Debug.Log(\"test\"); }"; - + bool result = CallCheckScopedBalance(validCode, 0, validCode.Length); Assert.IsTrue(result, "Valid scoped code should pass balance check"); } @@ -75,9 +75,9 @@ public void CheckScopedBalance_ShouldTolerateOuterContext_ReturnsTrue() { // This simulates a snippet extracted from a larger context string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context"; - + bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length); - + // Scoped balance should tolerate some imbalance from outer context Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance"); } @@ -87,11 +87,11 @@ public void TicTacToe3D_ValidationScenario_DoesNotCrash() { // Test the scenario that was causing issues without file I/O string ticTacToeCode = "using UnityEngine;\n\npublic class TicTacToe3D : MonoBehaviour\n{\n public string gameState = \"active\";\n void Start() { Debug.Log(\"Game started\"); }\n public void MakeMove(int position) { if (gameState == \"active\") Debug.Log($\"Move {position}\"); }\n}"; - + // Test that the validation methods don't crash on this code bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected); bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length); - + Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation"); Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation"); } @@ -101,12 +101,12 @@ private bool CallCheckBalancedDelimiters(string contents, out int line, out char { line = 0; expected = ' '; - + try { - var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters", + var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters", BindingFlags.NonPublic | BindingFlags.Static); - + if (method != null) { var parameters = new object[] { contents, line, expected }; @@ -120,7 +120,7 @@ private bool CallCheckBalancedDelimiters(string contents, out int line, out char { Debug.LogWarning($"Could not test CheckBalancedDelimiters directly: {ex.Message}"); } - + // Fallback: basic structural check return BasicBalanceCheck(contents); } @@ -129,9 +129,9 @@ private bool CallCheckScopedBalance(string text, int start, int end) { try { - var method = typeof(ManageScript).GetMethod("CheckScopedBalance", + var method = typeof(ManageScript).GetMethod("CheckScopedBalance", BindingFlags.NonPublic | BindingFlags.Static); - + if (method != null) { return (bool)method.Invoke(null, new object[] { text, start, end }); @@ -141,7 +141,7 @@ private bool CallCheckScopedBalance(string text, int start, int end) { Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}"); } - + return true; // Default to passing if we can't test the actual method } @@ -151,32 +151,32 @@ private bool BasicBalanceCheck(string contents) int braceCount = 0; bool inString = false; bool escaped = false; - + for (int i = 0; i < contents.Length; i++) { char c = contents[i]; - + if (escaped) { escaped = false; continue; } - + if (inString) { if (c == '\\') escaped = true; else if (c == '"') inString = false; continue; } - + if (c == '"') inString = true; else if (c == '{') braceCount++; else if (c == '}') braceCount--; - + if (braceCount < 0) return false; } - + return braceCount == 0; } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/Editor/AssemblyInfo.cs b/UnityMcpBridge/Editor/AssemblyInfo.cs index 30a86a36..bae75b67 100644 --- a/UnityMcpBridge/Editor/AssemblyInfo.cs +++ b/UnityMcpBridge/Editor/AssemblyInfo.cs @@ -1,3 +1,3 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] \ No newline at end of file +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] diff --git a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs index c7b0c9f6..59cced75 100644 --- a/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs +++ b/UnityMcpBridge/Editor/Data/DefaultServerConfig.cs @@ -15,4 +15,3 @@ public class DefaultServerConfig : ServerConfig public new float retryDelay = 1.0f; } } - diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs index 95399e40..22e83b81 100644 --- a/UnityMcpBridge/Editor/External/Tommy.cs +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -121,19 +121,19 @@ public virtual void AddRange(IEnumerable nodes) #region Native type to TOML cast - public static implicit operator TomlNode(string value) => new TomlString {Value = value}; + public static implicit operator TomlNode(string value) => new TomlString { Value = value }; - public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value}; + public static implicit operator TomlNode(bool value) => new TomlBoolean { Value = value }; - public static implicit operator TomlNode(long value) => new TomlInteger {Value = value}; + public static implicit operator TomlNode(long value) => new TomlInteger { Value = value }; - public static implicit operator TomlNode(float value) => new TomlFloat {Value = value}; + public static implicit operator TomlNode(float value) => new TomlFloat { Value = value }; - public static implicit operator TomlNode(double value) => new TomlFloat {Value = value}; + public static implicit operator TomlNode(double value) => new TomlFloat { Value = value }; - public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value}; + public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal { Value = value }; - public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value}; + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; public static implicit operator TomlNode(TomlNode[] nodes) { @@ -148,11 +148,11 @@ public static implicit operator TomlNode(TomlNode[] nodes) public static implicit operator string(TomlNode value) => value.ToString(); - public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value; + public static implicit operator int(TomlNode value) => (int)value.AsInteger.Value; public static implicit operator long(TomlNode value) => value.AsInteger.Value; - public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value; + public static implicit operator float(TomlNode value) => (float)value.AsFloat.Value; public static implicit operator double(TomlNode value) => value.AsFloat.Value; @@ -212,7 +212,7 @@ public enum Base public override string ToInlineToml() => IntegerBase != Base.Decimal - ? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}" + ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" : Value.ToString(CultureInfo.InvariantCulture); } @@ -232,10 +232,10 @@ public class TomlFloat : TomlNode, IFormattable public override string ToInlineToml() => Value switch { - var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, + var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, - var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() + var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() }; } @@ -286,7 +286,7 @@ public enum DateTimeStyle Time, DateTime } - + public override bool IsDateTimeLocal { get; } = true; public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; public DateTime Value { get; set; } @@ -303,7 +303,7 @@ public override string ToInlineToml() => { DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), - var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) }; } @@ -422,12 +422,12 @@ public class TomlTable : TomlNode { private Dictionary children; internal bool isImplicit; - + public override bool HasValue { get; } = false; public override bool IsTable { get; } = true; public bool IsInline { get; set; } public Dictionary RawTable => children ??= new Dictionary(); - + public override TomlNode this[string key] { get @@ -478,7 +478,7 @@ private LinkedList> CollectCollapsedItems(string { var node = keyValuePair.Value; var key = keyValuePair.Key.AsKey(); - + if (node is TomlTable tbl) { var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); @@ -493,7 +493,7 @@ private LinkedList> CollectCollapsedItems(string else if (node.CollapseLevel == level) nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); } - + if (normalizeOrder) foreach (var kv in postNodes) nodes.AddLast(kv); @@ -513,11 +513,11 @@ internal void WriteTo(TextWriter tw, string name, bool writeSectionName) } var collapsedItems = CollectCollapsedItems(); - + if (collapsedItems.Count == 0) return; - var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true}); + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); Comment?.AsComment(tw); @@ -539,7 +539,7 @@ internal void WriteTo(TextWriter tw, string name, bool writeSectionName) foreach (var collapsedItem in collapsedItems) { var key = collapsedItem.Key; - if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false}) + if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) { if (!first) tw.WriteLine(); first = false; @@ -547,13 +547,13 @@ internal void WriteTo(TextWriter tw, string name, bool writeSectionName) continue; } first = false; - + collapsedItem.Value.Comment?.AsComment(tw); tw.Write(key); tw.Write(' '); tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); tw.Write(' '); - + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); } } @@ -660,7 +660,7 @@ public TomlTable Parse() int currentChar; while ((currentChar = reader.Peek()) >= 0) { - var c = (char) currentChar; + var c = (char)currentChar; if (currentState == ParseState.None) { @@ -771,7 +771,7 @@ public TomlTable Parse() // Consume the ending bracket so we can peek the next character ConsumeChar(); var nextChar = reader.Peek(); - if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL) + if (nextChar < 0 || (char)nextChar != TomlSyntax.TABLE_END_SYMBOL) { AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); keyParts.Clear(); @@ -837,7 +837,7 @@ public TomlTable Parse() AddError($"Unexpected character \"{c}\" at the end of the line."); } - consume_character: + consume_character: reader.Read(); col++; } @@ -858,7 +858,7 @@ private bool AddError(string message, bool skipLine = true) if (skipLine) { reader.ReadLine(); - AdvanceLine(1); + AdvanceLine(1); } currentState = ParseState.None; return false; @@ -892,7 +892,7 @@ private TomlNode ReadKeyValuePair(List keyParts) int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) { @@ -941,7 +941,7 @@ private TomlNode ReadValue(bool skipNewlines = false) int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (TomlSyntax.IsWhiteSpace(c)) { @@ -982,7 +982,7 @@ private TomlNode ReadValue(bool skipNewlines = false) if (value is null) return null; - + return new TomlString { Value = value, @@ -994,8 +994,8 @@ private TomlNode ReadValue(bool skipNewlines = false) return c switch { TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), - TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), - var _ => ReadTomlValue() + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() }; } @@ -1023,7 +1023,7 @@ private bool ReadKeyName(ref List parts, char until) int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; // Reached the final character if (c == until) break; @@ -1062,7 +1062,7 @@ private bool ReadKeyName(ref List parts, char until) // Consume the quote character and read the key name col++; - buffer.Append(ReadQuotedValueSingleLine((char) reader.Read())); + buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); quoted = true; continue; } @@ -1076,7 +1076,7 @@ private bool ReadKeyName(ref List parts, char until) // If we see an invalid symbol, let the next parser handle it break; - consume_character: + consume_character: reader.Read(); col++; } @@ -1107,7 +1107,7 @@ private string ReadRawValue() int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; result.Append(c); ConsumeChar(); @@ -1134,9 +1134,9 @@ private TomlNode ReadTomlValue() TomlNode node = value switch { var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), - var v when TomlSyntax.IsNaN(v) => double.NaN, - var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, - var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, + var v when TomlSyntax.IsNaN(v) => double.NaN, + var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, + var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), CultureInfo.InvariantCulture), var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), @@ -1144,7 +1144,7 @@ var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_ var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger { Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), - IntegerBase = (TomlInteger.Base) numberBase + IntegerBase = (TomlInteger.Base)numberBase }, var _ => null }; @@ -1187,7 +1187,7 @@ var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_ Style = TomlDateTimeLocal.DateTimeStyle.Time, SecondsPrecision = precision }; - + if (StringUtils.TryParseDateTime(value, TomlSyntax.RFC3339Formats, DateTimeStyles.None, @@ -1223,7 +1223,7 @@ private TomlArray ReadArray() int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (c == TomlSyntax.ARRAY_END_SYMBOL) { @@ -1274,7 +1274,7 @@ private TomlArray ReadArray() expectValue = false; continue; - consume_character: + consume_character: ConsumeChar(); } @@ -1293,14 +1293,14 @@ private TomlArray ReadArray() private TomlNode ReadInlineTable() { ConsumeChar(); - var result = new TomlTable {IsInline = true}; + var result = new TomlTable { IsInline = true }; TomlNode currentValue = null; var separator = false; var keyParts = new List(); int cur; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) { @@ -1343,7 +1343,7 @@ private TomlNode ReadInlineTable() currentValue = ReadKeyValuePair(keyParts); continue; - consume_character: + consume_character: ConsumeChar(); } @@ -1352,7 +1352,7 @@ private TomlNode ReadInlineTable() AddError("Trailing commas are not allowed in inline tables."); return null; } - + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) return null; @@ -1394,15 +1394,15 @@ private bool IsTripleQuote(char quote, out char excess) return AddError("Unexpected end of file!"); } - if ((char) cur != quote) + if ((char)cur != quote) { excess = '\0'; return false; } // Consume the second quote - excess = (char) ConsumeChar(); - if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false; + excess = (char)ConsumeChar(); + if ((cur = reader.Peek()) < 0 || (char)cur != quote) return false; // Consume the final quote ConsumeChar(); @@ -1420,7 +1420,7 @@ private bool ProcessQuotedValueCharacter(char quote, ref bool escaped) { if (TomlSyntax.MustBeEscaped(c)) - return AddError($"The character U+{(int) c:X8} must be escaped in a string!"); + return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); if (escaped) { @@ -1487,7 +1487,7 @@ private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') { // Consume the character col++; - var c = (char) cur; + var c = (char)cur; readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); if (readDone) { @@ -1529,10 +1529,10 @@ private string ReadQuotedValueMultiLine(char quote) int cur; while ((cur = ConsumeChar()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (TomlSyntax.MustBeEscaped(c, true)) { - AddError($"The character U+{(int) c:X8} must be escaped!"); + AddError($"The character U+{(int)c:X8} must be escaped!"); return null; } // Trim the first newline @@ -1582,7 +1582,7 @@ private string ReadQuotedValueMultiLine(char quote) if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) { var next = reader.Peek(); - var nc = (char) next; + var nc = (char)next; if (next >= 0) { // ...and the next char is empty space, we must skip all whitespaces @@ -1614,7 +1614,7 @@ private string ReadQuotedValueMultiLine(char quote) quotesEncountered = 0; while ((cur = reader.Peek()) >= 0) { - var c = (char) cur; + var c = (char)cur; if (c == quote && ++quotesEncountered < 3) { sb.Append(c); @@ -1677,7 +1677,7 @@ private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable { if (node.IsArray && arrayTable) { - var arr = (TomlArray) node; + var arr = (TomlArray)node; if (!arr.IsTableArray) { @@ -1695,7 +1695,7 @@ private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable latestNode = arr[arr.ChildrenCount - 1]; continue; } - + if (node is TomlTable { IsInline: true }) { AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); @@ -1751,13 +1751,13 @@ private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable latestNode = node; } - var result = (TomlTable) latestNode; + var result = (TomlTable)latestNode; result.isImplicit = false; return result; } #endregion - + #region Misc parsing private string ParseComment() @@ -1779,7 +1779,7 @@ public static class TOML public static TomlTable Parse(TextReader reader) { - using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII}; + using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; return parser.Parse(); } } @@ -1960,7 +1960,7 @@ public static bool IsIntegerWithBase(string s, out int numberBase) public const char LITERAL_STRING_SYMBOL = '\''; public const char INT_NUMBER_SEPARATOR = '_'; - public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER}; + public static readonly char[] NewLineCharacters = { NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER }; public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; @@ -2013,7 +2013,7 @@ public static string Join(this string self, IEnumerable subItems) } public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); - + public static bool TryParseDateTime(string s, string[] formats, DateTimeStyles styles, @@ -2057,17 +2057,17 @@ public static string Escape(this string txt, bool escapeNewlines = true) static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" - : $"\\u{(ushort) c:X4}"; + : $"\\u{(ushort)c:X4}"; stringBuilder.Append(c switch { - '\b' => @"\b", - '\t' => @"\t", + '\b' => @"\b", + '\t' => @"\t", '\n' when escapeNewlines => @"\n", - '\f' => @"\f", + '\f' => @"\f", '\r' when escapeNewlines => @"\r", - '\\' => @"\\", - '\"' => @"\""", + '\\' => @"\\", + '\"' => @"\""", var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => CodePoint(txt, ref i, c), var _ => c @@ -2092,7 +2092,7 @@ public static bool TryUnescape(this string txt, out string unescaped, out Except return false; } } - + public static string Unescape(this string txt) { if (string.IsNullOrEmpty(txt)) return txt; @@ -2115,16 +2115,16 @@ static string CodePoint(int next, string txt, ref int num, int size) stringBuilder.Append(c switch { - 'b' => "\b", - 't' => "\t", - 'n' => "\n", - 'f' => "\f", - 'r' => "\r", - '\'' => "\'", - '\"' => "\"", - '\\' => "\\", - 'u' => CodePoint(next, txt, ref num, 4), - 'U' => CodePoint(next, txt, ref num, 8), + 'b' => "\b", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + '\'' => "\'", + '\"' => "\"", + '\\' => "\\", + 'u' => CodePoint(next, txt, ref num, 4), + 'U' => CodePoint(next, txt, ref num, 8), var _ => throw new Exception("Undefined escape sequence!") }); i = num + 2; diff --git a/UnityMcpBridge/Editor/Helpers/ExecPath.cs b/UnityMcpBridge/Editor/Helpers/ExecPath.cs index 5130a21c..20c1200b 100644 --- a/UnityMcpBridge/Editor/Helpers/ExecPath.cs +++ b/UnityMcpBridge/Editor/Helpers/ExecPath.cs @@ -205,7 +205,7 @@ internal static bool TryRun( var so = new StringBuilder(); var se = new StringBuilder(); process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; if (!process.Start()) return false; @@ -276,5 +276,3 @@ private static string Where(string exe) #endif } } - - diff --git a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs index b143f487..d7bf979c 100644 --- a/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs +++ b/UnityMcpBridge/Editor/Helpers/GameObjectSerializer.cs @@ -124,7 +124,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // --- Add Early Logging --- // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); // --- End Early Logging --- - + if (c == null) return null; Type componentType = c.GetType(); @@ -150,8 +150,8 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, { "childCount", tr.childCount }, // Include standard Object/Component properties - { "name", tr.name }, - { "tag", tr.tag }, + { "name", tr.name }, + { "tag", tr.tag }, { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } }; } @@ -244,8 +244,9 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // Basic filtering (readable, not indexer, not transform which is handled elsewhere) if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; // Add if not already added (handles overrides - keep the most derived version) - if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { - propertiesToCache.Add(propInfo); + if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) + { + propertiesToCache.Add(propInfo); } } @@ -258,8 +259,8 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ { if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields - // Add if not already added (handles hiding - keep the most derived version) - if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; + // Add if not already added (handles hiding - keep the most derived version) + if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; bool shouldInclude = false; if (includeNonPublicSerializedFields) @@ -291,7 +292,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // --- Use cached metadata --- var serializablePropertiesOutput = new Dictionary(); - + // --- Add Logging Before Property Loop --- // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); // --- End Logging Before Property Loop --- @@ -310,16 +311,16 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ propName == "particleSystem" || // Also skip potentially problematic Matrix properties prone to cycles/errors propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") - { - // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log - skipProperty = true; - } + { + // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log + skipProperty = true; + } // --- End Skip Generic Properties --- // --- Skip specific potentially problematic Camera properties --- - if (componentType == typeof(Camera) && - (propName == "pixelRect" || - propName == "rect" || + if (componentType == typeof(Camera) && + (propName == "pixelRect" || + propName == "rect" || propName == "cullingMatrix" || propName == "useOcclusionCulling" || propName == "worldToCameraMatrix" || @@ -334,8 +335,8 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // --- End Skip Camera Properties --- // --- Skip specific potentially problematic Transform properties --- - if (componentType == typeof(Transform) && - (propName == "lossyScale" || + if (componentType == typeof(Transform) && + (propName == "lossyScale" || propName == "rotation" || propName == "worldToLocalMatrix" || propName == "localToWorldMatrix")) @@ -345,11 +346,11 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ } // --- End Skip Transform Properties --- - // Skip if flagged - if (skipProperty) - { + // Skip if flagged + if (skipProperty) + { continue; - } + } try { @@ -362,7 +363,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ } catch (Exception) { - // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); + // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); } } @@ -373,7 +374,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ // Use cached fields foreach (var fieldInfo in cachedData.SerializableFields) { - try + try { // --- Add detailed logging for fields --- // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); @@ -385,7 +386,7 @@ public static object GetComponentData(Component c, bool includeNonPublicSerializ } catch (Exception) { - // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); + // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); } } // --- End Use cached metadata --- @@ -458,19 +459,19 @@ private static object ConvertJTokenToPlainObject(JToken token) case JTokenType.Boolean: return token.ToObject(); case JTokenType.Date: - return token.ToObject(); - case JTokenType.Guid: - return token.ToObject(); - case JTokenType.Uri: - return token.ToObject(); - case JTokenType.TimeSpan: - return token.ToObject(); + return token.ToObject(); + case JTokenType.Guid: + return token.ToObject(); + case JTokenType.Uri: + return token.ToObject(); + case JTokenType.TimeSpan: + return token.ToObject(); case JTokenType.Bytes: - return token.ToObject(); + return token.ToObject(); case JTokenType.Null: return null; - case JTokenType.Undefined: - return null; // Treat undefined as null + case JTokenType.Undefined: + return null; // Treat undefined as null default: // Fallback for simple value types not explicitly listed @@ -524,4 +525,4 @@ private static JToken CreateTokenFromValue(object value, Type type) } } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs index 9b2e5b86..389d47d2 100644 --- a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs @@ -184,4 +184,3 @@ public static string ResolveServerSource() } } } - diff --git a/UnityMcpBridge/Editor/Helpers/McpLog.cs b/UnityMcpBridge/Editor/Helpers/McpLog.cs index 7e467187..85abdb79 100644 --- a/UnityMcpBridge/Editor/Helpers/McpLog.cs +++ b/UnityMcpBridge/Editor/Helpers/McpLog.cs @@ -29,5 +29,3 @@ public static void Error(string message) } } } - - diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs index d39685c2..bb8861fe 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs @@ -105,5 +105,3 @@ private static bool LegacyRootsExist() } } } - - diff --git a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs index be9f0a41..795256a7 100644 --- a/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/PackageInstaller.cs @@ -10,7 +10,7 @@ namespace MCPForUnity.Editor.Helpers public static class PackageInstaller { private const string InstallationFlagKey = "MCPForUnity.ServerInstalled"; - + static PackageInstaller() { // Check if this is the first time the package is loaded @@ -20,17 +20,17 @@ static PackageInstaller() EditorApplication.delayCall += InstallServerOnFirstLoad; } } - + private static void InstallServerOnFirstLoad() { try { Debug.Log("MCP-FOR-UNITY: Installing Python server..."); ServerInstaller.EnsureServerInstalled(); - + // Mark as installed EditorPrefs.SetBool(InstallationFlagKey, true); - + Debug.Log("MCP-FOR-UNITY: Python server installation completed successfully."); } catch (System.Exception ex) diff --git a/UnityMcpBridge/Editor/Helpers/PortManager.cs b/UnityMcpBridge/Editor/Helpers/PortManager.cs index f041ac23..09d85798 100644 --- a/UnityMcpBridge/Editor/Helpers/PortManager.cs +++ b/UnityMcpBridge/Editor/Helpers/PortManager.cs @@ -43,8 +43,8 @@ public static int GetPortWithFallback() { // Try to load stored port first, but only if it's from the current project var storedConfig = GetStoredPortConfig(); - if (storedConfig != null && - storedConfig.unity_port > 0 && + if (storedConfig != null && + storedConfig.unity_port > 0 && string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && IsPortAvailable(storedConfig.unity_port)) { @@ -228,7 +228,7 @@ private static int LoadStoredPort() try { string registryFile = GetRegistryFilePath(); - + if (!File.Exists(registryFile)) { // Backwards compatibility: try the legacy file name @@ -261,7 +261,7 @@ public static PortConfig GetStoredPortConfig() try { string registryFile = GetRegistryFilePath(); - + if (!File.Exists(registryFile)) { // Backwards compatibility: try the legacy file @@ -316,4 +316,4 @@ private static string ComputeProjectHash(string input) } } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/Editor/Helpers/Response.cs b/UnityMcpBridge/Editor/Helpers/Response.cs index 1a3bd520..cfcd2efb 100644 --- a/UnityMcpBridge/Editor/Helpers/Response.cs +++ b/UnityMcpBridge/Editor/Helpers/Response.cs @@ -60,4 +60,3 @@ public static object Error(string errorCodeOrMessage, object data = null) } } } - diff --git a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs index 56cf0952..26c0fbb2 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -419,7 +419,7 @@ private static void CopyDirectoryRecursive(string sourceDir, string destinationD try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } string destSubDir = Path.Combine(destinationDir, dirName); CopyDirectoryRecursive(dirPath, destSubDir); - NextDir: ; + NextDir:; } } @@ -467,7 +467,7 @@ public static bool RepairPythonEnvironment() string uvPath = FindUvPath(); if (uvPath == null) { - Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); + Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)."); return false; } @@ -486,7 +486,7 @@ public static bool RepairPythonEnvironment() var sbOut = new StringBuilder(); var sbErr = new StringBuilder(); proc.OutputDataReceived += (_, e) => { if (e.Data != null) sbOut.AppendLine(e.Data); }; - proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); }; if (!proc.Start()) { diff --git a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs index 5684b19a..1342dc12 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs @@ -147,5 +147,3 @@ private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageIn } } } - - diff --git a/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs b/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs index 4e068e99..6440a675 100644 --- a/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/TelemetryHelper.cs @@ -14,7 +14,7 @@ public static class TelemetryHelper private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled"; private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID"; private static Action> s_sender; - + /// /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) /// @@ -24,14 +24,14 @@ public static bool IsEnabled { // Check environment variables first var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); - if (!string.IsNullOrEmpty(envDisable) && + if (!string.IsNullOrEmpty(envDisable) && (envDisable.ToLower() == "true" || envDisable == "1")) { return false; } - + var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); - if (!string.IsNullOrEmpty(unityMcpDisable) && + if (!string.IsNullOrEmpty(unityMcpDisable) && (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) { return false; @@ -49,7 +49,7 @@ public static bool IsEnabled return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); } } - + /// /// Get or generate customer UUID for anonymous tracking /// @@ -63,7 +63,7 @@ public static string GetCustomerUUID() } return uuid; } - + /// /// Disable telemetry (stored in EditorPrefs) /// @@ -71,7 +71,7 @@ public static void DisableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); } - + /// /// Enable telemetry (stored in EditorPrefs) /// @@ -79,7 +79,7 @@ public static void EnableTelemetry() { UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); } - + /// /// Send telemetry data to Python server for processing /// This is a lightweight bridge - the actual telemetry logic is in Python @@ -88,7 +88,7 @@ public static void RecordEvent(string eventType, Dictionary data { if (!IsEnabled) return; - + try { var telemetryData = new Dictionary @@ -100,12 +100,12 @@ public static void RecordEvent(string eventType, Dictionary data ["platform"] = Application.platform.ToString(), ["source"] = "unity_bridge" }; - + if (data != null) { telemetryData["data"] = data; } - + // Send to Python server via existing bridge communication // The Python server will handle actual telemetry transmission SendTelemetryToPythonServer(telemetryData); @@ -119,7 +119,7 @@ public static void RecordEvent(string eventType, Dictionary data } } } - + /// /// Allows the bridge to register a concrete sender for telemetry payloads. /// @@ -144,7 +144,7 @@ public static void RecordBridgeStartup() ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() }); } - + /// /// Record bridge connection event /// @@ -154,15 +154,15 @@ public static void RecordBridgeConnection(bool success, string error = null) { ["success"] = success }; - + if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } - + RecordEvent("bridge_connection", data); } - + /// /// Record tool execution from Unity side /// @@ -174,15 +174,15 @@ public static void RecordToolExecution(string toolName, bool success, float dura ["success"] = success, ["duration_ms"] = Math.Round(durationMs, 2) }; - + if (!string.IsNullOrEmpty(error)) { data["error"] = error.Substring(0, Math.Min(200, error.Length)); } - + RecordEvent("tool_execution_unity", data); } - + private static void SendTelemetryToPythonServer(Dictionary telemetryData) { var sender = Volatile.Read(ref s_sender); @@ -208,17 +208,17 @@ private static void SendTelemetryToPythonServer(Dictionary telem Debug.Log($"MCP-TELEMETRY: {telemetryData["event_type"]}"); } } - + private static bool IsDebugEnabled() { - try - { - return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); - } - catch - { - return false; + try + { + return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + } + catch + { + return false; } } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs index 1075a199..41566188 100644 --- a/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs +++ b/UnityMcpBridge/Editor/Helpers/Vector3Helper.cs @@ -22,4 +22,3 @@ public static Vector3 ParseVector3(JArray array) } } } - diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 326f921e..39312ce4 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -54,7 +54,7 @@ private static Dictionary< 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 - + // IO diagnostics private static long _ioSeq = 0; private static void IoInfo(string s) { McpLog.Info(s, always: false); } @@ -90,14 +90,14 @@ public static void StartAutoConnect() currentUnityPort = PortManager.GetPortWithFallback(); Start(); isAutoConnectMode = true; - + // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { Debug.LogError($"Auto-connect failed: {ex.Message}"); - + // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; @@ -151,7 +151,8 @@ static MCPForUnityBridge() IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); } } - }) { IsBackground = true, Name = "MCP-Writer" }; + }) + { IsBackground = true, Name = "MCP-Writer" }; writerThread.Start(); } catch { } @@ -516,160 +517,160 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken lock (clientsLock) { activeClients.Add(client); } try { - // Framed I/O only; legacy mode removed - try - { - if (IsDebugEnabled()) + // Framed I/O only; legacy mode removed + try { - var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; - Debug.Log($"UNITY-MCP: Client connected {ep}"); + if (IsDebugEnabled()) + { + 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); + 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); + await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif - if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); - } - catch (Exception ex) - { - if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); - return; // abort this client - } - - while (isRunning && !token.IsCancellationRequested) - { - try + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); + } + catch (Exception ex) { - // Strict framed mode only: enforced framed I/O for this connection - string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); + return; // abort this client + } + while (isRunning && !token.IsCancellationRequested) + { try { - if (IsDebugEnabled()) + // Strict framed mode only: enforced framed I/O for this connection + string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); + + try { - var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; - MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); + if (IsDebugEnabled()) + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); + } } - } - catch { } - string commandId = Guid.NewGuid().ToString(); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + catch { } + string commandId = Guid.NewGuid().ToString(); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - // Special handling for ping command to avoid JSON parsing - if (commandText.Trim() == "ping") - { - // Direct response to ping without going through JSON parsing - byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( - /*lang=json,strict*/ - "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" - ); - await WriteFrameAsync(stream, pingResponseBytes); - continue; - } + // Special handling for ping command to avoid JSON parsing + if (commandText.Trim() == "ping") + { + // Direct response to ping without going through JSON parsing + byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( + /*lang=json,strict*/ + "{\"status\":\"success\",\"result\":{\"message\":\"pong\"}}" + ); + await WriteFrameAsync(stream, pingResponseBytes); + continue; + } - lock (lockObj) - { - commandQueue[commandId] = (commandText, tcs); - } + lock (lockObj) + { + commandQueue[commandId] = (commandText, tcs); + } - // Wait for the handler to produce a response, but do not block indefinitely - string response; - try - { - using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); - var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); - if (completed == tcs.Task) + // Wait for the handler to produce a response, but do not block indefinitely + string response; + try { - // Got a result from the handler - respCts.Cancel(); - response = tcs.Task.Result; + using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); + var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); + if (completed == tcs.Task) + { + // Got a result from the handler + respCts.Cancel(); + response = tcs.Task.Result; + } + else + { + // Timeout: return a structured error so the client can recover + var timeoutResponse = new + { + status = "error", + error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + }; + response = JsonConvert.SerializeObject(timeoutResponse); + } } - else + catch (Exception ex) { - // Timeout: return a structured error so the client can recover - var timeoutResponse = new + var errorResponse = new { status = "error", - error = $"Command processing timed out after {FrameIOTimeoutMs} ms", + error = ex.Message, }; - response = JsonConvert.SerializeObject(timeoutResponse); + response = JsonConvert.SerializeObject(errorResponse); } - } - catch (Exception ex) - { - var errorResponse = new - { - status = "error", - error = ex.Message, - }; - response = JsonConvert.SerializeObject(errorResponse); - } - if (IsDebugEnabled()) - { - try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { } - } - // Crash-proof and self-reporting writer logs (direct write to this client's stream) - long seq = System.Threading.Interlocked.Increment(ref _ioSeq); - byte[] responseBytes; - try - { - responseBytes = System.Text.Encoding.UTF8.GetBytes(response); - IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); - } - catch (Exception ex) - { - IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); - throw; - } + if (IsDebugEnabled()) + { + try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { } + } + // Crash-proof and self-reporting writer logs (direct write to this client's stream) + long seq = System.Threading.Interlocked.Increment(ref _ioSeq); + byte[] responseBytes; + try + { + responseBytes = System.Text.Encoding.UTF8.GetBytes(response); + IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } - var swDirect = System.Diagnostics.Stopwatch.StartNew(); - try - { - await WriteFrameAsync(stream, responseBytes); - swDirect.Stop(); - IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); + var swDirect = System.Diagnostics.Stopwatch.StartNew(); + try + { + await WriteFrameAsync(stream, responseBytes); + swDirect.Stop(); + IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); + throw; + } } catch (Exception ex) { - IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}"); - throw; - } - } - catch (Exception ex) - { - // Treat common disconnects/timeouts as benign; only surface hard errors - string msg = ex.Message ?? string.Empty; - bool isBenign = - msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 - || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 - || ex is System.IO.IOException; - if (isBenign) - { - if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); - } - else - { - MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); + // Treat common disconnects/timeouts as benign; only surface hard errors + string msg = ex.Message ?? string.Empty; + bool isBenign = + msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 + || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 + || ex is System.IO.IOException; + if (isBenign) + { + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info($"Client handler: {msg}", always: false); + } + else + { + MCPForUnity.Editor.Helpers.McpLog.Error($"Client handler error: {msg}"); + } + break; } - break; } } - } finally { lock (clientsLock) { activeClients.Remove(client); } @@ -806,116 +807,116 @@ private static void ProcessCommands() if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard try { - // Heartbeat without holding the queue lock - double now = EditorApplication.timeSinceStartup; - if (now >= nextHeartbeatAt) - { - WriteHeartbeat(false); - nextHeartbeatAt = now + 0.5f; - } - - // Snapshot under lock, then process outside to reduce contention - List<(string id, string text, TaskCompletionSource tcs)> work; - lock (lockObj) - { - work = commandQueue - .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) - .ToList(); - } + // Heartbeat without holding the queue lock + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + WriteHeartbeat(false); + nextHeartbeatAt = now + 0.5f; + } - foreach (var item in work) - { - string id = item.id; - string commandText = item.text; - TaskCompletionSource tcs = item.tcs; + // Snapshot under lock, then process outside to reduce contention + List<(string id, string text, TaskCompletionSource tcs)> work; + lock (lockObj) + { + work = commandQueue + .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) + .ToList(); + } - try + foreach (var item in work) { - // Special case handling - if (string.IsNullOrEmpty(commandText)) + string id = item.id; + string commandText = item.text; + TaskCompletionSource tcs = item.tcs; + + try { - var emptyResponse = new + // Special case handling + if (string.IsNullOrEmpty(commandText)) { - status = "error", - error = "Empty command received", - }; - tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - continue; - } + var emptyResponse = new + { + status = "error", + error = "Empty command received", + }; + tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); + // Remove quickly under lock + lock (lockObj) { commandQueue.Remove(id); } + continue; + } - // Trim the command text to remove any whitespace - commandText = commandText.Trim(); + // Trim the command text to remove any whitespace + commandText = commandText.Trim(); - // Non-JSON direct commands handling (like ping) - if (commandText == "ping") - { - var pingResponse = new + // Non-JSON direct commands handling (like ping) + if (commandText == "ping") { - status = "success", - result = new { message = "pong" }, - }; - tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } - // Check if the command is valid JSON before attempting to deserialize - if (!IsValidJson(commandText)) - { - var invalidJsonResponse = new + // Check if the command is valid JSON before attempting to deserialize + if (!IsValidJson(commandText)) { - status = "error", - error = "Invalid JSON format", - receivedText = commandText.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); - lock (lockObj) { commandQueue.Remove(id); } - continue; - } + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 + ? commandText[..50] + "..." + : commandText, + }; + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } - // Normal JSON command processing - Command command = JsonConvert.DeserializeObject(commandText); + // Normal JSON command processing + Command command = JsonConvert.DeserializeObject(commandText); - if (command == null) + if (command == null) + { + var nullCommandResponse = new + { + status = "error", + error = "Command deserialized to null", + details = "The command was valid JSON but could not be deserialized to a Command object", + }; + tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); + } + else + { + string responseJson = ExecuteCommand(command); + tcs.SetResult(responseJson); + } + } + catch (Exception ex) { - var nullCommandResponse = new + Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + + var response = new { status = "error", - error = "Command deserialized to null", - details = "The command was valid JSON but could not be deserialized to a Command object", + error = ex.Message, + commandType = "Unknown (error during processing)", + receivedText = commandText?.Length > 50 + ? commandText[..50] + "..." + : commandText, }; - tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); - } - else - { - string responseJson = ExecuteCommand(command); + string responseJson = JsonConvert.SerializeObject(response); tcs.SetResult(responseJson); } - } - catch (Exception ex) - { - Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); - var response = new - { - status = "error", - error = ex.Message, - commandType = "Unknown (error during processing)", - receivedText = commandText?.Length > 50 - ? commandText[..50] + "..." - : commandText, - }; - string responseJson = JsonConvert.SerializeObject(response); - tcs.SetResult(responseJson); + // Remove quickly under lock + lock (lockObj) { commandQueue.Remove(id); } } - - // Remove quickly under lock - lock (lockObj) { commandQueue.Remove(id); } - } } finally { diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index afc14448..2503391d 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -48,4 +48,3 @@ public static void Add(string commandName, Func handler) } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 4c11f343..71a379b0 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -27,7 +27,7 @@ public static class ManageGameObject Converters = new List { new Vector3Converter(), - new Vector2Converter(), + new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), @@ -35,7 +35,7 @@ public static class ManageGameObject new UnityEngineObjectConverter() } }); - + // --- Main Handler --- public static object HandleCommand(JObject @params) @@ -879,7 +879,7 @@ string searchMethod // return Response.Success( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); - + } private static object DeleteGameObject(JToken targetToken, string searchMethod) @@ -962,23 +962,23 @@ private static object GetComponentsFromTarget(string target, string searchMethod // --- Get components, immediately copy to list, and null original array --- Component[] originalComponents = targetGo.GetComponents(); List componentsToIterate = new List(originalComponents ?? Array.Empty()); // Copy immediately, handle null case - int componentCount = componentsToIterate.Count; + int componentCount = componentsToIterate.Count; originalComponents = null; // Null the original reference - // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); - // --- End Copy and Null --- - + // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); + // --- End Copy and Null --- + var componentData = new List(); - + for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY { Component c = componentsToIterate[i]; // Use the copy - if (c == null) + if (c == null) { // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); continue; // Safety check } // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); - try + try { var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); if (data != null) // Ensure GetComponentData didn't return null @@ -1002,7 +1002,7 @@ private static object GetComponentsFromTarget(string target, string searchMethod } } // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); - + // Cleanup the list we created componentsToIterate.Clear(); componentsToIterate = null; @@ -1181,7 +1181,7 @@ string searchMethod return removeResult; // Return error EditorUtility.SetDirty(targetGo); - // Use the new serializer helper + // Use the new serializer helper return Response.Success( $"Component '{typeName}' removed from '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) @@ -1230,7 +1230,7 @@ string searchMethod return setResult; // Return error EditorUtility.SetDirty(targetGo); - // Use the new serializer helper + // Use the new serializer helper return Response.Success( $"Properties set for component '{compName}' on '{targetGo.name}'.", Helpers.GameObjectSerializer.GetGameObjectData(targetGo) @@ -1693,8 +1693,8 @@ private static bool SetProperty(object target, string memberName, JToken value) BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - // Use shared serializer to avoid per-call allocation - var inputSerializer = InputSerializer; + // Use shared serializer to avoid per-call allocation + var inputSerializer = InputSerializer; try { @@ -1716,8 +1716,9 @@ private static bool SetProperty(object target, string memberName, JToken value) propInfo.SetValue(target, convertedValue); return true; } - else { - Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); + else + { + Debug.LogWarning($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } else @@ -1725,16 +1726,17 @@ private static bool SetProperty(object target, string memberName, JToken value) FieldInfo fieldInfo = type.GetField(memberName, flags); if (fieldInfo != null) // Check if !IsLiteral? { - // Use the inputSerializer for conversion + // Use the inputSerializer for conversion object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null + if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null { fieldInfo.SetValue(target, convertedValue); return true; } - else { - Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); - } + else + { + Debug.LogWarning($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); + } } else { @@ -1881,12 +1883,17 @@ private static bool SetNestedProperty(object target, string path, JToken value, if (value is JArray jArray) { // Try converting to known types that SetColor/SetVector accept - if (jArray.Count == 4) { + if (jArray.Count == 4) + { try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } try { Vector4 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } - } else if (jArray.Count == 3) { + } + else if (jArray.Count == 3) + { try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color - } else if (jArray.Count == 2) { + } + else if (jArray.Count == 2) + { try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } } } @@ -1901,13 +1908,16 @@ private static bool SetNestedProperty(object target, string path, JToken value, else if (value.Type == JTokenType.String) { // Try converting to Texture using the serializer/converter - try { + try + { Texture texture = value.ToObject(inputSerializer); - if (texture != null) { + if (texture != null) + { material.SetTexture(finalPart, texture); return true; } - } catch { } + } + catch { } } Debug.LogWarning( @@ -1927,7 +1937,8 @@ private static bool SetNestedProperty(object target, string path, JToken value, finalPropInfo.SetValue(currentObject, convertedValue); return true; } - else { + else + { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); } } @@ -1943,7 +1954,8 @@ private static bool SetNestedProperty(object target, string path, JToken value, finalFieldInfo.SetValue(currentObject, convertedValue); return true; } - else { + else + { Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); } } @@ -2025,25 +2037,25 @@ private static object ConvertJTokenToType(JToken token, Type targetType, JsonSer } catch (JsonSerializationException jsonEx) { - Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); - // Optionally re-throw or return null/default - // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; - throw; // Re-throw to indicate failure higher up + Debug.LogError($"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}"); + // Optionally re-throw or return null/default + // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + throw; // Re-throw to indicate failure higher up } catch (ArgumentException argEx) { Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); - throw; + throw; } catch (Exception ex) { - Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); - throw; + Debug.LogError($"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}"); + throw; } // If ToObject succeeded, it would have returned. If it threw, we wouldn't reach here. - // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. - // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); - // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + // This fallback logic is likely unreachable if ToObject covers all cases or throws on failure. + // Debug.LogWarning($"Conversion failed for token to {targetType.FullName}. Token: {token.ToString(Formatting.None)}"); + // return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; } // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- @@ -2059,7 +2071,7 @@ private static Vector3 ParseJTokenToVector3(JToken token) } if (token is JArray arr && arr.Count >= 3) { - return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); + return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); return Vector3.zero; @@ -2068,13 +2080,13 @@ private static Vector3 ParseJTokenToVector3(JToken token) private static Vector2 ParseJTokenToVector2(JToken token) { // ... (implementation - likely replaced by Vector2Converter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) { return new Vector2(obj["x"].ToObject(), obj["y"].ToObject()); } if (token is JArray arr && arr.Count >= 2) { - return new Vector2(arr[0].ToObject(), arr[1].ToObject()); + return new Vector2(arr[0].ToObject(), arr[1].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); return Vector2.zero; @@ -2088,47 +2100,47 @@ private static Quaternion ParseJTokenToQuaternion(JToken token) } if (token is JArray arr && arr.Count >= 4) { - return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); return Quaternion.identity; } private static Color ParseJTokenToColor(JToken token) { - // ... (implementation - likely replaced by ColorConverter) ... + // ... (implementation - likely replaced by ColorConverter) ... if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) { return new Color(obj["r"].ToObject(), obj["g"].ToObject(), obj["b"].ToObject(), obj["a"].ToObject()); } if (token is JArray arr && arr.Count >= 4) { - return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); return Color.white; } private static Rect ParseJTokenToRect(JToken token) { - // ... (implementation - likely replaced by RectConverter) ... + // ... (implementation - likely replaced by RectConverter) ... if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) { return new Rect(obj["x"].ToObject(), obj["y"].ToObject(), obj["width"].ToObject(), obj["height"].ToObject()); } if (token is JArray arr && arr.Count >= 4) { - return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); } Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); return Rect.zero; } private static Bounds ParseJTokenToBounds(JToken token) { - // ... (implementation - likely replaced by BoundsConverter) ... + // ... (implementation - likely replaced by BoundsConverter) ... if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) { // Requires Vector3 conversion, which should ideally use the serializer too - Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) - Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) + Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) + Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) return new Bounds(center, size); } // Array fallback for Bounds is less intuitive, maybe remove? @@ -2141,109 +2153,109 @@ private static Bounds ParseJTokenToBounds(JToken token) } // --- End Redundant Parse Helpers --- - /// - /// Finds a specific UnityEngine.Object based on a find instruction JObject. - /// Primarily used by UnityEngineObjectConverter during deserialization. - /// - // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. - public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) - { - string findTerm = instruction["find"]?.ToString(); - string method = instruction["method"]?.ToString()?.ToLower(); - string componentName = instruction["component"]?.ToString(); // Specific component to get - - if (string.IsNullOrEmpty(findTerm)) - { - Debug.LogWarning("Find instruction missing 'find' term."); - return null; - } - - // Use a flexible default search method if none provided - string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; - - // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first - if (typeof(Material).IsAssignableFrom(targetType) || - typeof(Texture).IsAssignableFrom(targetType) || - typeof(ScriptableObject).IsAssignableFrom(targetType) || - targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. - typeof(AudioClip).IsAssignableFrom(targetType) || - typeof(AnimationClip).IsAssignableFrom(targetType) || - typeof(Font).IsAssignableFrom(targetType) || - typeof(Shader).IsAssignableFrom(targetType) || - typeof(ComputeShader).IsAssignableFrom(targetType) || - typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check - { + /// + /// Finds a specific UnityEngine.Object based on a find instruction JObject. + /// Primarily used by UnityEngineObjectConverter during deserialization. + /// + // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. + public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) + { + string findTerm = instruction["find"]?.ToString(); + string method = instruction["method"]?.ToString()?.ToLower(); + string componentName = instruction["component"]?.ToString(); // Specific component to get + + if (string.IsNullOrEmpty(findTerm)) + { + Debug.LogWarning("Find instruction missing 'find' term."); + return null; + } + + // Use a flexible default search method if none provided + string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; + + // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first + if (typeof(Material).IsAssignableFrom(targetType) || + typeof(Texture).IsAssignableFrom(targetType) || + typeof(ScriptableObject).IsAssignableFrom(targetType) || + targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. + typeof(AudioClip).IsAssignableFrom(targetType) || + typeof(AnimationClip).IsAssignableFrom(targetType) || + typeof(Font).IsAssignableFrom(targetType) || + typeof(Shader).IsAssignableFrom(targetType) || + typeof(ComputeShader).IsAssignableFrom(targetType) || + typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check + { // Try loading directly by path/GUID first UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); - if (asset != null) return asset; - asset = AssetDatabase.LoadAssetAtPath(findTerm); // Try generic if type specific failed - if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; - - - // If direct path failed, try finding by name/type using FindAssets - string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name - string[] guids = AssetDatabase.FindAssets(searchFilter); - - if (guids.Length == 1) - { - asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); - if (asset != null) return asset; - } - else if (guids.Length > 1) - { - Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); - // Optionally return the first one? Or null? Returning null is safer. - return null; - } - // If still not found, fall through to scene search (though unlikely for assets) - } - - - // --- Scene Object Search --- - // Find the GameObject using the internal finder - GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); - - if (foundGo == null) - { - // Don't warn yet, could still be an asset not found above - // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); - return null; - } - - // Now, get the target object/component from the found GameObject - if (targetType == typeof(GameObject)) - { - return foundGo; // We were looking for a GameObject - } - else if (typeof(Component).IsAssignableFrom(targetType)) - { - Type componentToGetType = targetType; - if (!string.IsNullOrEmpty(componentName)) - { - Type specificCompType = FindType(componentName); - if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) - { - componentToGetType = specificCompType; - } - else - { - Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); - } - } - - Component foundComp = foundGo.GetComponent(componentToGetType); - if (foundComp == null) - { - Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); - } - return foundComp; - } - else - { - Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); - return null; - } - } + if (asset != null) return asset; + asset = AssetDatabase.LoadAssetAtPath(findTerm); // Try generic if type specific failed + if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; + + + // If direct path failed, try finding by name/type using FindAssets + string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name + string[] guids = AssetDatabase.FindAssets(searchFilter); + + if (guids.Length == 1) + { + asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); + if (asset != null) return asset; + } + else if (guids.Length > 1) + { + Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); + // Optionally return the first one? Or null? Returning null is safer. + return null; + } + // If still not found, fall through to scene search (though unlikely for assets) + } + + + // --- Scene Object Search --- + // Find the GameObject using the internal finder + GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); + + if (foundGo == null) + { + // Don't warn yet, could still be an asset not found above + // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); + return null; + } + + // Now, get the target object/component from the found GameObject + if (targetType == typeof(GameObject)) + { + return foundGo; // We were looking for a GameObject + } + else if (typeof(Component).IsAssignableFrom(targetType)) + { + Type componentToGetType = targetType; + if (!string.IsNullOrEmpty(componentName)) + { + Type specificCompType = FindType(componentName); + if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) + { + componentToGetType = specificCompType; + } + else + { + Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); + } + } + + Component foundComp = foundGo.GetComponent(componentToGetType); + if (foundComp == null) + { + Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); + } + return foundComp; + } + else + { + Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); + return null; + } + } /// @@ -2256,17 +2268,17 @@ private static Type FindType(string typeName) { return resolvedType; } - + // Log the resolver error if type wasn't found if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"[FindType] {error}"); } - + return null; } } - + /// /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. /// Prioritizes runtime (Player) assemblies over Editor assemblies. @@ -2445,7 +2457,7 @@ public static List GetAIPropertySuggestions(string userInput, List GetRuleBasedSuggestions(string userInput, List 0 && char.IsUpper(trimmedLine[0]) @@ -568,4 +568,3 @@ May change between versions. */ } } - diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index cdaa6c17..a02193e6 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -31,17 +31,17 @@ public class MCPForUnityEditorWindow : EditorWindow private bool lastBridgeVerifiedOk; private string pythonDirOverride = null; private bool debugLogsEnabled; - + // Script validation settings private int validationLevelIndex = 1; // Default to Standard private readonly string[] validationLevelOptions = new string[] { "Basic - Only syntax checks", - "Standard - Syntax + Unity practices", + "Standard - Syntax + Unity practices", "Comprehensive - All checks + semantic analysis", "Strict - Full semantic validation (requires Roslyn)" }; - + // UI state private int selectedClientIndex = 0; @@ -67,7 +67,7 @@ private void OnEnable() { CheckMcpConfiguration(mcpClient); } - + // Load validation level setting LoadValidationLevelSetting(); @@ -77,7 +77,7 @@ private void OnEnable() AutoFirstRunSetup(); } } - + private void OnFocus() { // Refresh bridge running state on focus in case initialization completed after domain reload @@ -172,7 +172,7 @@ private void OnGUI() // Header DrawHeader(); - + // Compute equal column widths for uniform layout float horizontalSpacing = 2f; float outerPadding = 20f; // approximate padding @@ -226,13 +226,13 @@ private void DrawHeader() EditorGUILayout.Space(15); Rect titleRect = EditorGUILayout.GetControlRect(false, 40); EditorGUI.DrawRect(titleRect, new Color(0.2f, 0.2f, 0.2f, 0.1f)); - + GUIStyle titleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 16, alignment = TextAnchor.MiddleLeft }; - + GUI.Label( new Rect(titleRect.x + 15, titleRect.y + 8, titleRect.width - 30, titleRect.height), "MCP for Unity Editor", @@ -323,7 +323,7 @@ private static string ReadEmbeddedVersionOrFallback() private void DrawServerStatusSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 @@ -334,7 +334,7 @@ private void DrawServerStatusSection() EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); - + GUIStyle statusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, @@ -344,14 +344,14 @@ private void DrawServerStatusSection() EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(5); - + EditorGUILayout.BeginHorizontal(); bool isAutoMode = MCPForUnityBridge.IsAutoConnectMode(); GUIStyle modeStyle = new GUIStyle(EditorStyles.miniLabel) { fontSize = 11 }; EditorGUILayout.LabelField($"Mode: {(isAutoMode ? "Auto" : "Standard")}", modeStyle); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); - + int currentUnityPort = MCPForUnityBridge.GetCurrentPort(); GUIStyle portStyle = new GUIStyle(EditorStyles.miniLabel) { @@ -441,7 +441,7 @@ private void DrawServerStatusSection() private void DrawBridgeSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - + // Always reflect the live state each repaint to avoid stale UI after recompiles isUnityBridgeRunning = MCPForUnityBridge.IsRunning; @@ -451,12 +451,12 @@ private void DrawBridgeSection() }; EditorGUILayout.LabelField("Unity Bridge", sectionTitleStyle); EditorGUILayout.Space(8); - + EditorGUILayout.BeginHorizontal(); Color bridgeColor = isUnityBridgeRunning ? Color.green : Color.red; Rect bridgeStatusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); DrawStatusDot(bridgeStatusRect, bridgeColor, 16); - + GUIStyle bridgeStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, @@ -477,21 +477,21 @@ private void DrawBridgeSection() private void DrawValidationSection() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("Script Validation", sectionTitleStyle); EditorGUILayout.Space(8); - + EditorGUI.BeginChangeCheck(); validationLevelIndex = EditorGUILayout.Popup("Validation Level", validationLevelIndex, validationLevelOptions, GUILayout.Height(20)); if (EditorGUI.EndChangeCheck()) { SaveValidationLevelSetting(); } - + EditorGUILayout.Space(8); string description = GetValidationLevelDescription(validationLevelIndex); EditorGUILayout.HelpBox(description, MessageType.Info); @@ -504,15 +504,15 @@ private void DrawValidationSection() private void DrawUnifiedClientConfiguration() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); - + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 }; EditorGUILayout.LabelField("MCP Client Configuration", sectionTitleStyle); EditorGUILayout.Space(10); - - // (Auto-connect toggle removed per design) + + // (Auto-connect toggle removed per design) // Client selector string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); @@ -522,15 +522,15 @@ private void DrawUnifiedClientConfiguration() { selectedClientIndex = Mathf.Clamp(selectedClientIndex, 0, mcpClients.clients.Count - 1); } - + EditorGUILayout.Space(10); - + if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) { McpClient selectedClient = mcpClients.clients[selectedClientIndex]; DrawClientConfigurationCompact(selectedClient); } - + EditorGUILayout.Space(5); EditorGUILayout.EndVertical(); } @@ -582,10 +582,10 @@ private void AutoFirstRunSetup() MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered - || IsCursorConfigured(pythonDir) - || CodexConfigHelper.IsCodexConfigured(pythonDir) - || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port @@ -676,10 +676,10 @@ private void RunSetupNow() UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered - || IsCursorConfigured(pythonDir) - || CodexConfigHelper.IsCodexConfigured(pythonDir) - || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); @@ -695,14 +695,14 @@ private void RunSetupNow() } } - private static bool IsCursorConfigured(string pythonDir) - { - try - { - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + private static bool IsCursorConfigured(string pythonDir) + { + try + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") - : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json"); if (!File.Exists(configPath)) return false; string json = File.ReadAllText(configPath); @@ -861,37 +861,37 @@ private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int max private void DrawClientConfigurationCompact(McpClient mcpClient) { - // Special pre-check for Claude Code: if CLI missing, reflect in status UI - if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - string claudeCheck = ExecPath.ResolveClaude(); - if (string.IsNullOrEmpty(claudeCheck)) - { - mcpClient.configStatus = "Claude Not Found"; - mcpClient.status = McpStatus.NotConfigured; - } - } - - // Pre-check for clients that require uv (all except Claude Code) - bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; - bool uvMissingEarly = false; - if (uvRequired) - { - string uvPathEarly = FindUvPath(); - if (string.IsNullOrEmpty(uvPathEarly)) - { - uvMissingEarly = true; - mcpClient.configStatus = "uv Not Found"; - mcpClient.status = McpStatus.NotConfigured; - } - } + // Special pre-check for Claude Code: if CLI missing, reflect in status UI + if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + string claudeCheck = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudeCheck)) + { + mcpClient.configStatus = "Claude Not Found"; + mcpClient.status = McpStatus.NotConfigured; + } + } + + // Pre-check for clients that require uv (all except Claude Code) + bool uvRequired = mcpClient.mcpType != McpTypes.ClaudeCode; + bool uvMissingEarly = false; + if (uvRequired) + { + string uvPathEarly = FindUvPath(); + if (string.IsNullOrEmpty(uvPathEarly)) + { + uvMissingEarly = true; + mcpClient.configStatus = "uv Not Found"; + mcpClient.status = McpStatus.NotConfigured; + } + } // Status display EditorGUILayout.BeginHorizontal(); Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); Color statusColor = GetStatusColor(mcpClient.status); DrawStatusDot(statusRect, statusColor, 16); - + GUIStyle clientStatusStyle = new GUIStyle(EditorStyles.label) { fontSize = 12, @@ -899,68 +899,68 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) }; EditorGUILayout.LabelField(mcpClient.configStatus, clientStatusStyle, GUILayout.Height(28)); EditorGUILayout.EndHorizontal(); - // When Claude CLI is missing, show a clear install hint directly below status - if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) - { - GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); - installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange - EditorGUILayout.BeginHorizontal(); - GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); - Vector2 textSize = installHintStyle.CalcSize(installText); - EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); - GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; - GUILayout.Space(6); - if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); - } - EditorGUILayout.EndHorizontal(); - } - - EditorGUILayout.Space(10); - - // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls - if (uvRequired && uvMissingEarly) - { - GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) - { - fontSize = 12, - fontStyle = FontStyle.Bold, - wordWrap = false - }; - installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); - EditorGUILayout.BeginHorizontal(); - GUIContent installText2 = new GUIContent("Make sure uv is installed!"); - Vector2 sz = installHintStyle2.CalcSize(installText2); - EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); - GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; - GUILayout.Space(6); - if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) - { - Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); - } - EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - EditorGUILayout.BeginHorizontal(); - if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) - { - string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); - if (!string.IsNullOrEmpty(picked)) - { - EditorPrefs.SetString("MCPForUnity.UvPath", picked); - ConfigureMcpClient(mcpClient); - Repaint(); - } - } - EditorGUILayout.EndHorizontal(); - return; - } - + // When Claude CLI is missing, show a clear install hint directly below status + if (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) + { + GUIStyle installHintStyle = new GUIStyle(clientStatusStyle); + installHintStyle.normal.textColor = new Color(1f, 0.5f, 0f); // orange + EditorGUILayout.BeginHorizontal(); + GUIContent installText = new GUIContent("Make sure Claude Code is installed!"); + Vector2 textSize = installHintStyle.CalcSize(installText); + EditorGUILayout.LabelField(installText, installHintStyle, GUILayout.Height(22), GUILayout.Width(textSize.x + 2), GUILayout.ExpandWidth(false)); + GUIStyle helpLinkStyle = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; + GUILayout.Space(6); + if (GUILayout.Button("[HELP]", helpLinkStyle, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Claude-Code"); + } + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.Space(10); + + // If uv is missing for required clients, show hint and picker then exit early to avoid showing other controls + if (uvRequired && uvMissingEarly) + { + GUIStyle installHintStyle2 = new GUIStyle(EditorStyles.label) + { + fontSize = 12, + fontStyle = FontStyle.Bold, + wordWrap = false + }; + installHintStyle2.normal.textColor = new Color(1f, 0.5f, 0f); + EditorGUILayout.BeginHorizontal(); + GUIContent installText2 = new GUIContent("Make sure uv is installed!"); + Vector2 sz = installHintStyle2.CalcSize(installText2); + EditorGUILayout.LabelField(installText2, installHintStyle2, GUILayout.Height(22), GUILayout.Width(sz.x + 2), GUILayout.ExpandWidth(false)); + GUIStyle helpLinkStyle2 = new GUIStyle(EditorStyles.linkLabel) { fontStyle = FontStyle.Bold }; + GUILayout.Space(6); + if (GUILayout.Button("[HELP]", helpLinkStyle2, GUILayout.Height(22), GUILayout.ExpandWidth(false))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp/wiki/Troubleshooting-Unity-MCP-and-Cursor,-VSCode-&-Windsurf"); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Choose uv Install Location", GUILayout.Width(260), GUILayout.Height(22))) + { + string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string picked = EditorUtility.OpenFilePanel("Select 'uv' binary", suggested, ""); + if (!string.IsNullOrEmpty(picked)) + { + EditorPrefs.SetString("MCPForUnity.UvPath", picked); + ConfigureMcpClient(mcpClient); + Repaint(); + } + } + EditorGUILayout.EndHorizontal(); + return; + } + // Action buttons in horizontal layout EditorGUILayout.BeginHorizontal(); - + if (mcpClient.mcpType == McpTypes.VSCode) { if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) @@ -968,57 +968,57 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) ConfigureMcpClient(mcpClient); } } - else if (mcpClient.mcpType == McpTypes.ClaudeCode) - { - bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); - if (claudeAvailable) - { - bool isConfigured = mcpClient.status == McpStatus.Configured; - string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; - if (GUILayout.Button(buttonText, GUILayout.Height(32))) - { - if (isConfigured) - { - UnregisterWithClaudeCode(); - } - else - { - string pythonDir = FindPackagePythonDirectory(); - RegisterWithClaudeCode(pythonDir); - } - } - // Hide the picker once a valid binary is available - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; - string resolvedClaude = ExecPath.ResolveClaude(); - EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - } - // CLI picker row (only when not found) - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - if (!claudeAvailable) - { - // Only show the picker button in not-found state (no redundant "not found" label) - if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) - { - string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); - if (!string.IsNullOrEmpty(picked)) - { - ExecPath.SetClaudeCliPath(picked); - // Auto-register after setting a valid path - string pythonDir = FindPackagePythonDirectory(); - RegisterWithClaudeCode(pythonDir); - Repaint(); - } - } - } - EditorGUILayout.EndHorizontal(); - EditorGUILayout.BeginHorizontal(); - } + else if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); + if (claudeAvailable) + { + bool isConfigured = mcpClient.status == McpStatus.Configured; + string buttonText = isConfigured ? "Unregister MCP for Unity with Claude Code" : "Register with Claude Code"; + if (GUILayout.Button(buttonText, GUILayout.Height(32))) + { + if (isConfigured) + { + UnregisterWithClaudeCode(); + } + else + { + string pythonDir = FindPackagePythonDirectory(); + RegisterWithClaudeCode(pythonDir); + } + } + // Hide the picker once a valid binary is available + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + GUIStyle pathLabelStyle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true }; + string resolvedClaude = ExecPath.ResolveClaude(); + EditorGUILayout.LabelField($"Claude CLI: {resolvedClaude}", pathLabelStyle); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + } + // CLI picker row (only when not found) + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + if (!claudeAvailable) + { + // Only show the picker button in not-found state (no redundant "not found" label) + if (GUILayout.Button("Choose Claude Install Location", GUILayout.Width(260), GUILayout.Height(22))) + { + string suggested = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "/opt/homebrew/bin" : Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string picked = EditorUtility.OpenFilePanel("Select 'claude' CLI", suggested, ""); + if (!string.IsNullOrEmpty(picked)) + { + ExecPath.SetClaudeCliPath(picked); + // Auto-register after setting a valid path + string pythonDir = FindPackagePythonDirectory(); + RegisterWithClaudeCode(pythonDir); + Repaint(); + } + } + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + } else { if (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) @@ -1026,7 +1026,7 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) ConfigureMcpClient(mcpClient); } } - + if (mcpClient.mcpType != McpTypes.ClaudeCode) { if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) @@ -1034,7 +1034,7 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath; - + if (mcpClient.mcpType == McpTypes.VSCode) { string pythonDir = FindPackagePythonDirectory(); @@ -1066,22 +1066,22 @@ private void DrawClientConfigurationCompact(McpClient mcpClient) } } } - + EditorGUILayout.EndHorizontal(); - - EditorGUILayout.Space(8); - // Quick info (hide when Claude is not found to avoid confusion) - bool hideConfigInfo = - (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) - || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); - if (!hideConfigInfo) - { - GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) - { - fontSize = 10 - }; - EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); - } + + EditorGUILayout.Space(8); + // Quick info (hide when Claude is not found to avoid confusion) + bool hideConfigInfo = + (mcpClient.mcpType == McpTypes.ClaudeCode && string.IsNullOrEmpty(ExecPath.ResolveClaude())) + || ((mcpClient.mcpType != McpTypes.ClaudeCode) && string.IsNullOrEmpty(FindUvPath())); + if (!hideConfigInfo) + { + GUIStyle configInfoStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 10 + }; + EditorGUILayout.LabelField($"Config: {Path.GetFileName(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? mcpClient.windowsConfigPath : mcpClient.linuxConfigPath)}", configInfoStyle); + } } private void ToggleUnityBridge() @@ -1099,52 +1099,52 @@ private void ToggleUnityBridge() Repaint(); } - private static bool IsValidUv(string path) - { - return !string.IsNullOrEmpty(path) - && System.IO.Path.IsPathRooted(path) - && System.IO.File.Exists(path); - } - - private static bool ValidateUvBinarySafe(string path) - { - try - { - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false; - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = path, - Arguments = "--version", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return false; - if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } - if (p.ExitCode != 0) return false; - string output = p.StandardOutput.ReadToEnd().Trim(); - return output.StartsWith("uv "); - } - catch { return false; } - } - - private static bool ArgsEqual(string[] a, string[] b) - { - if (a == null || b == null) return a == b; - if (a.Length != b.Length) return false; - for (int i = 0; i < a.Length; i++) - { - if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; - } - return true; - } + private static bool IsValidUv(string path) + { + return !string.IsNullOrEmpty(path) + && System.IO.Path.IsPathRooted(path) + && System.IO.File.Exists(path); + } + + private static bool ValidateUvBinarySafe(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return false; + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = path, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return false; + if (!p.WaitForExit(3000)) { try { p.Kill(); } catch { } return false; } + if (p.ExitCode != 0) return false; + string output = p.StandardOutput.ReadToEnd().Trim(); + return output.StartsWith("uv "); + } + catch { return false; } + } + + private static bool ArgsEqual(string[] a, string[] b) + { + if (a == null || b == null) return a == b; + if (a.Length != b.Length) return false; + for (int i = 0; i < a.Length; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.Ordinal)) return false; + } + return true; + } 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 { } + // 0) Respect explicit lock (hidden pref or UI toggle) + try { if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; @@ -1185,52 +1185,52 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingConfig = new Newtonsoft.Json.Linq.JObject(); } - // Determine existing entry references (command/args) - string existingCommand = null; - string[] existingArgs = null; - bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); - try - { - if (isVSCode) - { - existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); - existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); - } - else - { - existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); - existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); - } - } - catch { } - - // 1) Start from existing, only fill gaps (prefer trusted resolver) - string uvPath = ServerInstaller.FindUvPath(); - // Optionally trust existingCommand if it looks like uv/uv.exe - try - { - var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); - if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand)) - { - uvPath = existingCommand; - } - } - catch { } - if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); - - // 2) Canonical args order - var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; - - // 3) Only write if changed - bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) - || !ArgsEqual(existingArgs, newArgs); - if (!changed) - { - return "Configured successfully"; // nothing to do - } - - // 4) Ensure containers exist and write back minimal changes + // Determine existing entry references (command/args) + string existingCommand = null; + string[] existingArgs = null; + bool isVSCode = (mcpClient?.mcpType == McpTypes.VSCode); + try + { + if (isVSCode) + { + existingCommand = existingConfig?.servers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.servers?.unityMCP?.args?.ToObject(); + } + else + { + existingCommand = existingConfig?.mcpServers?.unityMCP?.command?.ToString(); + existingArgs = existingConfig?.mcpServers?.unityMCP?.args?.ToObject(); + } + } + catch { } + + // 1) Start from existing, only fill gaps (prefer trusted resolver) + string uvPath = ServerInstaller.FindUvPath(); + // Optionally trust existingCommand if it looks like uv/uv.exe + try + { + var name = System.IO.Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); + if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand)) + { + uvPath = existingCommand; + } + } + catch { } + if (uvPath == null) return "UV package manager not found. Please install UV first."; + string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); + + // 2) Canonical args order + var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; + + // 3) Only write if changed + bool changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) + || !ArgsEqual(existingArgs, newArgs); + if (!changed) + { + return "Configured successfully"; // nothing to do + } + + // 4) Ensure containers exist and write back minimal changes JObject existingRoot; if (existingConfig is JObject eo) existingRoot = eo; @@ -1239,18 +1239,18 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); - string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - - McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - try - { - if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } + McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); + + try + { + if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } - return "Configured successfully"; + return "Configured successfully"; } private void ShowManualConfigurationInstructions( @@ -1264,23 +1264,23 @@ McpClient mcpClient } // New method to show manual instructions without changing status - private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) - { - // Get the Python directory path using Package Manager API - string pythonDir = FindPackagePythonDirectory(); - // Build manual JSON centrally using the shared builder - string uvPathForManual = FindUvPath(); - if (uvPathForManual == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); - return; - } - - string manualConfig = mcpClient?.mcpType == McpTypes.Codex - ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine - : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); - ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); - } + private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) + { + // Get the Python directory path using Package Manager API + string pythonDir = FindPackagePythonDirectory(); + // Build manual JSON centrally using the shared builder + string uvPathForManual = FindUvPath(); + if (uvPathForManual == null) + { + UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); + return; + } + + string manualConfig = mcpClient?.mcpType == McpTypes.Codex + ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine + : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); + ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); + } private string FindPackagePythonDirectory() { @@ -1297,7 +1297,7 @@ private string FindPackagePythonDirectory() Path.Combine(currentPackagePath, "unity-mcp", "UnityMcpServer", "src"), Path.Combine(Path.GetDirectoryName(currentPackagePath), "unity-mcp", "UnityMcpServer", "src"), }; - + foreach (string devPath in devPaths) { if (Directory.Exists(devPath) && File.Exists(Path.Combine(devPath, "server.py"))) @@ -1311,25 +1311,25 @@ private string FindPackagePythonDirectory() } } - // Resolve via shared helper (handles local registry and older fallback) only if dev override on - if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) - { - return embedded; - } - } - - // Log only if the resolved path does not actually contain server.py - if (debugLogsEnabled) - { - bool hasServer = false; - try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } - if (!hasServer) - { - UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); - } - } + // Resolve via shared helper (handles local registry and older fallback) only if dev override on + if (UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)) + { + return embedded; + } + } + + // Log only if the resolved path does not actually contain server.py + if (debugLogsEnabled) + { + bool hasServer = false; + try { hasServer = File.Exists(Path.Combine(pythonDir, "server.py")); } catch { } + if (!hasServer) + { + UnityEngine.Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); + } + } } catch (Exception e) { @@ -1368,12 +1368,12 @@ private bool IsDevelopmentMode() } } - private string ConfigureMcpClient(McpClient mcpClient) - { - try - { - // Determine the config file path based on OS - string configPath; + private string ConfigureMcpClient(McpClient mcpClient) + { + try + { + // Determine the config file path based on OS + string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -1401,23 +1401,23 @@ private string ConfigureMcpClient(McpClient mcpClient) // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - // Find the server.py file location using the same logic as FindPackagePythonDirectory - string pythonDir = FindPackagePythonDirectory(); + // Find the server.py file location using the same logic as FindPackagePythonDirectory + string pythonDir = FindPackagePythonDirectory(); - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - ShowManualInstructionsWindow(configPath, mcpClient); - return "Manual Configuration Required"; - } + if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + ShowManualInstructionsWindow(configPath, mcpClient); + return "Manual Configuration Required"; + } - string result = mcpClient.mcpType == McpTypes.Codex - ? ConfigureCodexClient(pythonDir, configPath, mcpClient) - : WriteToConfig(pythonDir, configPath, mcpClient); + string result = mcpClient.mcpType == McpTypes.Codex + ? ConfigureCodexClient(pythonDir, configPath, mcpClient) + : WriteToConfig(pythonDir, configPath, mcpClient); - // Update the client status after successful configuration - if (result == "Configured successfully") - { - mcpClient.SetStatus(McpStatus.Configured); + // Update the client status after successful configuration + if (result == "Configured successfully") + { + mcpClient.SetStatus(McpStatus.Configured); } return result; @@ -1450,82 +1450,82 @@ private string ConfigureMcpClient(McpClient mcpClient) $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); return $"Failed to configure {mcpClient.name}"; - } - } - - private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) - { - try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } - - string existingToml = string.Empty; - if (File.Exists(configPath)) - { - try - { - existingToml = File.ReadAllText(configPath); - } - catch (Exception e) - { - if (debugLogsEnabled) - { - UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); - } - existingToml = string.Empty; - } - } - - string existingCommand = null; - string[] existingArgs = null; - if (!string.IsNullOrWhiteSpace(existingToml)) - { - CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); - } - - string uvPath = ServerInstaller.FindUvPath(); - try - { - var name = 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 = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); - var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; - - bool changed = true; - if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null) - { - changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) - || !ArgsEqual(existingArgs, newArgs); - } - - if (!changed) - { - return "Configured successfully"; - } - - string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc); - string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock); - - McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml); - - try - { - if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - - return "Configured successfully"; - } + } + } + + private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) + { + try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { } + + string existingToml = string.Empty; + if (File.Exists(configPath)) + { + try + { + existingToml = File.ReadAllText(configPath); + } + catch (Exception e) + { + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}"); + } + existingToml = string.Empty; + } + } + + string existingCommand = null; + string[] existingArgs = null; + if (!string.IsNullOrWhiteSpace(existingToml)) + { + CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); + } + + string uvPath = ServerInstaller.FindUvPath(); + try + { + var name = 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 = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); + var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; + + bool changed = true; + if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null) + { + changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal) + || !ArgsEqual(existingArgs, newArgs); + } + + if (!changed) + { + return "Configured successfully"; + } + + string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc); + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock); + + McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml); + + try + { + if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } private void ShowCursorManualConfigurationInstructions( string configPath, @@ -1544,7 +1544,7 @@ McpClient mcpClient UnityEngine.Debug.LogError("UV package manager not found. Cannot configure manual setup."); return; } - + McpConfig jsonConfig = new() { mcpServers = new McpConfigServers @@ -1617,7 +1617,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) CheckClaudeCodeConfiguration(mcpClient); return; } - + string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -1652,42 +1652,42 @@ private void CheckMcpConfiguration(McpClient mcpClient) string configJson = File.ReadAllText(configPath); // Use the same path resolution as configuration to avoid false "Incorrect Path" in dev mode string pythonDir = FindPackagePythonDirectory(); - + // Use switch statement to handle different client types, extracting common logic string[] args = null; bool configExists = false; - - switch (mcpClient.mcpType) - { - case McpTypes.VSCode: - dynamic config = JsonConvert.DeserializeObject(configJson); - - // New schema: top-level servers - if (config?.servers?.unityMCP != null) - { - args = config.servers.unityMCP.args.ToObject(); - configExists = true; - } - // Back-compat: legacy mcp.servers - else if (config?.mcp?.servers?.unityMCP != null) - { - args = config.mcp.servers.unityMCP.args.ToObject(); - configExists = true; - } - break; - - case McpTypes.Codex: - if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) - { - args = codexArgs; - configExists = true; - } - break; - - default: - // Standard MCP configuration check for Claude Desktop, Cursor, etc. - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); - + + switch (mcpClient.mcpType) + { + case McpTypes.VSCode: + dynamic config = JsonConvert.DeserializeObject(configJson); + + // New schema: top-level servers + if (config?.servers?.unityMCP != null) + { + args = config.servers.unityMCP.args.ToObject(); + configExists = true; + } + // Back-compat: legacy mcp.servers + else if (config?.mcp?.servers?.unityMCP != null) + { + args = config.mcp.servers.unityMCP.args.ToObject(); + configExists = true; + } + break; + + case McpTypes.Codex: + if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) + { + args = codexArgs; + configExists = true; + } + break; + + default: + // Standard MCP configuration check for Claude Desktop, Cursor, etc. + McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); + if (standardConfig?.mcpServers?.unityMCP != null) { args = standardConfig.mcpServers.unityMCP.args; @@ -1695,7 +1695,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) } break; } - + // Common logic for checking configuration status if (configExists) { @@ -1812,35 +1812,35 @@ private void UnregisterWithClaudeCode() ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" : null; // On Windows, don't modify PATH - use system PATH as-is - // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get ` - string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; - List existingNames = new List(); - foreach (var candidate in candidateNamesForGet) - { - if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) - { - // Success exit code indicates the server exists - existingNames.Add(candidate); - } - } - - if (existingNames.Count == 0) - { - // Nothing to unregister – set status and bail early - var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); - if (claudeClient != null) - { - claudeClient.SetStatus(McpStatus.NotConfigured); - UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); - Repaint(); - } - return; - } - + // Determine if Claude has a "UnityMCP" server registered by using exit codes from `claude mcp get ` + string[] candidateNamesForGet = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; + List existingNames = new List(); + foreach (var candidate in candidateNamesForGet) + { + if (ExecPath.TryRun(claudePath, $"mcp get {candidate}", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) + { + // Success exit code indicates the server exists + existingNames.Add(candidate); + } + } + + if (existingNames.Count == 0) + { + // Nothing to unregister – set status and bail early + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + claudeClient.SetStatus(McpStatus.NotConfigured); + UnityEngine.Debug.Log("Claude CLI reports no MCP for Unity server via 'mcp get' - setting status to NotConfigured and aborting unregister."); + Repaint(); + } + return; + } + // Try different possible server names string[] possibleNames = { "UnityMCP", "unityMCP", "unity-mcp", "UnityMcpServer" }; bool success = false; - + foreach (string serverName in possibleNames) { if (ExecPath.TryRun(claudePath, $"mcp remove {serverName}", projectDir, out var stdout, out var stderr, 10000, pathPrepend)) @@ -1905,7 +1905,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) // Get the Unity project directory to check project-specific config string unityProjectDir = Application.dataPath; string projectDir = Path.GetDirectoryName(unityProjectDir); - + // Read the global Claude config file (honor macConfigPath on macOS) string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -1914,22 +1914,22 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) ? mcpClient.linuxConfigPath : mcpClient.macConfigPath; else configPath = mcpClient.linuxConfigPath; - + if (debugLogsEnabled) { MCPForUnity.Editor.Helpers.McpLog.Info($"Checking Claude config at: {configPath}", always: false); } - + if (!File.Exists(configPath)) { UnityEngine.Debug.LogWarning($"Claude config file not found at: {configPath}"); mcpClient.SetStatus(McpStatus.NotConfigured); return; } - + string configJson = File.ReadAllText(configPath); dynamic claudeConfig = JsonConvert.DeserializeObject(configJson); - + // Check for "UnityMCP" server in the mcpServers section (current format) if (claudeConfig?.mcpServers != null) { @@ -1941,7 +1941,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) return; } } - + // Also check if there's a project-specific configuration for this Unity project (legacy format) if (claudeConfig?.projects != null) { @@ -1949,11 +1949,11 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) foreach (var project in claudeConfig.projects) { string projectPath = project.Name; - + // Normalize paths for comparison (handle forward/back slash differences) string normalizedProjectPath = Path.GetFullPath(projectPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string normalizedProjectDir = Path.GetFullPath(projectDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - + if (string.Equals(normalizedProjectPath, normalizedProjectDir, StringComparison.OrdinalIgnoreCase) && project.Value?.mcpServers != null) { // Check for "UnityMCP" (case variations) @@ -1967,7 +1967,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) } } } - + // No configuration found for this project mcpClient.SetStatus(McpStatus.NotConfigured); } @@ -2004,7 +2004,7 @@ private bool IsPythonDetected() Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python310\python.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python39\python.exe"), }; - + foreach (string c in windowsCandidates) { if (File.Exists(c)) return true; diff --git a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs index e5544510..10e066d2 100644 --- a/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs +++ b/UnityMcpBridge/Editor/Windows/VSCodeManualSetupWindow.cs @@ -13,14 +13,14 @@ public static void ShowWindow(string configPath, string configJson) window.configPath = configPath; window.configJson = configJson; window.minSize = new Vector2(550, 500); - + // Create a McpClient for VSCode window.mcpClient = new McpClient { name = "VSCode GitHub Copilot", mcpType = McpTypes.VSCode }; - + window.Show(); } @@ -84,7 +84,7 @@ protected override void OnGUI() instructionStyle ); EditorGUILayout.Space(5); - + EditorGUILayout.LabelField( "2. Steps to Configure", EditorStyles.boldLabel @@ -102,7 +102,7 @@ protected override void OnGUI() instructionStyle ); EditorGUILayout.Space(5); - + EditorGUILayout.LabelField( "3. VSCode mcp.json location:", EditorStyles.boldLabel @@ -120,7 +120,7 @@ protected override void OnGUI() "mcp.json" ); } - else + else { displayPath = System.IO.Path.Combine( System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), diff --git a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs index 05503f42..c76b280d 100644 --- a/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs +++ b/UnityMcpBridge/Runtime/Serialization/UnityTypeConverters.cs @@ -110,7 +110,7 @@ public override Color ReadJson(JsonReader reader, Type objectType, Color existin ); } } - + public class RectConverter : JsonConverter { public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) @@ -138,7 +138,7 @@ public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingV ); } } - + public class BoundsConverter : JsonConverter { public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) @@ -263,4 +263,4 @@ public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index a2765cc2..af6fe036 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,3 +1,4 @@ +from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType from mcp.server.fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler @@ -21,10 +22,12 @@ # Also write logs to a rotating file so logs are available when launched via stdio try: import os as _os - _log_dir = _os.path.join(_os.path.expanduser("~/Library/Application Support/UnityMCP"), "Logs") + _log_dir = _os.path.join(_os.path.expanduser( + "~/Library/Application Support/UnityMCP"), "Logs") _os.makedirs(_log_dir, exist_ok=True) _file_path = _os.path.join(_log_dir, "unity_mcp_server.log") - _fh = RotatingFileHandler(_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") + _fh = RotatingFileHandler( + _file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8") _fh.setFormatter(logging.Formatter(config.log_format)) _fh.setLevel(getattr(logging, config.log_level)) logger.addHandler(_fh) @@ -42,7 +45,8 @@ # Quieten noisy third-party loggers to avoid clutter during stdio handshake for noisy in ("httpx", "urllib3"): try: - logging.getLogger(noisy).setLevel(max(logging.WARNING, getattr(logging, config.log_level))) + logging.getLogger(noisy).setLevel( + max(logging.WARNING, getattr(logging, config.log_level))) except Exception: pass @@ -50,13 +54,11 @@ # Ensure a slightly higher telemetry timeout unless explicitly overridden by env try: - # Ensure generous timeout unless explicitly overridden by env if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"): os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0" except Exception: pass -from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType # Global connection state _unity_connection: UnityConnection = None @@ -67,7 +69,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Handle server startup and shutdown.""" global _unity_connection logger.info("MCP for Unity Server starting up") - + # Record server startup telemetry start_time = time.time() start_clk = time.perf_counter() @@ -79,6 +81,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: server_version = "unknown" # Defer initial telemetry by 1s to avoid stdio handshake interference import threading + def _emit_startup(): try: record_telemetry(RecordType.STARTUP, { @@ -89,15 +92,17 @@ def _emit_startup(): except Exception: logger.debug("Deferred startup telemetry failed", exc_info=True) threading.Timer(1.0, _emit_startup).start() - + try: - skip_connect = os.environ.get("UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") + skip_connect = os.environ.get( + "UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on") if skip_connect: - logger.info("Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") + logger.info( + "Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)") else: _unity_connection = get_unity_connection() logger.info("Connected to Unity on startup") - + # Record successful Unity connection (deferred) import threading as _t _t.Timer(1.0, lambda: record_telemetry( @@ -107,11 +112,11 @@ def _emit_startup(): "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() - + except ConnectionError as e: logger.warning("Could not connect to Unity on startup: %s", e) _unity_connection = None - + # Record connection failure (deferred) import threading as _t _err_msg = str(e)[:200] @@ -124,7 +129,8 @@ def _emit_startup(): } )).start() except Exception as e: - logger.warning("Unexpected error connecting to Unity on startup: %s", e) + logger.warning( + "Unexpected error connecting to Unity on startup: %s", e) _unity_connection = None import threading as _t _err_msg = str(e)[:200] @@ -136,7 +142,7 @@ def _emit_startup(): "connection_time_ms": (time.perf_counter() - start_clk) * 1000, } )).start() - + try: # Yield the connection object so it can be attached to the context # The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge) diff --git a/mcp_source.py b/mcp_source.py index 7d5a48a3..203dbe75 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -32,7 +32,8 @@ def run_git(repo: pathlib.Path, *args: str) -> str: "git", "-C", str(repo), *args ], capture_output=True, text=True) if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + raise RuntimeError(result.stderr.strip() + or f"git {' '.join(args)} failed") return result.stdout.strip() @@ -77,7 +78,8 @@ def find_manifest(explicit: Optional[str]) -> pathlib.Path: candidate = parent / "Packages" / "manifest.json" if candidate.exists(): return candidate - raise FileNotFoundError("Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") + raise FileNotFoundError( + "Could not find Packages/manifest.json from current directory. Use --manifest to specify a path.") def read_json(path: pathlib.Path) -> dict: @@ -103,16 +105,21 @@ def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): origin_remote = origin return [ ("[1] Upstream main", upstream), - ("[2] Remote current branch", f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), - ("[3] Local workspace", f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), + ("[2] Remote current branch", + f"{origin_remote}?path=/{BRIDGE_SUBPATH}#{branch}"), + ("[3] Local workspace", + f"file:{(repo_root / BRIDGE_SUBPATH).as_posix()}"), ] def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Switch MCP for Unity package source") + p = argparse.ArgumentParser( + description="Switch MCP for Unity package source") p.add_argument("--manifest", help="Path to Packages/manifest.json") - p.add_argument("--repo", help="Path to unity-mcp repo root (for local file option)") - p.add_argument("--choice", choices=["1", "2", "3"], help="Pick option non-interactively") + p.add_argument( + "--repo", help="Path to unity-mcp repo root (for local file option)") + p.add_argument( + "--choice", choices=["1", "2", "3"], help="Pick option non-interactively") return p.parse_args() @@ -153,7 +160,8 @@ def main() -> None: data = read_json(manifest_path) deps = data.get("dependencies", {}) if PKG_NAME not in deps: - print(f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) + print( + f"Error: '{PKG_NAME}' not found in manifest dependencies.", file=sys.stderr) sys.exit(1) print(f"\nUpdating {PKG_NAME} → {chosen}") diff --git a/test_unity_socket_framing.py b/test_unity_socket_framing.py index 7c0cb93f..27d36855 100644 --- a/test_unity_socket_framing.py +++ b/test_unity_socket_framing.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -import socket, struct, json, sys +import socket +import struct +import json +import sys HOST = "127.0.0.1" PORT = 6400 @@ -10,6 +13,7 @@ FILL = "R" MAX_FRAME = 64 * 1024 * 1024 + def recv_exact(sock, n): buf = bytearray(n) view = memoryview(buf) @@ -21,6 +25,7 @@ def recv_exact(sock, n): off += r return bytes(buf) + def is_valid_json(b): try: json.loads(b.decode("utf-8")) @@ -28,6 +33,7 @@ def is_valid_json(b): except Exception: return False + def recv_legacy_json(sock, timeout=60): sock.settimeout(timeout) chunks = [] @@ -45,6 +51,7 @@ def recv_legacy_json(sock, timeout=60): if is_valid_json(data): return data + def main(): # Cap filler to stay within framing limit (reserve small overhead for JSON) safe_max = max(1, MAX_FRAME - 4096) @@ -83,16 +90,16 @@ def main(): print(f"Response framed length: {resp_len}") MAX_RESP = MAX_FRAME if resp_len <= 0 or resp_len > MAX_RESP: - raise RuntimeError(f"invalid framed length: {resp_len} (max {MAX_RESP})") + 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')}") + print(f"Response head: {resp[:120].decode('utf-8', 'ignore')}") + if __name__ == "__main__": main() - - diff --git a/tests/conftest.py b/tests/conftest.py index a839e9c4..fede9707 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,4 +5,3 @@ os.environ.setdefault("DISABLE_TELEMETRY", "true") os.environ.setdefault("UNITY_MCP_DISABLE_TELEMETRY", "true") os.environ.setdefault("MCP_DISABLE_TELEMETRY", "true") - diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py index ab97e5e2..86f60afa 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -12,7 +12,12 @@ mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") -class _Dummy: pass + + +class _Dummy: + pass + + fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg @@ -21,22 +26,27 @@ class _Dummy: pass sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + def _load(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod + manage_script = _load(SRC / "tools" / "manage_script.py", "manage_script_mod2") -manage_script_edits = _load(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") +manage_script_edits = _load( + SRC / "tools" / "manage_script_edits.py", "manage_script_edits_mod2") class DummyMCP: def __init__(self): self.tools = {} + def tool(self, *args, **kwargs): def deco(fn): self.tools[fn.__name__] = fn; return fn return deco + def setup_tools(): mcp = DummyMCP() manage_script.register_manage_script_tools(mcp) @@ -59,7 +69,8 @@ def fake_send(cmd, params): "range": {"start": {"line": 10, "character": 2}, "end": {"line": 10, "character": 2}}, "newText": "// lsp\n" }] - apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") + apply(None, uri="unity://path/Assets/Scripts/F.cs", + edits=edits, precondition_sha256="x") p = calls[-1] e = p["edits"][0] assert e["startLine"] == 11 and e["startCol"] == 3 @@ -68,24 +79,28 @@ def fake_send(cmd, params): calls.clear() edits = [{"range": [0, 0], "text": "// idx\n"}] # fake read to provide contents length + def fake_read(cmd, params): if params.get("action") == "read": return {"success": True, "data": {"contents": "hello\n"}} return {"success": True} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_read) - apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x") + apply(None, uri="unity://path/Assets/Scripts/F.cs", + edits=edits, precondition_sha256="x") # last call is apply_text_edits - + def test_noop_evidence_shape(monkeypatch): tools = setup_tools() apply = tools["apply_text_edits"] # Route response from Unity indicating no-op + def fake_send(cmd, params): return {"success": True, "data": {"no_op": True, "evidence": {"reason": "identical_content"}}} monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) - resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":""}], precondition_sha256="x") + resp = apply(None, uri="unity://path/Assets/Scripts/F.cs", edits=[ + {"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""}], precondition_sha256="x") assert resp["success"] is True assert resp.get("data", {}).get("no_op") is True @@ -93,9 +108,11 @@ def fake_send(cmd, params): def test_atomic_multi_span_and_relaxed(monkeypatch): tools_text = setup_tools() apply_text = tools_text["apply_text_edits"] - tools_struct = DummyMCP(); manage_script_edits.register_manage_script_edits_tools(tools_struct) + tools_struct = DummyMCP() + manage_script_edits.register_manage_script_edits_tools(tools_struct) # Fake send for read and write; verify atomic applyMode and validate=relaxed passes through sent = {} + def fake_send(cmd, params): if params.get("action") == "read": return {"success": True, "data": {"contents": "public class C{\nvoid M(){ int x=2; }\n}\n"}} @@ -105,12 +122,13 @@ def fake_send(cmd, params): edits = [ {"startLine": 2, "startCol": 14, "endLine": 2, "endCol": 15, "newText": "3"}, - {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"} + {"startLine": 3, "startCol": 2, "endLine": 3, + "endCol": 2, "newText": "// tail\n"} ] - resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) + resp = apply_text(None, uri="unity://path/Assets/Scripts/C.cs", edits=edits, + precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}) assert resp["success"] is True # Last manage_script call should include options with applyMode atomic and validate relaxed last = sent["calls"][-1] assert last.get("options", {}).get("applyMode") == "atomic" assert last.get("options", {}).get("validate") == "relaxed" - diff --git a/tests/test_edit_strict_and_warnings.py b/tests/test_edit_strict_and_warnings.py index 1d35323f..8ae3bdb6 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -12,7 +12,12 @@ mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") -class _Dummy: pass + + +class _Dummy: + pass + + fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg @@ -34,6 +39,7 @@ def _load(path: pathlib.Path, name: str): class DummyMCP: def __init__(self): self.tools = {} + def tool(self, *args, **kwargs): def deco(fn): self.tools[fn.__name__] = fn; return fn return deco @@ -56,13 +62,16 @@ def fake_send(cmd, params): monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) # Explicit fields given as 0-based (invalid); SDK should normalize and warn - edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] - resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha") + edits = [{"startLine": 0, "startCol": 0, + "endLine": 0, "endCol": 0, "newText": "//x"}] + resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", + edits=edits, precondition_sha256="sha") assert resp["success"] is True data = resp.get("data", {}) assert "normalizedEdits" in data - assert any(w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", [])) + assert any( + w == "zero_based_explicit_fields_normalized" for w in data.get("warnings", [])) ne = data["normalizedEdits"][0] assert ne["startLine"] == 1 and ne["startCol"] == 1 and ne["endLine"] == 1 and ne["endCol"] == 1 @@ -76,9 +85,9 @@ def fake_send(cmd, params): monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) - edits = [{"startLine": 0, "startCol": 0, "endLine": 0, "endCol": 0, "newText": "//x"}] - resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True) + edits = [{"startLine": 0, "startCol": 0, + "endLine": 0, "endCol": 0, "newText": "//x"}] + resp = apply_edits(None, uri="unity://path/Assets/Scripts/F.cs", + edits=edits, precondition_sha256="sha", strict=True) assert resp["success"] is False assert resp.get("code") == "zero_based_explicit_fields" - - diff --git a/tests/test_find_in_file_minimal.py b/tests/test_find_in_file_minimal.py index 91e61ad3..a0ebd3b3 100644 --- a/tests/test_find_in_file_minimal.py +++ b/tests/test_find_in_file_minimal.py @@ -1,3 +1,4 @@ +from tools.resource_tools import register_resource_tools # type: ignore import sys import pathlib import importlib.util @@ -9,7 +10,6 @@ SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -from tools.resource_tools import register_resource_tools # type: ignore class DummyMCP: def __init__(self): @@ -21,12 +21,14 @@ def deco(fn): return fn return deco + @pytest.fixture() def resource_tools(): mcp = DummyMCP() register_resource_tools(mcp) return mcp.tools + def test_find_in_file_returns_positions(resource_tools, tmp_path): proj = tmp_path assets = proj / "Assets" @@ -37,9 +39,11 @@ def test_find_in_file_returns_positions(resource_tools, tmp_path): loop = asyncio.new_event_loop() try: resp = loop.run_until_complete( - find_in_file(uri="unity://path/Assets/A.txt", pattern="world", ctx=None, project_root=str(proj)) + find_in_file(uri="unity://path/Assets/A.txt", + pattern="world", ctx=None, project_root=str(proj)) ) finally: loop.close() assert resp["success"] is True - assert resp["data"]["matches"] == [{"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}] + assert resp["data"]["matches"] == [ + {"startLine": 1, "startCol": 7, "endLine": 1, "endCol": 12}] diff --git a/tests/test_get_sha.py b/tests/test_get_sha.py index 42bebaba..f0a0d7fa 100644 --- a/tests/test_get_sha.py +++ b/tests/test_get_sha.py @@ -13,9 +13,11 @@ 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 @@ -32,7 +34,8 @@ def _load_module(path: pathlib.Path, name: str): return mod -manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod") +manage_script = _load_module( + SRC / "tools" / "manage_script.py", "manage_script_mod") class DummyMCP: @@ -72,4 +75,3 @@ def fake_send(cmd, params): assert captured["params"]["path"].endswith("Assets/Scripts") assert resp["success"] is True assert resp["data"] == {"sha256": "abc", "lengthBytes": 1} - diff --git a/tests/test_improved_anchor_matching.py b/tests/test_improved_anchor_matching.py index 5fd7c933..b3047c92 100644 --- a/tests/test_improved_anchor_matching.py +++ b/tests/test_improved_anchor_matching.py @@ -17,9 +17,11 @@ 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 @@ -28,17 +30,21 @@ class _Dummy: sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + def load_module(path, name): spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -manage_script_edits_module = load_module(SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module") + +manage_script_edits_module = load_module( + SRC / "tools" / "manage_script_edits.py", "manage_script_edits_module") + def test_improved_anchor_matching(): """Test that our improved anchor matching finds the right closing brace.""" - + test_code = '''using UnityEngine; public class TestClass : MonoBehaviour @@ -53,27 +59,29 @@ def test_improved_anchor_matching(): // Update logic } }''' - + import re - + # Test the problematic anchor pattern anchor_pattern = r"\s*}\s*$" flags = re.MULTILINE - + # Test our improved function best_match = manage_script_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) - + assert best_match is not None, "anchor pattern not found" match_pos = best_match.start() line_num = test_code[:match_pos].count('\n') + 1 total_lines = test_code.count('\n') + 1 - assert line_num >= total_lines - 2, f"expected match near end (>= {total_lines-2}), got line {line_num}" + assert line_num >= total_lines - \ + 2, f"expected match near end (>= {total_lines-2}), got line {line_num}" + def test_old_vs_new_matching(): """Compare old vs new matching behavior.""" - + test_code = '''using UnityEngine; public class TestClass : MonoBehaviour @@ -96,30 +104,34 @@ def test_old_vs_new_matching(): // More logic } }''' - + import re - + anchor_pattern = r"\s*}\s*$" flags = re.MULTILINE - + # Old behavior (first match) old_match = re.search(anchor_pattern, test_code, flags) - old_line = test_code[:old_match.start()].count('\n') + 1 if old_match else None - + old_line = test_code[:old_match.start()].count( + '\n') + 1 if old_match else None + # New behavior (improved matching) new_match = manage_script_edits_module._find_best_anchor_match( anchor_pattern, test_code, flags, prefer_last=True ) - new_line = test_code[:new_match.start()].count('\n') + 1 if new_match else None - + new_line = test_code[:new_match.start()].count( + '\n') + 1 if new_match else None + assert old_line is not None and new_line is not None, "failed to locate anchors" assert new_line > old_line, f"improved matcher should choose a later line (old={old_line}, new={new_line})" total_lines = test_code.count('\n') + 1 - assert new_line >= total_lines - 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}" + assert new_line >= total_lines - \ + 2, f"expected class-end match near end (>= {total_lines-2}), got {new_line}" + def test_apply_edits_with_improved_matching(): """Test that _apply_edits_locally uses improved matching.""" - + original_code = '''using UnityEngine; public class TestClass : MonoBehaviour @@ -131,7 +143,7 @@ def test_apply_edits_with_improved_matching(): Debug.Log(message); } }''' - + # Test anchor_insert with the problematic pattern edits = [{ "op": "anchor_insert", @@ -139,30 +151,33 @@ def test_apply_edits_with_improved_matching(): "position": "before", "text": "\n public void NewMethod() { Debug.Log(\"Added at class end\"); }\n" }] - - result = manage_script_edits_module._apply_edits_locally(original_code, edits) + + result = manage_script_edits_module._apply_edits_locally( + original_code, edits) lines = result.split('\n') try: idx = next(i for i, line in enumerate(lines) if "NewMethod" in line) except StopIteration: assert False, "NewMethod not found in result" total_lines = len(lines) - assert idx >= total_lines - 5, f"method inserted too early (idx={idx}, total_lines={total_lines})" + assert idx >= total_lines - \ + 5, f"method inserted too early (idx={idx}, total_lines={total_lines})" + if __name__ == "__main__": print("Testing improved anchor matching...") print("="*60) - + success1 = test_improved_anchor_matching() - + print("\n" + "="*60) print("Comparing old vs new behavior...") success2 = test_old_vs_new_matching() - + print("\n" + "="*60) print("Testing _apply_edits_locally with improved matching...") success3 = test_apply_edits_with_improved_matching() - + print("\n" + "="*60) if success1 and success2 and success3: print("🎉 ALL TESTS PASSED! Improved anchor matching is working!") diff --git a/tests/test_logging_stdout.py b/tests/test_logging_stdout.py index 5b40fba3..9f5f8495 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -64,5 +64,7 @@ def visit_Call(self, node: ast.Call): 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) + 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_manage_script_uri.py b/tests/test_manage_script_uri.py index 40b64584..d2515922 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -1,3 +1,4 @@ +import tools.manage_script as manage_script # type: ignore import sys import types from pathlib import Path @@ -5,7 +6,6 @@ import pytest - # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) ROOT = Path(__file__).resolve().parents[1] candidates = [ @@ -25,7 +25,12 @@ mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") -class _Dummy: pass + + +class _Dummy: + pass + + fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy server_pkg.fastmcp = fastmcp_pkg @@ -36,7 +41,6 @@ class _Dummy: pass # Import target module after path injection -import tools.manage_script as manage_script # type: ignore class DummyMCP: @@ -83,10 +87,13 @@ def fake_send(cmd, params): # capture params and return success @pytest.mark.parametrize( "uri, expected_name, expected_path", [ - ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", "Foo Bar", "Assets/Scripts"), + ("file:///Users/alex/Project/Assets/Scripts/Foo%20Bar.cs", + "Foo Bar", "Assets/Scripts"), ("file://localhost/Users/alex/Project/Assets/Hello.cs", "Hello", "Assets"), - ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", "Hello", "Assets/Scripts"), - ("file:///tmp/Other.cs", "Other", "tmp"), # outside Assets → fall back to normalized dir + ("file:///C:/Users/Alex/Proj/Assets/Scripts/Hello.cs", + "Hello", "Assets/Scripts"), + # outside Assets → fall back to normalized dir + ("file:///tmp/Other.cs", "Other", "tmp"), ], ) def test_split_uri_file_urls(monkeypatch, uri, expected_name, expected_path): @@ -118,9 +125,8 @@ def fake_send(cmd, params): monkeypatch.setattr(manage_script, "send_command_with_retry", fake_send) fn = tools['apply_text_edits'] - fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", edits=[], precondition_sha256=None) + fn(DummyCtx(), uri="Assets/Scripts/Thing.cs", + edits=[], precondition_sha256=None) assert captured['params']['name'] == 'Thing' assert captured['params']['path'] == 'Assets/Scripts' - - diff --git a/tests/test_read_console_truncate.py b/tests/test_read_console_truncate.py index b2eafd29..6392d3bc 100644 --- a/tests/test_read_console_truncate.py +++ b/tests/test_read_console_truncate.py @@ -12,9 +12,11 @@ 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 @@ -23,13 +25,17 @@ class _Dummy: sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod -read_console_mod = _load_module(SRC / "tools" / "read_console.py", "read_console_mod") + +read_console_mod = _load_module( + SRC / "tools" / "read_console.py", "read_console_mod") + class DummyMCP: def __init__(self): @@ -41,11 +47,13 @@ def deco(fn): return fn return deco + def setup_tools(): mcp = DummyMCP() read_console_mod.register_read_console_tools(mcp) return mcp.tools + def test_read_console_full_default(monkeypatch): tools = setup_tools() read_console = tools["read_console"] @@ -60,7 +68,8 @@ def fake_send(cmd, params): } monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) - monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object()) + monkeypatch.setattr( + read_console_mod, "get_unity_connection", lambda: object()) resp = read_console(ctx=None, count=10) assert resp == { @@ -85,8 +94,10 @@ def fake_send(cmd, params): } monkeypatch.setattr(read_console_mod, "send_command_with_retry", fake_send) - monkeypatch.setattr(read_console_mod, "get_unity_connection", lambda: object()) + monkeypatch.setattr( + read_console_mod, "get_unity_connection", lambda: object()) resp = read_console(ctx=None, count=10, include_stacktrace=False) - assert resp == {"success": True, "data": {"lines": [{"level": "error", "message": "oops"}]}} + assert resp == {"success": True, "data": { + "lines": [{"level": "error", "message": "oops"}]}} assert captured["params"]["includeStacktrace"] is False diff --git a/tests/test_read_resource_minimal.py b/tests/test_read_resource_minimal.py index 90d2a59b..7f68a919 100644 --- a/tests/test_read_resource_minimal.py +++ b/tests/test_read_resource_minimal.py @@ -1,3 +1,4 @@ +from tools.resource_tools import register_resource_tools # type: ignore import sys import pathlib import asyncio @@ -13,9 +14,11 @@ 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 @@ -24,8 +27,6 @@ class _Dummy: sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) -from tools.resource_tools import register_resource_tools # type: ignore - class DummyMCP: def __init__(self): @@ -57,7 +58,8 @@ def test_read_resource_minimal_metadata_only(resource_tools, tmp_path): loop = asyncio.new_event_loop() try: resp = loop.run_until_complete( - read_resource(uri="unity://path/Assets/A.txt", ctx=None, project_root=str(proj)) + read_resource(uri="unity://path/Assets/A.txt", + ctx=None, project_root=str(proj)) ) finally: loop.close() diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 29082160..209fa4fd 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,3 +1,4 @@ +from tools.resource_tools import register_resource_tools # type: ignore import pytest @@ -21,17 +22,18 @@ ) sys.path.insert(0, str(SRC)) -from tools.resource_tools import register_resource_tools # type: ignore class DummyMCP: def __init__(self): self._tools = {} + def tool(self, *args, **kwargs): # accept kwargs like description def deco(fn): self._tools[fn.__name__] = fn return fn return deco + @pytest.fixture() def resource_tools(): mcp = DummyMCP() @@ -60,7 +62,8 @@ def test_resource_list_filters_and_rejects_traversal(resource_tools, tmp_path, m # Only .cs under Assets should be listed import asyncio resp = asyncio.get_event_loop().run_until_complete( - list_resources(ctx=None, pattern="*.cs", under="Assets", limit=50, project_root=str(proj)) + list_resources(ctx=None, pattern="*.cs", under="Assets", + limit=50, project_root=str(proj)) ) assert resp["success"] is True uris = resp["data"]["uris"] @@ -75,7 +78,9 @@ def test_resource_list_rejects_outside_paths(resource_tools, tmp_path): list_resources = resource_tools["list_resources"] import asyncio resp = asyncio.get_event_loop().run_until_complete( - list_resources(ctx=None, pattern="*.cs", under="..", limit=10, project_root=str(proj)) + list_resources(ctx=None, pattern="*.cs", under="..", + limit=10, project_root=str(proj)) ) assert resp["success"] is False - assert "Assets" in resp.get("error", "") or "under project root" in resp.get("error", "") + assert "Assets" in resp.get( + "error", "") or "under project root" in resp.get("error", "") diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index c7cadd35..aa14503b 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -15,9 +15,11 @@ 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 @@ -26,14 +28,18 @@ class _Dummy: 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") + +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: @@ -46,16 +52,19 @@ def decorator(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"] @@ -66,15 +75,18 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + monkeypatch.setattr(manage_script_module, + "send_command_with_retry", fake_send) - edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} + 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"] @@ -84,12 +96,16 @@ 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) + monkeypatch.setattr(manage_script_module, + "send_command_with_retry", fake_send) - edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} + 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"]) + 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" @@ -104,10 +120,12 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + monkeypatch.setattr(manage_script_module, + "send_command_with_retry", fake_send) opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} - apply_edits(None, "unity://path/Assets/Scripts/File.cs", [{"startLine":1,"startCol":1,"endLine":1,"endCol":1,"newText":"x"}], options=opts) + apply_edits(None, "unity://path/Assets/Scripts/File.cs", + [{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts) assert captured["params"].get("options") == opts @@ -120,16 +138,20 @@ def fake_send(cmd, params): captured["params"] = params return {"success": True} - monkeypatch.setattr(manage_script_module, "send_command_with_retry", fake_send) + monkeypatch.setattr(manage_script_module, + "send_command_with_retry", fake_send) edits = [ {"startLine": 2, "startCol": 2, "endLine": 2, "endCol": 3, "newText": "A"}, - {"startLine": 3, "startCol": 2, "endLine": 3, "endCol": 2, "newText": "// tail\n"}, + {"startLine": 3, "startCol": 2, "endLine": 3, + "endCol": 2, "newText": "// tail\n"}, ] - apply_edits(None, "unity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x") + apply_edits(None, "unity://path/Assets/Scripts/File.cs", + edits, precondition_sha256="x") opts = captured["params"].get("options", {}) assert opts.get("applyMode") == "atomic" + def test_manage_asset_prefab_modify_request(monkeypatch): tools = setup_manage_asset() manage_asset = tools["manage_asset"] @@ -140,8 +162,10 @@ async def fake_async(cmd, params, loop=None): 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()) + 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( diff --git a/tests/test_telemetry_endpoint_validation.py b/tests/test_telemetry_endpoint_validation.py index 8ba0d27b..16de719c 100644 --- a/tests/test_telemetry_endpoint_validation.py +++ b/tests/test_telemetry_endpoint_validation.py @@ -1,18 +1,21 @@ import os import importlib + def test_endpoint_rejects_non_http(tmp_path, monkeypatch): # Point data dir to temp to avoid touching real files monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "file:///etc/passwd") - telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") + telemetry = importlib.import_module( + "UnityMcpBridge.UnityMcpServer~.src.telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() # Should have fallen back to default endpoint assert tc.config.endpoint == tc.config.default_endpoint + def test_config_preferred_then_env_override(tmp_path, monkeypatch): # Simulate config telemetry endpoint monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) @@ -20,27 +23,32 @@ def test_config_preferred_then_env_override(tmp_path, monkeypatch): # Patch config.telemetry_endpoint via import mocking import importlib - cfg_mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.config") + cfg_mod = importlib.import_module( + "UnityMcpBridge.UnityMcpServer~.src.config") old_endpoint = cfg_mod.config.telemetry_endpoint cfg_mod.config.telemetry_endpoint = "https://example.com/telemetry" try: - telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") + telemetry = importlib.import_module( + "UnityMcpBridge.UnityMcpServer~.src.telemetry") importlib.reload(telemetry) tc = telemetry.TelemetryCollector() assert tc.config.endpoint == "https://example.com/telemetry" # Env should override config - monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", "https://override.example/ep") + monkeypatch.setenv("UNITY_MCP_TELEMETRY_ENDPOINT", + "https://override.example/ep") importlib.reload(telemetry) tc2 = telemetry.TelemetryCollector() assert tc2.config.endpoint == "https://override.example/ep" finally: cfg_mod.config.telemetry_endpoint = old_endpoint + def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path)) - telemetry = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry") + telemetry = importlib.import_module( + "UnityMcpBridge.UnityMcpServer~.src.telemetry") importlib.reload(telemetry) tc1 = telemetry.TelemetryCollector() @@ -53,4 +61,3 @@ def test_uuid_preserved_on_malformed_milestones(tmp_path, monkeypatch): importlib.reload(telemetry) tc2 = telemetry.TelemetryCollector() assert tc2._customer_uuid == first_uuid - diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index 09e4f90f..d323d094 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -16,9 +16,11 @@ 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 @@ -72,12 +74,12 @@ def slow_send(self, rec): time.sleep(0.3) # Verify drops were logged (queue full backpressure) - dropped_logs = [m for m in caplog.messages if "Telemetry queue full; dropping" in m] + dropped_logs = [ + m for m in caplog.messages if "Telemetry queue full; dropping" in m] assert len(dropped_logs) >= 1 # Ensure only one worker thread exists and is alive assert collector._worker.is_alive() - worker_threads = [t for t in threading.enumerate() if t is collector._worker] + worker_threads = [ + t for t in threading.enumerate() if t is collector._worker] assert len(worker_threads) == 1 - - diff --git a/tests/test_telemetry_subaction.py b/tests/test_telemetry_subaction.py index c1c597e2..b74b5ea1 100644 --- a/tests/test_telemetry_subaction.py +++ b/tests/test_telemetry_subaction.py @@ -3,7 +3,8 @@ def _get_decorator_module(): # Import the telemetry_decorator module from the Unity MCP server src - mod = importlib.import_module("UnityMcpBridge.UnityMcpServer~.src.telemetry_decorator") + mod = importlib.import_module( + "UnityMcpBridge.UnityMcpServer~.src.telemetry_decorator") return mod @@ -79,5 +80,3 @@ def dummy_tool_without_action(ctx, name: str): _ = wrapped(None, name="X") assert captured["tool_name"] == "apply_text_edits" assert captured["sub_action"] is None - - diff --git a/tests/test_transport_framing.py b/tests/test_transport_framing.py index 42f93701..882f912c 100644 --- a/tests/test_transport_framing.py +++ b/tests/test_transport_framing.py @@ -1,3 +1,4 @@ +from unity_connection import UnityConnection import sys import json import struct @@ -24,8 +25,6 @@ ) 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.""" @@ -159,7 +158,10 @@ def test_unframed_data_disconnect(): def test_zero_length_payload_heartbeat(): # Server that sends handshake and a zero-length heartbeat frame followed by a pong payload - import socket, struct, threading, time + import socket + import struct + import threading + import time sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) @@ -181,8 +183,10 @@ def _run(): conn.sendall(struct.pack(">Q", len(payload)) + payload) time.sleep(0.02) finally: - try: conn.close() - except Exception: pass + try: + conn.close() + except Exception: + pass sock.close() threading.Thread(target=_run, daemon=True).start() diff --git a/tests/test_validate_script_summary.py b/tests/test_validate_script_summary.py index 86a8c057..f9638128 100644 --- a/tests/test_validate_script_summary.py +++ b/tests/test_validate_script_summary.py @@ -12,9 +12,11 @@ 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 @@ -23,13 +25,17 @@ class _Dummy: sys.modules.setdefault("mcp.server", server_pkg) sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) + def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod -manage_script = _load_module(SRC / "tools" / "manage_script.py", "manage_script_mod") + +manage_script = _load_module( + SRC / "tools" / "manage_script.py", "manage_script_mod") + class DummyMCP: def __init__(self): @@ -41,11 +47,13 @@ def deco(fn): return fn return deco + def setup_tools(): mcp = DummyMCP() manage_script.register_manage_script_tools(mcp) return mcp.tools + def test_validate_script_returns_counts(monkeypatch): tools = setup_tools() validate_script = tools["validate_script"] diff --git a/tools/stress_mcp.py b/tools/stress_mcp.py index bd14c35a..47b1675c 100644 --- a/tools/stress_mcp.py +++ b/tools/stress_mcp.py @@ -21,7 +21,8 @@ def dlog(*args): def find_status_files() -> list[Path]: home = Path.home() - status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp")) + status_dir = Path(os.environ.get( + "UNITY_MCP_STATUS_DIR", home / ".unity-mcp")) if not status_dir.exists(): return [] return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True) @@ -87,7 +88,8 @@ def make_ping_frame() -> bytes: def make_execute_menu_item(menu_path: str) -> bytes: # Retained for manual debugging; not used in normal stress runs - payload = {"type": "execute_menu_item", "params": {"action": "execute", "menu_path": menu_path}} + payload = {"type": "execute_menu_item", "params": { + "action": "execute", "menu_path": menu_path}} return json.dumps(payload).encode("utf-8") @@ -102,7 +104,8 @@ async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: d await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT) # Send a quick ping first await write_frame(writer, make_ping_frame()) - _ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) # ignore content + # ignore content + _ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) # Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task. while time.time() < stop_time: @@ -182,7 +185,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str if relative: # Derive name and directory for ManageScript and compute precondition SHA + EOF position name_base = Path(relative).stem - dir_path = str(Path(relative).parent).replace('\\', '/') + dir_path = str( + Path(relative).parent).replace('\\', '/') # 1) Read current contents via manage_script.read to compute SHA and true EOF location contents = None @@ -203,8 +207,10 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str await write_frame(writer, json.dumps(read_payload).encode("utf-8")) resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) - read_obj = json.loads(resp.decode("utf-8", errors="ignore")) - result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {} + read_obj = json.loads( + resp.decode("utf-8", errors="ignore")) + result = read_obj.get("result", read_obj) if isinstance( + read_obj, dict) else {} if result.get("success"): data_obj = result.get("data", {}) contents = data_obj.get("contents") or "" @@ -222,13 +228,15 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str pass if not read_success or contents is None: - stats["apply_errors"] = stats.get("apply_errors", 0) + 1 + stats["apply_errors"] = stats.get( + "apply_errors", 0) + 1 await asyncio.sleep(0.5) continue # Compute SHA and EOF insertion point import hashlib - sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + sha = hashlib.sha256( + contents.encode("utf-8")).hexdigest() lines = contents.splitlines(keepends=True) # Insert at true EOF (safe against header guards) end_line = len(lines) + 1 # 1-based exclusive end @@ -237,7 +245,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str # Build a unique marker append; ensure it begins with a newline if needed marker = f"// MCP_STRESS seq={seq} time={int(time.time())}" seq += 1 - insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n" + insert_text = ("\n" if not contents.endswith( + "\n") else "") + marker + "\n" # 2) Apply text edits with immediate refresh and precondition apply_payload = { @@ -269,11 +278,14 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str await write_frame(writer, json.dumps(apply_payload).encode("utf-8")) resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) try: - data = json.loads(resp.decode("utf-8", errors="ignore")) - result = data.get("result", data) if isinstance(data, dict) else {} + data = json.loads(resp.decode( + "utf-8", errors="ignore")) + result = data.get("result", data) if isinstance( + data, dict) else {} ok = bool(result.get("success", False)) if ok: - stats["applies"] = stats.get("applies", 0) + 1 + stats["applies"] = stats.get( + "applies", 0) + 1 apply_success = True break except Exception: @@ -290,7 +302,8 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str except Exception: pass if not apply_success: - stats["apply_errors"] = stats.get("apply_errors", 0) + 1 + stats["apply_errors"] = stats.get( + "apply_errors", 0) + 1 except Exception: pass @@ -298,13 +311,17 @@ async def reload_churn_task(project_path: str, stop_time: float, unity_file: str async def main(): - ap = argparse.ArgumentParser(description="Stress test the Unity MCP bridge with concurrent clients and reload churn") + ap = argparse.ArgumentParser( + description="Stress test the Unity MCP bridge with concurrent clients and reload churn") ap.add_argument("--host", default="127.0.0.1") - ap.add_argument("--project", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests")) - ap.add_argument("--unity-file", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs")) + ap.add_argument("--project", default=str( + Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests")) + ap.add_argument("--unity-file", default=str(Path(__file__).resolve( + ).parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs")) ap.add_argument("--clients", type=int, default=10) ap.add_argument("--duration", type=int, default=60) - ap.add_argument("--storm-count", type=int, default=1, help="Number of scripts to touch each cycle") + ap.add_argument("--storm-count", type=int, default=1, + help="Number of scripts to touch each cycle") args = ap.parse_args() port = discover_port(args.project) @@ -315,10 +332,12 @@ async def main(): # Spawn clients for i in range(max(1, args.clients)): - tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats))) + tasks.append(asyncio.create_task( + client_loop(i, args.host, port, stop_time, stats))) # Spawn reload churn task - tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port, stats, storm_count=args.storm_count))) + tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, + args.unity_file, args.host, port, stats, storm_count=args.storm_count))) await asyncio.gather(*tasks, return_exceptions=True) print(json.dumps({"port": port, "stats": stats}, indent=2)) @@ -329,5 +348,3 @@ async def main(): asyncio.run(main()) except KeyboardInterrupt: pass - -