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..3ce232b7 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" @@ -31,7 +31,7 @@ jobs: run: | set -euo pipefail BUMP="${{ inputs.version_bump }}" - CURRENT_VERSION=$(jq -r '.version' "UnityMcpBridge/package.json") + CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json") echo "Current version: $CURRENT_VERSION" IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION" @@ -63,15 +63,15 @@ jobs: run: | set -euo pipefail - echo "Updating UnityMcpBridge/package.json to $NEW_VERSION" - jq ".version = \"${NEW_VERSION}\"" UnityMcpBridge/package.json > UnityMcpBridge/package.json.tmp - mv UnityMcpBridge/package.json.tmp UnityMcpBridge/package.json + echo "Updating MCPForUnity/package.json to $NEW_VERSION" + jq ".version = \"${NEW_VERSION}\"" MCPForUnity/package.json > MCPForUnity/package.json.tmp + mv MCPForUnity/package.json.tmp MCPForUnity/package.json - echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" - sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" + echo "Updating MCPForUnity/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" + sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "MCPForUnity/UnityMcpServer~/src/pyproject.toml" - echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" - echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" + echo "Updating MCPForUnity/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" + echo "$NEW_VERSION" > "MCPForUnity/UnityMcpServer~/src/server_version.txt" - name: Commit and push changes env: @@ -81,7 +81,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" + git add MCPForUnity/package.json "MCPForUnity/UnityMcpServer~/src/pyproject.toml" "MCPForUnity/UnityMcpServer~/src/server_version.txt" if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.github/workflows/claude-nl-suite.yml b/.github/workflows/claude-nl-suite.yml index 539263d6..e5b88763 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 MCPForUnity/UnityMcpServer~/src/pyproject.toml ]; then + uv pip install -e MCPForUnity/UnityMcpServer~/src + elif [ -f MCPForUnity/UnityMcpServer~/src/requirements.txt ]; then + uv pip install -r MCPForUnity/UnityMcpServer~/src/requirements.txt + elif [ -f MCPForUnity/UnityMcpServer~/pyproject.toml ]; then + uv pip install -e MCPForUnity/UnityMcpServer~/ + elif [ -f MCPForUnity/UnityMcpServer~/requirements.txt ]; then + uv pip install -r MCPForUnity/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..4b795ad1 100644 --- a/.github/workflows/unity-tests.yml +++ b/.github/workflows/unity-tests.yml @@ -2,10 +2,10 @@ name: Unity Tests on: push: - branches: [ main ] + branches: [main] paths: - TestProjects/UnityMCPTests/** - - UnityMcpBridge/Editor/** + - MCPForUnity/Editor/** - .github/workflows/unity-tests.yml jobs: diff --git a/.gitignore b/.gitignore index be9fc7e3..d82423e0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ UnityMcpServer.meta # Unity Editor *.unitypackage *.asset -UnityMcpBridge.meta LICENSE.meta CONTRIBUTING.md.meta diff --git a/MCPForUnity/Editor.meta b/MCPForUnity/Editor.meta new file mode 100644 index 00000000..26495d40 --- /dev/null +++ b/MCPForUnity/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31e7fac5858840340a75cc6df0ad3d9e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/AssemblyInfo.cs b/MCPForUnity/Editor/AssemblyInfo.cs new file mode 100644 index 00000000..bae75b67 --- /dev/null +++ b/MCPForUnity/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")] diff --git a/MCPForUnity/Editor/AssemblyInfo.cs.meta b/MCPForUnity/Editor/AssemblyInfo.cs.meta new file mode 100644 index 00000000..72bf5f72 --- /dev/null +++ b/MCPForUnity/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be61633e00d934610ac1ff8192ffbe3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Data.meta b/MCPForUnity/Editor/Data.meta new file mode 100644 index 00000000..bb714ec4 --- /dev/null +++ b/MCPForUnity/Editor/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e59036660cc33d24596fbbf6d4657a83 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Data/DefaultServerConfig.cs b/MCPForUnity/Editor/Data/DefaultServerConfig.cs new file mode 100644 index 00000000..59cced75 --- /dev/null +++ b/MCPForUnity/Editor/Data/DefaultServerConfig.cs @@ -0,0 +1,17 @@ +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Data +{ + public class DefaultServerConfig : ServerConfig + { + public new string unityHost = "localhost"; + public new int unityPort = 6400; + public new int mcpPort = 6500; + public new float connectionTimeout = 15.0f; + public new int bufferSize = 32768; + public new string logLevel = "INFO"; + public new string logFormat = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"; + public new int maxRetries = 3; + public new float retryDelay = 1.0f; + } +} diff --git a/MCPForUnity/Editor/Data/DefaultServerConfig.cs.meta b/MCPForUnity/Editor/Data/DefaultServerConfig.cs.meta new file mode 100644 index 00000000..82e437f2 --- /dev/null +++ b/MCPForUnity/Editor/Data/DefaultServerConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de8f5721c34f7194392e9d8c7d0226c0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Data/McpClients.cs b/MCPForUnity/Editor/Data/McpClients.cs new file mode 100644 index 00000000..9e718847 --- /dev/null +++ b/MCPForUnity/Editor/Data/McpClients.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Data +{ + public class McpClients + { + public List clients = new() + { + // 1) Cursor + new() + { + name = "Cursor", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".cursor", + "mcp.json" + ), + mcpType = McpTypes.Cursor, + configStatus = "Not Configured", + }, + // 2) Claude Code + new() + { + name = "Claude Code", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".claude.json" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".claude.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".claude.json" + ), + mcpType = McpTypes.ClaudeCode, + configStatus = "Not Configured", + }, + // 3) Windsurf + new() + { + name = "Windsurf", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codeium", + "windsurf", + "mcp_config.json" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codeium", + "windsurf", + "mcp_config.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codeium", + "windsurf", + "mcp_config.json" + ), + mcpType = McpTypes.Windsurf, + configStatus = "Not Configured", + }, + // 4) Claude Desktop + new() + { + name = "Claude Desktop", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Claude", + "claude_desktop_config.json" + ), + + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Claude", + "claude_desktop_config.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "Claude", + "claude_desktop_config.json" + ), + + mcpType = McpTypes.ClaudeDesktop, + configStatus = "Not Configured", + }, + // 5) VSCode GitHub Copilot + new() + { + name = "VSCode GitHub Copilot", + // Windows path is canonical under %AppData%\Code\User + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Code", + "User", + "mcp.json" + ), + // macOS: ~/Library/Application Support/Code/User/mcp.json + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "mcp.json" + ), + // Linux: ~/.config/Code/User/mcp.json + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", + "Code", + "User", + "mcp.json" + ), + mcpType = McpTypes.VSCode, + configStatus = "Not Configured", + }, + // 3) Kiro + new() + { + name = "Kiro", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".kiro", + "settings", + "mcp.json" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".kiro", + "settings", + "mcp.json" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".kiro", + "settings", + "mcp.json" + ), + mcpType = McpTypes.Kiro, + configStatus = "Not Configured", + }, + // 4) Codex CLI + new() + { + name = "Codex CLI", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + mcpType = McpTypes.Codex, + configStatus = "Not Configured", + }, + }; + + // Initialize status enums after construction + public McpClients() + { + foreach (var client in clients) + { + if (client.configStatus == "Not Configured") + { + client.status = McpStatus.NotConfigured; + } + } + } + } +} diff --git a/MCPForUnity/Editor/Data/McpClients.cs.meta b/MCPForUnity/Editor/Data/McpClients.cs.meta new file mode 100644 index 00000000..e5a10813 --- /dev/null +++ b/MCPForUnity/Editor/Data/McpClients.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 711b86bbc1f661e4fb2c822e14970e16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies.meta b/MCPForUnity/Editor/Dependencies.meta new file mode 100644 index 00000000..77685d17 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 221a4d6e595be6897a5b17b77aedd4d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs new file mode 100644 index 00000000..ce6efef2 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Dependencies.PlatformDetectors; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Main orchestrator for dependency validation and management + /// + public static class DependencyManager + { + private static readonly List _detectors = new List + { + new WindowsPlatformDetector(), + new MacOSPlatformDetector(), + new LinuxPlatformDetector() + }; + + private static IPlatformDetector _currentDetector; + + /// + /// Get the platform detector for the current operating system + /// + public static IPlatformDetector GetCurrentPlatformDetector() + { + if (_currentDetector == null) + { + _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); + if (_currentDetector == null) + { + throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); + } + } + return _currentDetector; + } + + /// + /// Perform a comprehensive dependency check + /// + public static DependencyCheckResult CheckAllDependencies() + { + var result = new DependencyCheckResult(); + + try + { + var detector = GetCurrentPlatformDetector(); + McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); + + // Check Python + var pythonStatus = detector.DetectPython(); + result.Dependencies.Add(pythonStatus); + + // Check UV + var uvStatus = detector.DetectUV(); + result.Dependencies.Add(uvStatus); + + // Check MCP Server + var serverStatus = detector.DetectMCPServer(); + result.Dependencies.Add(serverStatus); + + // Generate summary and recommendations + result.GenerateSummary(); + GenerateRecommendations(result, detector); + + McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); + } + catch (Exception ex) + { + McpLog.Error($"Error during dependency check: {ex.Message}"); + result.Summary = $"Dependency check failed: {ex.Message}"; + result.IsSystemReady = false; + } + + return result; + } + + /// + /// Get installation recommendations for the current platform + /// + public static string GetInstallationRecommendations() + { + try + { + var detector = GetCurrentPlatformDetector(); + return detector.GetInstallationRecommendations(); + } + catch (Exception ex) + { + return $"Error getting installation recommendations: {ex.Message}"; + } + } + + /// + /// Get platform-specific installation URLs + /// + public static (string pythonUrl, string uvUrl) GetInstallationUrls() + { + try + { + var detector = GetCurrentPlatformDetector(); + return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); + } + catch + { + return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); + } + } + + private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) + { + var missing = result.GetMissingDependencies(); + + if (missing.Count == 0) + { + result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); + return; + } + + foreach (var dep in missing) + { + if (dep.Name == "Python") + { + result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); + } + else if (dep.Name == "UV Package Manager") + { + result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); + } + else if (dep.Name == "MCP Server") + { + result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); + } + } + + if (result.GetMissingRequired().Count > 0) + { + result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); + } + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta new file mode 100644 index 00000000..ae03260a --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6789012345678901234abcdef012345 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/Models.meta b/MCPForUnity/Editor/Dependencies/Models.meta new file mode 100644 index 00000000..2174dd52 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2c3d4e5f6789012345678901234abcd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs new file mode 100644 index 00000000..5dd2edaf --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Result of a comprehensive dependency check + /// + [Serializable] + public class DependencyCheckResult + { + /// + /// List of all dependency statuses checked + /// + public List Dependencies { get; set; } + + /// + /// Overall system readiness for MCP operations + /// + public bool IsSystemReady { get; set; } + + /// + /// Whether all required dependencies are available + /// + public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; + + /// + /// Whether any optional dependencies are missing + /// + public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; + + /// + /// Summary message about the dependency state + /// + public string Summary { get; set; } + + /// + /// Recommended next steps for the user + /// + public List RecommendedActions { get; set; } + + /// + /// Timestamp when this check was performed + /// + public DateTime CheckedAt { get; set; } + + public DependencyCheckResult() + { + Dependencies = new List(); + RecommendedActions = new List(); + CheckedAt = DateTime.UtcNow; + } + + /// + /// Get dependencies by availability status + /// + public List GetMissingDependencies() + { + return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Get missing required dependencies + /// + public List GetMissingRequired() + { + return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Generate a user-friendly summary of the dependency state + /// + public void GenerateSummary() + { + var missing = GetMissingDependencies(); + var missingRequired = GetMissingRequired(); + + if (missing.Count == 0) + { + Summary = "All dependencies are available and ready."; + IsSystemReady = true; + } + else if (missingRequired.Count == 0) + { + Summary = $"System is ready. {missing.Count} optional dependencies are missing."; + IsSystemReady = true; + } + else + { + Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; + IsSystemReady = false; + } + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta new file mode 100644 index 00000000..a88c3bb2 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 789012345678901234abcdef01234567 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs new file mode 100644 index 00000000..e755ecad --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs @@ -0,0 +1,65 @@ +using System; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Represents the status of a dependency check + /// + [Serializable] + public class DependencyStatus + { + /// + /// Name of the dependency being checked + /// + public string Name { get; set; } + + /// + /// Whether the dependency is available and functional + /// + public bool IsAvailable { get; set; } + + /// + /// Version information if available + /// + public string Version { get; set; } + + /// + /// Path to the dependency executable/installation + /// + public string Path { get; set; } + + /// + /// Additional details about the dependency status + /// + public string Details { get; set; } + + /// + /// Error message if dependency check failed + /// + public string ErrorMessage { get; set; } + + /// + /// Whether this dependency is required for basic functionality + /// + public bool IsRequired { get; set; } + + /// + /// Suggested installation method or URL + /// + public string InstallationHint { get; set; } + + public DependencyStatus(string name, bool isRequired = true) + { + Name = name; + IsRequired = isRequired; + IsAvailable = false; + } + + public override string ToString() + { + var status = IsAvailable ? "✓" : "✗"; + var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : ""; + return $"{status} {Name}{version}"; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta new file mode 100644 index 00000000..d6eb1d59 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6789012345678901234abcdef0123456 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta new file mode 100644 index 00000000..22a6b1db --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c3d4e5f6789012345678901234abcdef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs new file mode 100644 index 00000000..7fba58f9 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -0,0 +1,50 @@ +using MCPForUnity.Editor.Dependencies.Models; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Interface for platform-specific dependency detection + /// + public interface IPlatformDetector + { + /// + /// Platform name this detector handles + /// + string PlatformName { get; } + + /// + /// Whether this detector can run on the current platform + /// + bool CanDetect { get; } + + /// + /// Detect Python installation on this platform + /// + DependencyStatus DetectPython(); + + /// + /// Detect UV package manager on this platform + /// + DependencyStatus DetectUV(); + + /// + /// Detect MCP server installation on this platform + /// + DependencyStatus DetectMCPServer(); + + /// + /// Get platform-specific installation recommendations + /// + string GetInstallationRecommendations(); + + /// + /// Get platform-specific Python installation URL + /// + string GetPythonInstallUrl(); + + /// + /// Get platform-specific UV installation URL + /// + string GetUVInstallUrl(); + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta new file mode 100644 index 00000000..d2cd9f07 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9012345678901234abcdef0123456789 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs new file mode 100644 index 00000000..4ace9756 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -0,0 +1,212 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Linux-specific dependency detection + /// + public class LinuxPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Linux"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths on Linux + var candidates = new[] + { + "python3", + "python", + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/python/bin/python3", + "/snap/bin/python3" + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths including system, snap, and user-local locations."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/source/"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#linux"; + } + + public override string GetInstallationRecommendations() + { + return @"Linux Installation Recommendations: + +1. Python: Install via package manager or pyenv + - Ubuntu/Debian: sudo apt install python3 python3-pip + - Fedora/RHEL: sudo dnf install python3 python3-pip + - Arch: sudo pacman -S python python-pip + - Or use pyenv: https://github.com/pyenv/pyenv + +2. UV Package Manager: Install via curl + - Run: curl -LsSf https://astral.sh/uv/install.sh | sh + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by Unity MCP Bridge + +Note: Make sure ~/.local/bin is in your PATH for user-local installations."; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Set PATH to include common locations + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Enhance PATH for Unity's GUI environment + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + fullPath = output; + return true; + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta new file mode 100644 index 00000000..4f8267fd --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2345678901234abcdef0123456789abc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs new file mode 100644 index 00000000..c89e7cb9 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -0,0 +1,212 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// macOS-specific dependency detection + /// + public class MacOSPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "macOS"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths on macOS + var candidates = new[] + { + "python3", + "python", + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/homebrew/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/macos/"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#macos"; + } + + public override string GetInstallationRecommendations() + { + return @"macOS Installation Recommendations: + +1. Python: Install via Homebrew (recommended) or python.org + - Homebrew: brew install python3 + - Direct download: https://python.org/downloads/macos/ + +2. UV Package Manager: Install via curl or Homebrew + - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh + - Homebrew: brew install uv + +3. MCP Server: Will be installed automatically by Unity MCP Bridge + +Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Set PATH to include common locations + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Enhance PATH for Unity's GUI environment + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + fullPath = output; + return true; + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta new file mode 100644 index 00000000..b43864a2 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12345678901234abcdef0123456789ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs new file mode 100644 index 00000000..98044f17 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -0,0 +1,161 @@ +using System; +using System.Diagnostics; +using System.IO; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Base class for platform-specific dependency detection + /// + public abstract class PlatformDetectorBase : IPlatformDetector + { + public abstract string PlatformName { get; } + public abstract bool CanDetect { get; } + + public abstract DependencyStatus DetectPython(); + public abstract string GetPythonInstallUrl(); + public abstract string GetUVInstallUrl(); + public abstract string GetInstallationRecommendations(); + + public virtual DependencyStatus DetectUV() + { + var status = new DependencyStatus("UV Package Manager", isRequired: true) + { + InstallationHint = GetUVInstallUrl() + }; + + try + { + // Use existing UV detection from ServerInstaller + string uvPath = ServerInstaller.FindUvPath(); + if (!string.IsNullOrEmpty(uvPath)) + { + if (TryValidateUV(uvPath, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = uvPath; + status.Details = $"Found UV {version} at {uvPath}"; + return status; + } + } + + status.ErrorMessage = "UV package manager not found. Please install UV."; + status.Details = "UV is required for managing Python dependencies."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting UV: {ex.Message}"; + } + + return status; + } + + public virtual DependencyStatus DetectMCPServer() + { + var status = new DependencyStatus("MCP Server", isRequired: false); + + try + { + // Check if server is installed + string serverPath = ServerInstaller.GetServerPath(); + string serverPy = Path.Combine(serverPath, "server.py"); + + if (File.Exists(serverPy)) + { + status.IsAvailable = true; + status.Path = serverPath; + + // Try to get version + string versionFile = Path.Combine(serverPath, "server_version.txt"); + if (File.Exists(versionFile)) + { + status.Version = File.ReadAllText(versionFile).Trim(); + } + + status.Details = $"MCP Server found at {serverPath}"; + } + else + { + // Check for embedded server + if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) + { + status.IsAvailable = true; + status.Path = embeddedPath; + status.Details = "MCP Server available (embedded in package)"; + } + else + { + status.ErrorMessage = "MCP Server not found"; + status.Details = "Server will be installed automatically when needed"; + } + } + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; + } + + return status; + } + + protected bool TryValidateUV(string uvPath, out string version) + { + version = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3); // Remove "uv " prefix + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected bool TryParseVersion(string version, out int major, out int minor) + { + major = 0; + minor = 0; + + try + { + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + } + } + catch + { + // Ignore parsing errors + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta new file mode 100644 index 00000000..4821e757 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44d715aedea2b8b41bf914433bbb2c49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs new file mode 100644 index 00000000..bd9c6f03 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -0,0 +1,191 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Windows-specific dependency detection + /// + public class WindowsPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Windows"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths + var candidates = new[] + { + "python.exe", + "python3.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python313", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python312", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python311", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Python313", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Python312", "python.exe") + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'where' command + if (TryFindInPath("python.exe", out string pathResult) || + TryFindInPath("python3.exe", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths and PATH environment variable."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#windows"; + } + + public override string GetInstallationRecommendations() + { + return @"Windows Installation Recommendations: + +1. Python: Install from Microsoft Store or python.org + - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' + - Direct download: https://python.org/downloads/windows/ + +2. UV Package Manager: Install via PowerShell + - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by Unity MCP Bridge"; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + // Take the first result + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > 0) + { + fullPath = lines[0].Trim(); + return File.Exists(fullPath); + } + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta new file mode 100644 index 00000000..e7e53d7d --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 012345678901234abcdef0123456789a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/External.meta b/MCPForUnity/Editor/External.meta new file mode 100644 index 00000000..ce757b15 --- /dev/null +++ b/MCPForUnity/Editor/External.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c11944bcfb9ec4576bab52874b7df584 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/External/Tommy.cs b/MCPForUnity/Editor/External/Tommy.cs new file mode 100644 index 00000000..22e83b81 --- /dev/null +++ b/MCPForUnity/Editor/External/Tommy.cs @@ -0,0 +1,2138 @@ +#region LICENSE + +/* + * MIT License + * + * Copyright (c) 2020 Denis Zhidkikh + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace MCPForUnity.External.Tommy +{ + #region TOML Nodes + + public abstract class TomlNode : IEnumerable + { + public virtual bool HasValue { get; } = false; + public virtual bool IsArray { get; } = false; + public virtual bool IsTable { get; } = false; + public virtual bool IsString { get; } = false; + public virtual bool IsInteger { get; } = false; + public virtual bool IsFloat { get; } = false; + public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; + public virtual bool IsDateTimeLocal { get; } = false; + public virtual bool IsDateTimeOffset { get; } = false; + public virtual bool IsBoolean { get; } = false; + public virtual string Comment { get; set; } + public virtual int CollapseLevel { get; set; } + + public virtual TomlTable AsTable => this as TomlTable; + public virtual TomlString AsString => this as TomlString; + public virtual TomlInteger AsInteger => this as TomlInteger; + public virtual TomlFloat AsFloat => this as TomlFloat; + public virtual TomlBoolean AsBoolean => this as TomlBoolean; + public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; + public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; + public virtual TomlDateTime AsDateTime => this as TomlDateTime; + public virtual TomlArray AsArray => this as TomlArray; + + public virtual int ChildrenCount => 0; + + public virtual TomlNode this[string key] + { + get => null; + set { } + } + + public virtual TomlNode this[int index] + { + get => null; + set { } + } + + public virtual IEnumerable Children + { + get { yield break; } + } + + public virtual IEnumerable Keys + { + get { yield break; } + } + + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + + public virtual bool TryGetNode(string key, out TomlNode node) + { + node = null; + return false; + } + + public virtual bool HasKey(string key) => false; + + public virtual bool HasItemAt(int index) => false; + + public virtual void Add(string key, TomlNode node) { } + + public virtual void Add(TomlNode node) { } + + public virtual void Delete(TomlNode node) { } + + public virtual void Delete(string key) { } + + public virtual void Delete(int index) { } + + public virtual void AddRange(IEnumerable nodes) + { + foreach (var tomlNode in nodes) Add(tomlNode); + } + + public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); + + public virtual string ToInlineToml() => ToString(); + + #region Native type to TOML cast + + 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(long value) => new TomlInteger { 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(DateTime value) => new TomlDateTimeLocal { Value = value }; + + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset { Value = value }; + + public static implicit operator TomlNode(TomlNode[] nodes) + { + var result = new TomlArray(); + result.AddRange(nodes); + return result; + } + + #endregion + + #region TOML to native type cast + + public static implicit operator string(TomlNode value) => value.ToString(); + + 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 double(TomlNode value) => value.AsFloat.Value; + + public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; + + public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; + + public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; + + #endregion + } + + public class TomlString : TomlNode + { + public override bool HasValue { get; } = true; + public override bool IsString { get; } = true; + public bool IsMultiline { get; set; } + public bool MultilineTrimFirstLine { get; set; } + public bool PreferLiteral { get; set; } + + public string Value { get; set; } + + public override string ToString() => Value; + + public override string ToInlineToml() + { + // Automatically convert literal to non-literal if there are too many literal string symbols + if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; + var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, + IsMultiline ? 3 : 1); + var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); + if (IsMultiline) + result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); + if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) + result = $"{Environment.NewLine}{result}"; + return $"{quotes}{result}{quotes}"; + } + } + + public class TomlInteger : TomlNode + { + public enum Base + { + Binary = 2, + Octal = 8, + Decimal = 10, + Hexadecimal = 16 + } + + public override bool IsInteger { get; } = true; + public override bool HasValue { get; } = true; + public Base IntegerBase { get; set; } = Base.Decimal; + + public long Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => + IntegerBase != Base.Decimal + ? $"0{TomlSyntax.BaseIdentifiers[(int)IntegerBase]}{Convert.ToString(Value, (int)IntegerBase)}" + : Value.ToString(CultureInfo.InvariantCulture); + } + + public class TomlFloat : TomlNode, IFormattable + { + public override bool IsFloat { get; } = true; + public override bool HasValue { get; } = true; + + public double Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); + + public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToInlineToml() => + Value switch + { + 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() + }; + } + + public class TomlBoolean : TomlNode + { + public override bool IsBoolean { get; } = true; + public override bool HasValue { get; } = true; + + public bool Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; + } + + public class TomlDateTime : TomlNode, IFormattable + { + public int SecondsPrecision { get; set; } + public override bool HasValue { get; } = true; + public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; + public virtual string ToString(IFormatProvider formatProvider) => string.Empty; + protected virtual string ToInlineTomlInternal() => string.Empty; + + public override string ToInlineToml() => ToInlineTomlInternal() + .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) + .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); + } + + public class TomlDateTimeOffset : TomlDateTime + { + public override bool IsDateTimeOffset { get; } = true; + public DateTimeOffset Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); + } + + public class TomlDateTimeLocal : TomlDateTime + { + public enum DateTimeStyle + { + Date, + Time, + DateTime + } + + public override bool IsDateTimeLocal { get; } = true; + public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; + public DateTime Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + public override string ToInlineToml() => + Style switch + { + DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), + DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + }; + } + + public class TomlArray : TomlNode + { + private List values; + + public override bool HasValue { get; } = true; + public override bool IsArray { get; } = true; + public bool IsMultiline { get; set; } + public bool IsTableArray { get; set; } + public List RawArray => values ??= new List(); + + public override TomlNode this[int index] + { + get + { + if (index < RawArray.Count) return RawArray[index]; + var lazy = new TomlLazy(this); + this[index] = lazy; + return lazy; + } + set + { + if (index == RawArray.Count) + RawArray.Add(value); + else + RawArray[index] = value; + } + } + + public override int ChildrenCount => RawArray.Count; + + public override IEnumerable Children => RawArray.AsEnumerable(); + + public override void Add(TomlNode node) => RawArray.Add(node); + + public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes); + + public override void Delete(TomlNode node) => RawArray.Remove(node); + + public override void Delete(int index) => RawArray.RemoveAt(index); + + public override string ToString() => ToString(false); + + public string ToString(bool multiline) + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.ARRAY_START_SYMBOL); + if (ChildrenCount != 0) + { + var arrayStart = multiline ? $"{Environment.NewLine} " : " "; + var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; + var arrayEnd = multiline ? Environment.NewLine : " "; + sb.Append(arrayStart) + .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) + .Append(arrayEnd); + } + sb.Append(TomlSyntax.ARRAY_END_SYMBOL); + return sb.ToString(); + } + + public override void WriteTo(TextWriter tw, string name = null) + { + // If it's a normal array, write it as usual + if (!IsTableArray) + { + tw.WriteLine(ToString(IsMultiline)); + return; + } + + if (!(Comment is null)) + { + tw.WriteLine(); + Comment.AsComment(tw); + } + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + + var first = true; + + foreach (var tomlNode in RawArray) + { + if (!(tomlNode is TomlTable tbl)) + throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); + + // Ensure it's parsed as a section + tbl.IsInline = false; + + if (!first) + { + tw.WriteLine(); + + Comment?.AsComment(tw); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + + first = false; + + // Don't write section since it's already written here + tbl.WriteTo(tw, name, false); + } + } + } + + 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 + { + if (RawTable.TryGetValue(key, out var result)) return result; + var lazy = new TomlLazy(this); + RawTable[key] = lazy; + return lazy; + } + set => RawTable[key] = value; + } + + public override int ChildrenCount => RawTable.Count; + public override IEnumerable Children => RawTable.Select(kv => kv.Value); + public override IEnumerable Keys => RawTable.Select(kv => kv.Key); + public override bool HasKey(string key) => RawTable.ContainsKey(key); + public override void Add(string key, TomlNode node) => RawTable.Add(key, node); + public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); + public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); + public override void Delete(string key) => RawTable.Remove(key); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); + + if (ChildrenCount != 0) + { + var collapsed = CollectCollapsedItems(normalizeOrder: false); + + if (collapsed.Count != 0) + sb.Append(' ') + .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => + $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); + sb.Append(' '); + } + + sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); + return sb.ToString(); + } + + private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) + { + var nodes = new LinkedList>(); + var postNodes = normalizeOrder ? new LinkedList>() : nodes; + + foreach (var keyValuePair in RawTable) + { + var node = keyValuePair.Value; + var key = keyValuePair.Key.AsKey(); + + if (node is TomlTable tbl) + { + var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); + // Write main table first before writing collapsed items + if (subnodes.Count == 0 && node.CollapseLevel == level) + { + postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + foreach (var kv in subnodes) + postNodes.AddLast(kv); + } + else if (node.CollapseLevel == level) + nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + + if (normalizeOrder) + foreach (var kv in postNodes) + nodes.AddLast(kv); + + return nodes; + } + + public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); + + internal void WriteTo(TextWriter tw, string name, bool writeSectionName) + { + // The table is inline table + if (IsInline && name != null) + { + tw.WriteLine(ToInlineToml()); + return; + } + + var collapsedItems = CollectCollapsedItems(); + + if (collapsedItems.Count == 0) + return; + + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable { IsInline: false } or TomlArray { IsTableArray: true }); + + Comment?.AsComment(tw); + + if (name != null && (hasRealValues || Comment != null) && writeSectionName) + { + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + else if (Comment != null) // Add some spacing between the first node and the comment + { + tw.WriteLine(); + } + + var namePrefix = name == null ? "" : $"{name}."; + var first = true; + + foreach (var collapsedItem in collapsedItems) + { + var key = collapsedItem.Key; + if (collapsedItem.Value is TomlArray { IsTableArray: true } or TomlTable { IsInline: false }) + { + if (!first) tw.WriteLine(); + first = false; + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + 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}"); + } + } + } + + internal class TomlLazy : TomlNode + { + private readonly TomlNode parent; + private TomlNode replacement; + + public TomlLazy(TomlNode parent) => this.parent = parent; + + public override TomlNode this[int index] + { + get => Set()[index]; + set => Set()[index] = value; + } + + public override TomlNode this[string key] + { + get => Set()[key]; + set => Set()[key] = value; + } + + public override void Add(TomlNode node) => Set().Add(node); + + public override void Add(string key, TomlNode node) => Set().Add(key, node); + + public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes); + + private TomlNode Set() where T : TomlNode, new() + { + if (replacement != null) return replacement; + + var newNode = new T + { + Comment = Comment + }; + + if (parent.IsTable) + { + var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); + if (key == null) return default(T); + + parent[key] = newNode; + } + else if (parent.IsArray) + { + var index = parent.Children.TakeWhile(child => child != this).Count(); + if (index == parent.ChildrenCount) return default(T); + parent[index] = newNode; + } + else + { + return default(T); + } + + replacement = newNode; + return newNode; + } + } + + #endregion + + #region Parser + + public class TOMLParser : IDisposable + { + public enum ParseState + { + None, + KeyValuePair, + SkipToNextLine, + Table + } + + private readonly TextReader reader; + private ParseState currentState; + private int line, col; + private List syntaxErrors; + + public TOMLParser(TextReader reader) + { + this.reader = reader; + line = col = 0; + } + + public bool ForceASCII { get; set; } + + public void Dispose() => reader?.Dispose(); + + public TomlTable Parse() + { + syntaxErrors = new List(); + line = col = 1; + var rootNode = new TomlTable(); + var currentNode = rootNode; + currentState = ParseState.None; + var keyParts = new List(); + var arrayTable = false; + StringBuilder latestComment = null; + var firstComment = true; + + int currentChar; + while ((currentChar = reader.Peek()) >= 0) + { + var c = (char)currentChar; + + if (currentState == ParseState.None) + { + // Skip white space + if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; + + if (TomlSyntax.IsNewLine(c)) + { + // Check if there are any comments and so far no items being declared + if (latestComment != null && firstComment) + { + rootNode.Comment = latestComment.ToString().TrimEnd(); + latestComment = null; + firstComment = false; + } + + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + + goto consume_character; + } + + // Start of a comment; ignore until newline + if (c == TomlSyntax.COMMENT_SYMBOL) + { + latestComment ??= new StringBuilder(); + latestComment.AppendLine(ParseComment()); + AdvanceLine(1); + continue; + } + + // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! + firstComment = false; + + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + currentState = ParseState.Table; + goto consume_character; + } + + if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) + { + currentState = ParseState.KeyValuePair; + } + else + { + AddError($"Unexpected character \"{c}\""); + continue; + } + } + + if (currentState == ParseState.KeyValuePair) + { + var keyValuePair = ReadKeyValuePair(keyParts); + + if (keyValuePair == null) + { + latestComment = null; + keyParts.Clear(); + + if (currentState != ParseState.None) + AddError("Failed to parse key-value pair!"); + continue; + } + + keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); + var inserted = InsertNode(keyValuePair, currentNode, keyParts); + latestComment = null; + keyParts.Clear(); + if (inserted) + currentState = ParseState.SkipToNextLine; + continue; + } + + if (currentState == ParseState.Table) + { + if (keyParts.Count == 0) + { + // We have array table + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + // Consume the character + ConsumeChar(); + arrayTable = true; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) + { + keyParts.Clear(); + continue; + } + + if (keyParts.Count == 0) + { + AddError("Table name is emtpy."); + arrayTable = false; + latestComment = null; + keyParts.Clear(); + } + + continue; + } + + if (c == TomlSyntax.TABLE_END_SYMBOL) + { + if (arrayTable) + { + // 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) + { + AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + continue; + } + } + + currentNode = CreateTable(rootNode, keyParts, arrayTable); + if (currentNode != null) + { + currentNode.IsInline = false; + currentNode.Comment = latestComment?.ToString()?.TrimEnd(); + } + + keyParts.Clear(); + arrayTable = false; + latestComment = null; + + if (currentNode == null) + { + if (currentState != ParseState.None) + AddError("Error creating table array!"); + // Reset a node to root in order to try and continue parsing + currentNode = rootNode; + continue; + } + + currentState = ParseState.SkipToNextLine; + goto consume_character; + } + + if (keyParts.Count != 0) + { + AddError($"Unexpected character \"{c}\""); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + } + } + + if (currentState == ParseState.SkipToNextLine) + { + if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) + goto consume_character; + + if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) + { + currentState = ParseState.None; + AdvanceLine(); + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + col++; + ParseComment(); + continue; + } + + goto consume_character; + } + + AddError($"Unexpected character \"{c}\" at the end of the line."); + } + + consume_character: + reader.Read(); + col++; + } + + if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) + AddError("Unexpected end of file!"); + + if (syntaxErrors.Count > 0) + throw new TomlParseException(rootNode, syntaxErrors); + + return rootNode; + } + + private bool AddError(string message, bool skipLine = true) + { + syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); + // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) + if (skipLine) + { + reader.ReadLine(); + AdvanceLine(1); + } + currentState = ParseState.None; + return false; + } + + private void AdvanceLine(int startCol = 0) + { + line++; + col = startCol; + } + + private int ConsumeChar() + { + col++; + return reader.Read(); + } + + #region Key-Value pair parsing + + /** + * Reads a single key-value pair. + * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). + * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). + * + * Example: + * foo = "bar" ==> foo = "bar" + * ^ ^ + */ + private TomlNode ReadKeyValuePair(List keyParts) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) + { + if (keyParts.Count != 0) + { + AddError("Encountered extra characters in key definition!"); + return null; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) + return null; + + continue; + } + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.KEY_VALUE_SEPARATOR) + { + ConsumeChar(); + return ReadValue(); + } + + AddError($"Unexpected character \"{c}\" in key name."); + return null; + } + + return null; + } + + /** + * Reads a single value. + * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). + * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private TomlNode ReadValue(bool skipNewlines = false) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("No value found!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + if (skipNewlines) + { + reader.Read(); + AdvanceLine(1); + continue; + } + + AddError("Encountered a newline when expecting a value!"); + return null; + } + + if (TomlSyntax.IsQuoted(c)) + { + var isMultiline = IsTripleQuote(c, out var excess); + + // Error occurred in triple quote parsing + if (currentState == ParseState.None) + return null; + + var value = isMultiline + ? ReadQuotedValueMultiLine(c) + : ReadQuotedValueSingleLine(c, excess); + + if (value is null) + return null; + + return new TomlString + { + Value = value, + IsMultiline = isMultiline, + PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL + }; + } + + return c switch + { + TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() + }; + } + + return null; + } + + /** + * Reads a single key name. + * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). + * Consumes all the characters until the `until` character is met (but does not consume the character itself). + * + * Example 1: + * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) + * ^ ^ + * + * Example 2: + * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) + * ^ ^ + */ + private bool ReadKeyName(ref List parts, char until) + { + var buffer = new StringBuilder(); + var quoted = false; + var prevWasSpace = false; + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + // Reached the final character + if (c == until) break; + + if (TomlSyntax.IsWhiteSpace(c)) + { + prevWasSpace = true; + goto consume_character; + } + + if (buffer.Length == 0) prevWasSpace = false; + + if (c == TomlSyntax.SUBKEY_SEPARATOR) + { + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + buffer.Length = 0; + quoted = false; + prevWasSpace = false; + goto consume_character; + } + + if (prevWasSpace) + return AddError("Invalid spacing in key name"); + + if (TomlSyntax.IsQuoted(c)) + { + if (quoted) + + return AddError("Expected a subkey separator but got extra data instead!"); + + if (buffer.Length != 0) + return AddError("Encountered a quote in the middle of subkey name!"); + + // Consume the quote character and read the key name + col++; + buffer.Append(ReadQuotedValueSingleLine((char)reader.Read())); + quoted = true; + continue; + } + + if (TomlSyntax.IsBareKey(c)) + { + buffer.Append(c); + goto consume_character; + } + + // If we see an invalid symbol, let the next parser handle it + break; + + consume_character: + reader.Read(); + col++; + } + + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + + return true; + } + + #endregion + + #region Non-string value parsing + + /** + * Reads the whole raw value until the first non-value character is encountered. + * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. + * Example: + * + * 1_0_0_0 ==> 1_0_0_0 + * ^ ^ + */ + private string ReadRawValue() + { + var result = new StringBuilder(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; + result.Append(c); + ConsumeChar(); + } + + // Replace trim with manual space counting? + return result.ToString().Trim(); + } + + /** + * Reads and parses a non-string, non-composite TOML value. + * Assumes the cursor at the first character that is related to the value (with possible spaces). + * Consumes all the characters that are related to the value. + * + * Example + * 1_0_0_0 # This is a comment + * + * ==> 1_0_0_0 # This is a comment + * ^ ^ + */ + private TomlNode ReadTomlValue() + { + var value = ReadRawValue(); + 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.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), + CultureInfo.InvariantCulture), + 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 + }, + var _ => null + }; + if (node != null) return node; + + // Normalize by removing space separator + value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalDateTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out var dateTimeResult, + out var precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + SecondsPrecision = precision + }; + + if (DateTime.TryParseExact(value, + TomlSyntax.LocalDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dateTimeResult)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Date + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out dateTimeResult, + out precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Time, + SecondsPrecision = precision + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339Formats, + DateTimeStyles.None, + DateTimeOffset.TryParseExact, + out var dateTimeOffsetResult, + out precision)) + return new TomlDateTimeOffset + { + Value = dateTimeOffsetResult, + SecondsPrecision = precision + }; + + AddError($"Value \"{value}\" is not a valid TOML value!"); + return null; + } + + /** + * Reads an array value. + * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. + * + * Example: + * [1, 2, 3] ==> [1, 2, 3] + * ^ ^ + */ + private TomlArray ReadArray() + { + // Consume the start of array character + ConsumeChar(); + var result = new TomlArray(); + TomlNode currentValue = null; + var expectValue = true; + + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + + if (c == TomlSyntax.ARRAY_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + reader.ReadLine(); + AdvanceLine(1); + continue; + } + + if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + goto consume_character; + } + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators"); + return null; + } + + result.Add(currentValue); + currentValue = null; + expectValue = true; + goto consume_character; + } + + if (!expectValue) + { + AddError("Missing separator between values"); + return null; + } + currentValue = ReadValue(true); + if (currentValue == null) + { + if (currentState != ParseState.None) + AddError("Failed to determine and parse a value!"); + return null; + } + expectValue = false; + + continue; + consume_character: + ConsumeChar(); + } + + if (currentValue != null) result.Add(currentValue); + return result; + } + + /** + * Reads an inline table. + * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. + * + * Example: + * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } + * ^ ^ + */ + private TomlNode ReadInlineTable() + { + ConsumeChar(); + 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; + + if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("Incomplete inline table definition!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + AddError("Inline tables are only allowed to be on single line"); + return null; + } + + if (TomlSyntax.IsWhiteSpace(c)) + goto consume_character; + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators in inline table!"); + return null; + } + + if (!InsertNode(currentValue, result, keyParts)) + return null; + keyParts.Clear(); + currentValue = null; + separator = true; + goto consume_character; + } + + separator = false; + currentValue = ReadKeyValuePair(keyParts); + continue; + + consume_character: + ConsumeChar(); + } + + if (separator) + { + AddError("Trailing commas are not allowed in inline tables."); + return null; + } + + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) + return null; + + return result; + } + + #endregion + + #region String parsing + + /** + * Checks if the string value a multiline string (i.e. a triple quoted string). + * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. + * + * If the result is false, returns the consumed character through the `excess` variable. + * + * Example 1: + * """test""" ==> """test""" + * ^ ^ + * + * Example 2: + * "test" ==> "test" (doesn't return the first quote) + * ^ ^ + * + * Example 3: + * "" ==> "" (returns the extra `"` through the `excess` variable) + * ^ ^ + */ + private bool IsTripleQuote(char quote, out char excess) + { + // Copypasta, but it's faster... + + int cur; + // Consume the first quote + ConsumeChar(); + if ((cur = reader.Peek()) < 0) + { + excess = '\0'; + return AddError("Unexpected end of file!"); + } + + 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; + + // Consume the final quote + ConsumeChar(); + excess = '\0'; + return true; + } + + /** + * A convenience method to process a single character within a quote. + */ + private bool ProcessQuotedValueCharacter(char quote, + bool isNonLiteral, + char c, + StringBuilder sb, + ref bool escaped) + { + if (TomlSyntax.MustBeEscaped(c)) + return AddError($"The character U+{(int)c:X8} must be escaped in a string!"); + + if (escaped) + { + sb.Append(c); + escaped = false; + return false; + } + + if (c == quote) + { + if (!isNonLiteral && reader.Peek() == quote) + { + reader.Read(); + col++; + sb.Append(quote); + return false; + } + + return true; + } + if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) + escaped = true; + if (c == TomlSyntax.NEWLINE_CHARACTER) + return AddError("Encountered newline in single line string!"); + + sb.Append(c); + return false; + } + + /** + * Reads a single-line string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string (including the closing quote). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') + { + var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + + if (initialData != '\0') + { + var shouldReturn = + ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); + if (currentState == ParseState.None) return null; + if (shouldReturn) + if (isNonLiteral) + { + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + else + return sb.ToString(); + } + + int cur; + var readDone = false; + while ((cur = reader.Read()) >= 0) + { + // Consume the character + col++; + var c = (char)cur; + readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); + if (readDone) + { + if (currentState == ParseState.None) return null; + break; + } + } + + if (!readDone) + { + AddError("Unclosed string."); + return null; + } + + if (!isNonLiteral) return sb.ToString(); + if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; + AddError(unescapedEx.Message); + return null; + } + + /** + * Reads a multiline string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string and the three closing quotes. + * + * Example: + * """test""" ==> """test""" + * ^ ^ + */ + private string ReadQuotedValueMultiLine(char quote) + { + var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + var skipWhitespace = false; + var skipWhitespaceLineSkipped = false; + var quotesEncountered = 0; + var first = true; + int cur; + while ((cur = ConsumeChar()) >= 0) + { + var c = (char)cur; + if (TomlSyntax.MustBeEscaped(c, true)) + { + AddError($"The character U+{(int)c:X8} must be escaped!"); + return null; + } + // Trim the first newline + if (first && TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + first = false; + else + AdvanceLine(); + continue; + } + + first = false; + //TODO: Reuse ProcessQuotedValueCharacter + // Skip the current character if it is going to be escaped later + if (escaped) + { + sb.Append(c); + escaped = false; + continue; + } + + // If we are currently skipping empty spaces, skip + if (skipWhitespace) + { + if (TomlSyntax.IsEmptySpace(c)) + { + if (TomlSyntax.IsLineBreak(c)) + { + skipWhitespaceLineSkipped = true; + AdvanceLine(); + } + continue; + } + + if (!skipWhitespaceLineSkipped) + { + AddError("Non-whitespace character after trim marker."); + return null; + } + + skipWhitespaceLineSkipped = false; + skipWhitespace = false; + } + + // If we encounter an escape sequence... + if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) + { + var next = reader.Peek(); + var nc = (char)next; + if (next >= 0) + { + // ...and the next char is empty space, we must skip all whitespaces + if (TomlSyntax.IsEmptySpace(nc)) + { + skipWhitespace = true; + continue; + } + + // ...and we have \" or \, skip the character + if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; + } + } + + // Count the consecutive quotes + if (c == quote) + quotesEncountered++; + else + quotesEncountered = 0; + + // If the are three quotes, count them as closing quotes + if (quotesEncountered == 3) break; + + sb.Append(c); + } + + // TOML actually allows to have five ending quotes like + // """"" => "" belong to the string + """ is the actual ending + quotesEncountered = 0; + while ((cur = reader.Peek()) >= 0) + { + var c = (char)cur; + if (c == quote && ++quotesEncountered < 3) + { + sb.Append(c); + ConsumeChar(); + } + else break; + } + + // Remove last two quotes (third one wasn't included by default) + sb.Length -= 2; + if (!isBasic) return sb.ToString(); + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + + #endregion + + #region Node creation + + private bool InsertNode(TomlNode node, TomlNode root, IList path) + { + var latestNode = root; + if (path.Count > 1) + for (var index = 0; index < path.Count - 1; index++) + { + var subkey = path[index]; + if (latestNode.TryGetNode(subkey, out var currentNode)) + { + if (currentNode.HasValue) + return AddError($"The key {".".Join(path)} already has a value assigned to it!"); + } + else + { + currentNode = new TomlTable(); + latestNode[subkey] = currentNode; + } + + latestNode = currentNode; + if (latestNode is TomlTable { IsInline: true }) + return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); + } + + if (latestNode.HasKey(path[path.Count - 1])) + return AddError($"The key {".".Join(path)} is already defined!"); + latestNode[path[path.Count - 1]] = node; + node.CollapseLevel = path.Count - 1; + return true; + } + + private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable) + { + if (path.Count == 0) return null; + var latestNode = root; + for (var index = 0; index < path.Count; index++) + { + var subkey = path[index]; + + if (latestNode.TryGetNode(subkey, out var node)) + { + if (node.IsArray && arrayTable) + { + var arr = (TomlArray)node; + + if (!arr.IsTableArray) + { + AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (index == path.Count - 1) + { + latestNode = new TomlTable(); + arr.Add(latestNode); + break; + } + + 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."); + return null; + } + + if (node.HasValue) + { + if (!(node is TomlArray { IsTableArray: true } array)) + { + AddError($"The key {".".Join(path)} has a value assigned to it!"); + return null; + } + + latestNode = array[array.ChildrenCount - 1]; + continue; + } + + if (index == path.Count - 1) + { + if (arrayTable && !node.IsArray) + { + AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (node is TomlTable { isImplicit: false }) + { + AddError($"The table {".".Join(path)} is defined multiple times!"); + return null; + } + } + } + else + { + if (index == path.Count - 1 && arrayTable) + { + var table = new TomlTable(); + var arr = new TomlArray + { + IsTableArray = true + }; + arr.Add(table); + latestNode[subkey] = arr; + latestNode = table; + break; + } + + node = new TomlTable { isImplicit = true }; + latestNode[subkey] = node; + } + + latestNode = node; + } + + var result = (TomlTable)latestNode; + result.isImplicit = false; + return result; + } + + #endregion + + #region Misc parsing + + private string ParseComment() + { + ConsumeChar(); + var commentLine = reader.ReadLine()?.Trim() ?? ""; + if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) + AddError("Comment must not contain control characters other than tab.", false); + return commentLine; + } + #endregion + } + + #endregion + + public static class TOML + { + public static bool ForceASCII { get; set; } = false; + + public static TomlTable Parse(TextReader reader) + { + using var parser = new TOMLParser(reader) { ForceASCII = ForceASCII }; + return parser.Parse(); + } + } + + #region Exception Types + + public class TomlFormatException : Exception + { + public TomlFormatException(string message) : base(message) { } + } + + public class TomlParseException : Exception + { + public TomlParseException(TomlTable parsed, IEnumerable exceptions) : + base("TOML file contains format errors") + { + ParsedTable = parsed; + SyntaxErrors = exceptions; + } + + public TomlTable ParsedTable { get; } + + public IEnumerable SyntaxErrors { get; } + } + + public class TomlSyntaxException : Exception + { + public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) + { + ParseState = state; + Line = line; + Column = col; + } + + public TOMLParser.ParseState ParseState { get; } + + public int Line { get; } + + public int Column { get; } + } + + #endregion + + #region Parse utilities + + internal static class TomlSyntax + { + #region Type Patterns + + public const string TRUE_VALUE = "true"; + public const string FALSE_VALUE = "false"; + public const string NAN_VALUE = "nan"; + public const string POS_NAN_VALUE = "+nan"; + public const string NEG_NAN_VALUE = "-nan"; + public const string INF_VALUE = "inf"; + public const string POS_INF_VALUE = "+inf"; + public const string NEG_INF_VALUE = "-inf"; + + public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; + + public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; + + public static bool IsNegInf(string s) => s == NEG_INF_VALUE; + + public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; + + public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); + + public static bool IsFloat(string s) => FloatPattern.IsMatch(s); + + public static bool IsIntegerWithBase(string s, out int numberBase) + { + numberBase = 10; + var match = BasedIntegerPattern.Match(s); + if (!match.Success) return false; + IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); + return true; + } + + /** + * A pattern to verify the integer value according to the TOML specification. + */ + public static readonly Regex IntegerPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); + + /** + * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. + */ + public static readonly Regex BasedIntegerPattern = + new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A pattern to verify the float value according to the TOML specification. + */ + public static readonly Regex FloatPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A helper dictionary to map TOML base codes into the radii. + */ + public static readonly Dictionary IntegerBases = new() + { + ["x"] = 16, + ["o"] = 8, + ["b"] = 2 + }; + + /** + * A helper dictionary to map non-decimal bases to their TOML identifiers + */ + public static readonly Dictionary BaseIdentifiers = new() + { + [2] = "b", + [8] = "o", + [16] = "x" + }; + + public const string RFC3339EmptySeparator = " "; + public const string ISO861Separator = "T"; + public const string ISO861ZeroZone = "+00:00"; + public const string RFC3339ZeroZone = "Z"; + + /** + * Valid date formats with timezone as per RFC3339. + */ + public static readonly string[] RFC3339Formats = + { + "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" + }; + + /** + * Valid date formats without timezone (assumes local) as per RFC3339. + */ + public static readonly string[] RFC3339LocalDateTimeFormats = + { + "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" + }; + + /** + * Valid full date format as per TOML spec. + */ + public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; + + /** + * Valid time formats as per TOML spec. + */ + public static readonly string[] RFC3339LocalTimeFormats = + { + "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", + "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" + }; + + #endregion + + #region Character definitions + + public const char ARRAY_END_SYMBOL = ']'; + public const char ITEM_SEPARATOR = ','; + public const char ARRAY_START_SYMBOL = '['; + public const char BASIC_STRING_SYMBOL = '\"'; + public const char COMMENT_SYMBOL = '#'; + public const char ESCAPE_SYMBOL = '\\'; + public const char KEY_VALUE_SEPARATOR = '='; + public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; + public const char NEWLINE_CHARACTER = '\n'; + public const char SUBKEY_SEPARATOR = '.'; + public const char TABLE_END_SYMBOL = ']'; + public const char TABLE_START_SYMBOL = '['; + public const char INLINE_TABLE_START_SYMBOL = '{'; + public const char INLINE_TABLE_END_SYMBOL = '}'; + 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 bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; + + public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; + + public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; + + public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; + + public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); + + public static bool IsBareKey(char c) => + c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; + + public static bool MustBeEscaped(char c, bool allowNewLines = false) + { + var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; + if (!allowNewLines) + result |= c is >= '\u000a' and <= '\u000e'; + return result; + } + + public static bool IsValueSeparator(char c) => + c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; + + #endregion + } + + internal static class StringUtils + { + public static string AsKey(this string key) + { + var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); + return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; + } + + public static string Join(this string self, IEnumerable subItems) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var subItem in subItems) + { + if (!first) sb.Append(self); + first = false; + sb.Append(subItem); + } + + return sb.ToString(); + } + + 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, + TryDateParseDelegate parser, + out T dateTime, + out int parsedFormat) + { + parsedFormat = 0; + dateTime = default; + for (var i = 0; i < formats.Length; i++) + { + var format = formats[i]; + if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; + parsedFormat = i; + return true; + } + + return false; + } + + public static void AsComment(this string self, TextWriter tw) + { + foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) + tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); + } + + public static string RemoveAll(this string txt, char toRemove) + { + var sb = new StringBuilder(txt.Length); + foreach (var c in txt.Where(c => c != toRemove)) + sb.Append(c); + return sb.ToString(); + } + + public static string Escape(this string txt, bool escapeNewlines = true) + { + var stringBuilder = new StringBuilder(txt.Length + 2); + for (var i = 0; i < txt.Length; i++) + { + var c = txt[i]; + + static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) + ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" + : $"\\u{(ushort)c:X4}"; + + stringBuilder.Append(c switch + { + '\b' => @"\b", + '\t' => @"\t", + '\n' when escapeNewlines => @"\n", + '\f' => @"\f", + '\r' when escapeNewlines => @"\r", + '\\' => @"\\", + '\"' => @"\""", + var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => + CodePoint(txt, ref i, c), + var _ => c + }); + } + + return stringBuilder.ToString(); + } + + public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) + { + try + { + exception = null; + unescaped = txt.Unescape(); + return true; + } + catch (Exception e) + { + exception = e; + unescaped = null; + return false; + } + } + + public static string Unescape(this string txt) + { + if (string.IsNullOrEmpty(txt)) return txt; + var stringBuilder = new StringBuilder(txt.Length); + for (var i = 0; i < txt.Length;) + { + var num = txt.IndexOf('\\', i); + var next = num + 1; + if (num < 0 || num == txt.Length - 1) num = txt.Length; + stringBuilder.Append(txt, i, num - i); + if (num >= txt.Length) break; + var c = txt[next]; + + static string CodePoint(int next, string txt, ref int num, int size) + { + if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); + num += size; + return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); + } + + 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), + var _ => throw new Exception("Undefined escape sequence!") + }); + i = num + 2; + } + + return stringBuilder.ToString(); + } + } + + #endregion +} diff --git a/MCPForUnity/Editor/External/Tommy.cs.meta b/MCPForUnity/Editor/External/Tommy.cs.meta new file mode 100644 index 00000000..efcb8ff8 --- /dev/null +++ b/MCPForUnity/Editor/External/Tommy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea652131dcdaa44ca8cb35cd1191be3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers.meta b/MCPForUnity/Editor/Helpers.meta new file mode 100644 index 00000000..c57a3420 --- /dev/null +++ b/MCPForUnity/Editor/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 94cb070dc5e15024da86150b27699ca0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs new file mode 100644 index 00000000..f03b66c7 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -0,0 +1,29 @@ +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity asset paths. + /// + public static class AssetPathUtility + { + /// + /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// + public static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + path = path.Replace('\\', '/'); + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + + return path; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta new file mode 100644 index 00000000..bd6a0c70 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs new file mode 100644 index 00000000..fceab479 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using MCPForUnity.External.Tommy; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Codex CLI specific configuration helpers. Handles TOML snippet + /// generation and lightweight parsing so Codex can join the auto-setup + /// flow alongside JSON-based clients. + /// + public static class CodexConfigHelper + { + public static bool IsCodexConfigured(string pythonDir) + { + try + { + string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(basePath)) return false; + + string configPath = Path.Combine(basePath, ".codex", "config.toml"); + if (!File.Exists(configPath)) return false; + + string toml = File.ReadAllText(configPath); + if (!TryParseCodexServer(toml, out _, out var args)) return false; + + string dir = McpConfigFileHelper.ExtractDirectoryArg(args); + if (string.IsNullOrEmpty(dir)) return false; + + return McpConfigFileHelper.PathsEqual(dir, pythonDir); + } + catch + { + return false; + } + } + + public static string BuildCodexServerBlock(string uvPath, string serverSrc) + { + string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" }); + return $"[mcp_servers.unityMCP]{Environment.NewLine}" + + $"command = \"{EscapeTomlString(uvPath)}\"{Environment.NewLine}" + + $"args = {argsArray}"; + } + + public static string UpsertCodexServerBlock(string existingToml, string newBlock) + { + if (string.IsNullOrWhiteSpace(existingToml)) + { + return newBlock.TrimEnd() + Environment.NewLine; + } + + StringBuilder sb = new StringBuilder(); + using StringReader reader = new StringReader(existingToml); + string line; + bool inTarget = false; + bool replaced = false; + while ((line = reader.ReadLine()) != null) + { + string trimmed = line.Trim(); + bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); + if (isSection) + { + bool isTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); + if (isTarget) + { + if (!replaced) + { + if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine(); + sb.AppendLine(newBlock.TrimEnd()); + replaced = true; + } + inTarget = true; + continue; + } + + if (inTarget) + { + inTarget = false; + } + } + + if (inTarget) + { + continue; + } + + sb.AppendLine(line); + } + + if (!replaced) + { + if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine(); + sb.AppendLine(newBlock.TrimEnd()); + } + + return sb.ToString().TrimEnd() + Environment.NewLine; + } + + public static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + command = null; + args = null; + if (string.IsNullOrWhiteSpace(toml)) return false; + + try + { + using var reader = new StringReader(toml); + TomlTable root = TOML.Parse(reader); + if (root == null) return false; + + if (!TryGetTable(root, "mcp_servers", out var servers) + && !TryGetTable(root, "mcpServers", out servers)) + { + return false; + } + + if (!TryGetTable(servers, "unityMCP", out var unity)) + { + return false; + } + + command = GetTomlString(unity, "command"); + args = GetTomlStringArray(unity, "args"); + + return !string.IsNullOrEmpty(command) && args != null; + } + catch (TomlParseException) + { + return false; + } + catch (TomlSyntaxException) + { + return false; + } + catch (FormatException) + { + return false; + } + } + + private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) + { + table = null; + if (parent == null) return false; + + if (parent.TryGetNode(key, out var node)) + { + if (node is TomlTable tbl) + { + table = tbl; + return true; + } + + if (node is TomlArray array) + { + var firstTable = array.Children.OfType().FirstOrDefault(); + if (firstTable != null) + { + table = firstTable; + return true; + } + } + } + + return false; + } + + private static string GetTomlString(TomlTable table, string key) + { + if (table != null && table.TryGetNode(key, out var node)) + { + if (node is TomlString str) return str.Value; + if (node.HasValue) return node.ToString(); + } + return null; + } + + private static string[] GetTomlStringArray(TomlTable table, string key) + { + if (table == null) return null; + if (!table.TryGetNode(key, out var node)) return null; + + if (node is TomlArray array) + { + List values = new List(); + foreach (TomlNode element in array.Children) + { + if (element is TomlString str) + { + values.Add(str.Value); + } + else if (element.HasValue) + { + values.Add(element.ToString()); + } + } + + return values.Count > 0 ? values.ToArray() : Array.Empty(); + } + + if (node is TomlString single) + { + return new[] { single.Value }; + } + + return null; + } + + private static string FormatTomlStringArray(IEnumerable values) + { + if (values == null) return "[]"; + StringBuilder sb = new StringBuilder(); + sb.Append('['); + bool first = true; + foreach (string value in values) + { + if (!first) + { + sb.Append(", "); + } + sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"'); + first = false; + } + sb.Append(']'); + return sb.ToString(); + } + + private static string EscapeTomlString(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\""); + } + + } +} diff --git a/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta new file mode 100644 index 00000000..581a4474 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/CodexConfigHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3e68082ffc0b4cd39d3747673a4cc22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs new file mode 100644 index 00000000..5889e4f6 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs @@ -0,0 +1,129 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ConfigJsonBuilder + { + public static string BuildManualConfigJson(string uvPath, string pythonDir, McpClient client) + { + var root = new JObject(); + bool isVSCode = client?.mcpType == McpTypes.VSCode; + JObject container; + if (isVSCode) + { + container = EnsureObject(root, "servers"); + } + else + { + container = EnsureObject(root, "mcpServers"); + } + + var unity = new JObject(); + PopulateUnityNode(unity, uvPath, pythonDir, client, isVSCode); + + container["unityMCP"] = unity; + + return root.ToString(Formatting.Indented); + } + + public static JObject ApplyUnityServerToExistingConfig(JObject root, string uvPath, string serverSrc, McpClient client) + { + if (root == null) root = new JObject(); + bool isVSCode = client?.mcpType == McpTypes.VSCode; + JObject container = isVSCode ? EnsureObject(root, "servers") : EnsureObject(root, "mcpServers"); + JObject unity = container["unityMCP"] as JObject ?? new JObject(); + PopulateUnityNode(unity, uvPath, serverSrc, client, isVSCode); + + container["unityMCP"] = unity; + return root; + } + + /// + /// Centralized builder that applies all caveats consistently. + /// - Sets command/args with provided directory + /// - Ensures env exists + /// - Adds type:"stdio" for VSCode + /// - Adds disabled:false for Windsurf/Kiro only when missing + /// + private static void PopulateUnityNode(JObject unity, string uvPath, string directory, McpClient client, bool isVSCode) + { + unity["command"] = uvPath; + + // For Cursor (non-VSCode) on macOS, prefer a no-spaces symlink path to avoid arg parsing issues in some runners + string effectiveDir = directory; +#if UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX + bool isCursor = !isVSCode && (client == null || client.mcpType != McpTypes.VSCode); + if (isCursor && !string.IsNullOrEmpty(directory)) + { + // Replace canonical path segment with the symlink path if present + const string canonical = "/Library/Application Support/"; + const string symlinkSeg = "/Library/AppSupport/"; + try + { + // Normalize to full path style + if (directory.Contains(canonical)) + { + var candidate = directory.Replace(canonical, symlinkSeg).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } + } + else + { + // If installer returned XDG-style on macOS, map to canonical symlink + string norm = directory.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", System.StringComparison.Ordinal); + if (idx >= 0) + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... + string candidate = System.IO.Path.Combine(home, "Library", "AppSupport", suffix).Replace('\\', '/'); + if (System.IO.Directory.Exists(candidate)) + { + effectiveDir = candidate; + } + } + } + } + catch { /* fallback to original directory on any error */ } + } +#endif + + unity["args"] = JArray.FromObject(new[] { "run", "--directory", effectiveDir, "server.py" }); + + if (isVSCode) + { + unity["type"] = "stdio"; + } + else + { + // Remove type if it somehow exists from previous clients + if (unity["type"] != null) unity.Remove("type"); + } + + if (client != null && (client.mcpType == McpTypes.Windsurf || client.mcpType == McpTypes.Kiro)) + { + if (unity["env"] == null) + { + unity["env"] = new JObject(); + } + + if (unity["disabled"] == null) + { + unity["disabled"] = false; + } + } + } + + private static JObject EnsureObject(JObject parent, string name) + { + if (parent[name] is JObject o) return o; + var created = new JObject(); + parent[name] = created; + return created; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta new file mode 100644 index 00000000..f574fde7 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c07c3369f73943919d9e086a81d1dcc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs new file mode 100644 index 00000000..20c1200b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -0,0 +1,278 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Runtime.InteropServices; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class ExecPath + { + private const string PrefClaude = "MCPForUnity.ClaudeCliPath"; + + // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. + internal static string ResolveClaude() + { + try + { + string pref = EditorPrefs.GetString(PrefClaude, string.Empty); + if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; + } + catch { } + + string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + // Common npm global locations + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string[] candidates = + { + // Prefer .cmd (most reliable from non-interactive processes) + Path.Combine(appData, "npm", "claude.cmd"), + Path.Combine(localAppData, "npm", "claude.cmd"), + // Fall back to PowerShell shim if only .ps1 is present + Path.Combine(appData, "npm", "claude.ps1"), + Path.Combine(localAppData, "npm", "claude.ps1"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + + // Linux + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/usr/local/bin/claude", + "/usr/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + } + + // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version + private static string ResolveClaudeFromNvm(string home) + { + try + { + if (string.IsNullOrEmpty(home)) return null; + string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); + if (!Directory.Exists(nvmNodeDir)) return null; + + string bestPath = null; + Version bestVersion = null; + foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) + { + string name = Path.GetFileName(versionDir); + if (string.IsNullOrEmpty(name)) continue; + if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 + string versionStr = name.Substring(1); + int dashIndex = versionStr.IndexOf('-'); + if (dashIndex > 0) + { + versionStr = versionStr.Substring(0, dashIndex); + } + if (Version.TryParse(versionStr, out Version parsed)) + { + string candidate = Path.Combine(versionDir, "bin", "claude"); + if (File.Exists(candidate)) + { + if (bestVersion == null || parsed > bestVersion) + { + bestVersion = parsed; + bestPath = candidate; + } + } + } + } + } + return bestPath; + } + catch { return null; } + } + + // Explicitly set the Claude CLI absolute path override in EditorPrefs + internal static void SetClaudeCliPath(string absolutePath) + { + try + { + if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) + { + EditorPrefs.SetString(PrefClaude, absolutePath); + } + } + catch { } + } + + // Clear any previously set Claude CLI override path + internal static void ClearClaudeCliPath() + { + try + { + if (EditorPrefs.HasKey(PrefClaude)) + { + EditorPrefs.DeleteKey(PrefClaude); + } + } + catch { } + } + + // Use existing UV resolver; returns absolute path or null. + internal static string ResolveUv() + { + return ServerInstaller.FindUvPath(); + } + + internal static bool TryRun( + string file, + string args, + string workingDir, + out string stdout, + out string stderr, + int timeoutMs = 15000, + string extraPathPrepend = null) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + // Handle PowerShell scripts on Windows by invoking through powershell.exe + bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + + var psi = new ProcessStartInfo + { + FileName = isPs1 ? "powershell.exe" : file, + Arguments = isPs1 + ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() + : args, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + } + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; + + 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); }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { } + return false; + } + + // Ensure async buffers are flushed + process.WaitForExit(); + + stdout = so.ToString(); + stderr = se.ToString(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + private static string Which(string exe, string prependPath) + { + try + { + var psi = new ProcessStartInfo("/usr/bin/which", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + using var p = Process.Start(psi); + string output = p?.StandardOutput.ReadToEnd().Trim(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + catch { return null; } + } +#endif + +#if UNITY_EDITOR_WIN + private static string Where(string exe) + { + try + { + var psi = new ProcessStartInfo("where", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + string first = p?.StandardOutput.ReadToEnd() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + catch { return null; } + } +#endif + } +} diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs.meta b/MCPForUnity/Editor/Helpers/ExecPath.cs.meta new file mode 100644 index 00000000..aba921ed --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f2b7b3e9c3e4a0f9b2a1d4c7e6f5a12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs new file mode 100644 index 00000000..d7bf979c --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Runtime.Serialization; // For Converters + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Handles serialization of GameObjects and Components for MCP responses. + /// Includes reflection helpers and caching for performance. + /// + public static class GameObjectSerializer + { + // --- Data Serialization --- + + /// + /// Creates a serializable representation of a GameObject. + /// + public static object GetGameObjectData(GameObject go) + { + if (go == null) + return null; + return new + { + name = go.name, + instanceID = go.GetInstanceID(), + tag = go.tag, + layer = go.layer, + activeSelf = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + scenePath = go.scene.path, // Identify which scene it belongs to + transform = new // Serialize transform components carefully to avoid JSON issues + { + // Serialize Vector3 components individually to prevent self-referencing loops. + // The default serializer can struggle with properties like Vector3.normalized. + position = new + { + x = go.transform.position.x, + y = go.transform.position.y, + z = go.transform.position.z, + }, + localPosition = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.rotation.eulerAngles.x, + y = go.transform.rotation.eulerAngles.y, + z = go.transform.rotation.eulerAngles.z, + }, + localRotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + forward = new + { + x = go.transform.forward.x, + y = go.transform.forward.y, + z = go.transform.forward.z, + }, + up = new + { + x = go.transform.up.x, + y = go.transform.up.y, + z = go.transform.up.z, + }, + right = new + { + x = go.transform.right.x, + y = go.transform.right.y, + z = go.transform.right.z, + }, + }, + parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent + // Optionally include components, but can be large + // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() + // Or just component names: + componentNames = go.GetComponents() + .Select(c => c.GetType().FullName) + .ToList(), + }; + } + + // --- Metadata Caching for Reflection --- + private class CachedMetadata + { + public readonly List SerializableProperties; + public readonly List SerializableFields; + + public CachedMetadata(List properties, List fields) + { + SerializableProperties = properties; + SerializableFields = fields; + } + } + // Key becomes Tuple + private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); + // --- End Metadata Caching --- + + /// + /// Creates a serializable representation of a Component, attempting to serialize + /// public properties and fields using reflection, with caching and control over non-public fields. + /// + // Add the flag parameter here + public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) + { + // --- 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(); + + // --- Special handling for Transform to avoid reflection crashes and problematic properties --- + if (componentType == typeof(Transform)) + { + Transform tr = c as Transform; + // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", tr.GetInstanceID() }, + // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. + { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, // Use Euler angles + { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, + { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, + { "childCount", tr.childCount }, + // Include standard Object/Component properties + { "name", tr.name }, + { "tag", tr.tag }, + { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } + }; + } + // --- End Special handling for Transform --- + + // --- Special handling for Camera to avoid matrix-related crashes --- + if (componentType == typeof(Camera)) + { + Camera cam = c as Camera; + var cameraProperties = new Dictionary(); + + // List of safe properties to serialize + var safeProperties = new Dictionary> + { + { "nearClipPlane", () => cam.nearClipPlane }, + { "farClipPlane", () => cam.farClipPlane }, + { "fieldOfView", () => cam.fieldOfView }, + { "renderingPath", () => (int)cam.renderingPath }, + { "actualRenderingPath", () => (int)cam.actualRenderingPath }, + { "allowHDR", () => cam.allowHDR }, + { "allowMSAA", () => cam.allowMSAA }, + { "allowDynamicResolution", () => cam.allowDynamicResolution }, + { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, + { "orthographicSize", () => cam.orthographicSize }, + { "orthographic", () => cam.orthographic }, + { "opaqueSortMode", () => (int)cam.opaqueSortMode }, + { "transparencySortMode", () => (int)cam.transparencySortMode }, + { "depth", () => cam.depth }, + { "aspect", () => cam.aspect }, + { "cullingMask", () => cam.cullingMask }, + { "eventMask", () => cam.eventMask }, + { "backgroundColor", () => cam.backgroundColor }, + { "clearFlags", () => (int)cam.clearFlags }, + { "stereoEnabled", () => cam.stereoEnabled }, + { "stereoSeparation", () => cam.stereoSeparation }, + { "stereoConvergence", () => cam.stereoConvergence }, + { "enabled", () => cam.enabled }, + { "name", () => cam.name }, + { "tag", () => cam.tag }, + { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } + }; + + foreach (var prop in safeProperties) + { + try + { + var value = prop.Value(); + if (value != null) + { + AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); + } + } + catch (Exception) + { + // Silently skip any property that fails + continue; + } + } + + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", cam.GetInstanceID() }, + { "properties", cameraProperties } + }; + } + // --- End Special handling for Camera --- + + var data = new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceID() } + }; + + // --- Get Cached or Generate Metadata (using new cache key) --- + Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); + if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) + { + var propertiesToCache = new List(); + var fieldsToCache = new List(); + + // Traverse the hierarchy from the component type up to MonoBehaviour + Type currentType = componentType; + while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) + { + // Get properties declared only at the current type level + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var propInfo in currentType.GetProperties(propFlags)) + { + // 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); + } + } + + // Get fields declared only at the current type level (both public and non-public) + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var declaredFields = currentType.GetFields(fieldFlags); + + // Process the declared Fields for caching + foreach (var fieldInfo in declaredFields) + { + 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; + + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR NonPublic with [SerializeField] + shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } + + if (shouldInclude) + { + fieldsToCache.Add(fieldInfo); + } + } + + // Move to the base type + currentType = currentType.BaseType; + } + // --- End Hierarchy Traversal --- + + cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); + _metadataCache[cacheKey] = cachedData; // Add to cache with combined key + } + // --- End Get Cached or Generate Metadata --- + + // --- 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 --- + + // Use cached properties + foreach (var propInfo in cachedData.SerializableProperties) + { + string propName = propInfo.Name; + + // --- Skip known obsolete/problematic Component shortcut properties --- + bool skipProperty = false; + if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || + propName == "light" || propName == "animation" || propName == "constantForce" || + propName == "renderer" || propName == "audio" || propName == "networkView" || + propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || + 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; + } + // --- End Skip Generic Properties --- + + // --- Skip specific potentially problematic Camera properties --- + if (componentType == typeof(Camera) && + (propName == "pixelRect" || + propName == "rect" || + propName == "cullingMatrix" || + propName == "useOcclusionCulling" || + propName == "worldToCameraMatrix" || + propName == "projectionMatrix" || + propName == "nonJitteredProjectionMatrix" || + propName == "previousViewProjectionMatrix" || + propName == "cameraToWorldMatrix")) + { + // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}"); + skipProperty = true; + } + // --- End Skip Camera Properties --- + + // --- Skip specific potentially problematic Transform properties --- + if (componentType == typeof(Transform) && + (propName == "lossyScale" || + propName == "rotation" || + propName == "worldToLocalMatrix" || + propName == "localToWorldMatrix")) + { + // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}"); + skipProperty = true; + } + // --- End Skip Transform Properties --- + + // Skip if flagged + if (skipProperty) + { + continue; + } + + try + { + // --- Add detailed logging --- + // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); + // --- End detailed logging --- + object value = propInfo.GetValue(c); + Type propType = propInfo.PropertyType; + AddSerializableValue(serializablePropertiesOutput, propName, propType, value); + } + catch (Exception) + { + // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); + } + } + + // --- Add Logging Before Field Loop --- + // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}..."); + // --- End Logging Before Field Loop --- + + // Use cached fields + foreach (var fieldInfo in cachedData.SerializableFields) + { + try + { + // --- Add detailed logging for fields --- + // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); + // --- End detailed logging for fields --- + object value = fieldInfo.GetValue(c); + string fieldName = fieldInfo.Name; + Type fieldType = fieldInfo.FieldType; + AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); + } + catch (Exception) + { + // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); + } + } + // --- End Use cached metadata --- + + if (serializablePropertiesOutput.Count > 0) + { + data["properties"] = serializablePropertiesOutput; + } + + return data; + } + + // Helper function to decide how to serialize different types + private static void AddSerializableValue(Dictionary dict, string name, Type type, object value) + { + // Simplified: Directly use CreateTokenFromValue which uses the serializer + if (value == null) + { + dict[name] = null; + return; + } + + try + { + // Use the helper that employs our custom serializer settings + JToken token = CreateTokenFromValue(value, type); + if (token != null) // Check if serialization succeeded in the helper + { + // Convert JToken back to a basic object structure for the dictionary + dict[name] = ConvertJTokenToPlainObject(token); + } + // If token is null, it means serialization failed and a warning was logged. + } + catch (Exception e) + { + // Catch potential errors during JToken conversion or addition to dictionary + Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); + } + } + + // Helper to convert JToken back to basic object structure + private static object ConvertJTokenToPlainObject(JToken token) + { + if (token == null) return null; + + switch (token.Type) + { + case JTokenType.Object: + var objDict = new Dictionary(); + foreach (var prop in ((JObject)token).Properties()) + { + objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); + } + return objDict; + + case JTokenType.Array: + var list = new List(); + foreach (var item in (JArray)token) + { + list.Add(ConvertJTokenToPlainObject(item)); + } + return list; + + case JTokenType.Integer: + return token.ToObject(); // Use long for safety + case JTokenType.Float: + return token.ToObject(); // Use double for safety + case JTokenType.String: + return token.ToObject(); + 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(); + case JTokenType.Bytes: + return token.ToObject(); + case JTokenType.Null: + return null; + case JTokenType.Undefined: + return null; // Treat undefined as null + + default: + // Fallback for simple value types not explicitly listed + if (token is JValue jValue && jValue.Value != null) + { + return jValue.Value; + } + // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null."); + return null; + } + } + + // --- Define custom JsonSerializerSettings for OUTPUT --- + private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() // Handles serialization of references + }, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed + }; + private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); + // --- End Define custom JsonSerializerSettings --- + + // Helper to create JToken using the output serializer + private static JToken CreateTokenFromValue(object value, Type type) + { + if (value == null) return JValue.CreateNull(); + + try + { + // Use the pre-configured OUTPUT serializer instance + return JToken.FromObject(value, _outputSerializer); + } + catch (JsonSerializationException e) + { + Debug.LogWarning($"[GameObjectSerializer] Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); + return null; // Indicate serialization failure + } + catch (Exception e) // Catch other unexpected errors + { + Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); + return null; // Indicate serialization failure + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta new file mode 100644 index 00000000..9eb69d04 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/GameObjectSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64b8ff807bc9a401c82015cbafccffac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs new file mode 100644 index 00000000..389d47d2 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helpers for reading and writing MCP client configuration files. + /// Consolidates file atomics and server directory resolution so the editor + /// window can focus on UI concerns only. + /// + public static class McpConfigFileHelper + { + public static string ExtractDirectoryArg(string[] args) + { + if (args == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + public static bool PathsEqual(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + try + { + string na = Path.GetFullPath(a.Trim()); + string nb = Path.GetFullPath(b.Trim()); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch + { + return false; + } + } + + /// + /// Resolves the server directory to use for MCP tools, preferring + /// existing config values and falling back to installed/embedded copies. + /// + public static string ResolveServerDirectory(string pythonDir, string[] existingArgs) + { + string serverSrc = ExtractDirectoryArg(existingArgs); + bool serverValid = !string.IsNullOrEmpty(serverSrc) + && File.Exists(Path.Combine(serverSrc, "server.py")); + if (!serverValid) + { + if (!string.IsNullOrEmpty(pythonDir) + && File.Exists(Path.Combine(pythonDir, "server.py"))) + { + serverSrc = pythonDir; + } + else + { + serverSrc = ResolveServerSource(); + } + } + + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc)) + { + string norm = serverSrc.Replace('\\', '/'); + int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); + if (idx >= 0) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + string suffix = norm.Substring(idx + "/.local/share/".Length); + serverSrc = Path.Combine(home, "Library", "Application Support", suffix); + } + } + } + catch + { + // Ignore failures and fall back to the original path. + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && !string.IsNullOrEmpty(serverSrc) + && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 + && !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) + { + serverSrc = ServerInstaller.GetServerPath(); + } + + return serverSrc; + } + + public static void WriteAtomicFile(string path, string contents) + { + string tmp = path + ".tmp"; + string backup = path + ".backup"; + bool writeDone = false; + try + { + File.WriteAllText(tmp, contents, new UTF8Encoding(false)); + try + { + File.Replace(tmp, path, backup); + writeDone = true; + } + catch (FileNotFoundException) + { + File.Move(tmp, path); + writeDone = true; + } + catch (PlatformNotSupportedException) + { + if (File.Exists(path)) + { + try + { + if (File.Exists(backup)) File.Delete(backup); + } + catch { } + File.Move(path, backup); + } + File.Move(tmp, path); + writeDone = true; + } + } + catch (Exception ex) + { + try + { + if (!writeDone && File.Exists(backup)) + { + try { File.Copy(backup, path, true); } catch { } + } + } + catch { } + throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex); + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { } + } + } + + public static string ResolveServerSource() + { + try + { + string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); + if (!string.IsNullOrEmpty(remembered) + && File.Exists(Path.Combine(remembered, "server.py"))) + { + return remembered; + } + + ServerInstaller.EnsureServerInstalled(); + string installed = ServerInstaller.GetServerPath(); + if (File.Exists(Path.Combine(installed, "server.py"))) + { + return installed; + } + + bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); + if (useEmbedded + && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) + && File.Exists(Path.Combine(embedded, "server.py"))) + { + return embedded; + } + + return installed; + } + catch + { + return ServerInstaller.GetServerPath(); + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs.meta b/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs.meta new file mode 100644 index 00000000..8f81ae99 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigFileHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f69ad468942b74c0ea24e3e8e5f21a4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs new file mode 100644 index 00000000..8e727efb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs @@ -0,0 +1,297 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for MCP client configuration management with sophisticated + /// logic for preserving existing configs and handling different client types + /// + public static class McpConfigurationHelper + { + private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; + + /// + /// Writes MCP configuration to the specified path using sophisticated logic + /// that preserves existing configuration and only writes when necessary + /// + public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) + { + // 0) Respect explicit lock (hidden pref or UI toggle) + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; + + // Read existing config if it exists + string existingJson = "{}"; + if (File.Exists(configPath)) + { + try + { + existingJson = File.ReadAllText(configPath); + } + catch (Exception e) + { + Debug.LogWarning($"Error reading existing config: {e.Message}."); + } + } + + // Parse the existing JSON while preserving all properties + dynamic existingConfig; + try + { + if (string.IsNullOrWhiteSpace(existingJson)) + { + existingConfig = new JObject(); + } + else + { + existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); + } + } + catch + { + // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object + if (!string.IsNullOrWhiteSpace(existingJson)) + { + Debug.LogWarning("UnityMCP: Configuration file could not be parsed; rewriting server block."); + } + existingConfig = new 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 = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); + if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(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; + else + existingRoot = JObject.FromObject(existingConfig); + + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); + + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); + + try + { + if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } + + /// + /// Configures a Codex client with sophisticated TOML handling + /// + public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) + { + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + string existingToml = string.Empty; + if (File.Exists(configPath)) + { + try + { + existingToml = File.ReadAllText(configPath); + } + catch (Exception e) + { + 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") && IsValidUvBinary(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 (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } + + /// + /// Validates UV binary by running --version command + /// + private static bool IsValidUvBinary(string path) + { + try + { + if (!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; } + } + + /// + /// Compares two string arrays for equality + /// + 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; + } + + /// + /// Gets the appropriate config file path for the given MCP client based on OS + /// + public static string GetClientConfigPath(McpClient mcpClient) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return mcpClient.windowsConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return mcpClient.linuxConfigPath; + } + else + { + return mcpClient.linuxConfigPath; // fallback + } + } + + /// + /// Creates the directory for the config file if it doesn't exist + /// + public static void EnsureConfigDirectoryExists(string configPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta new file mode 100644 index 00000000..17de56c8 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpConfigurationHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e45ac2a13b4c1ba468b8e3aa67b292ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs b/MCPForUnity/Editor/Helpers/McpLog.cs new file mode 100644 index 00000000..85abdb79 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLog.cs @@ -0,0 +1,31 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + internal static class McpLog + { + private const string Prefix = "MCP-FOR-UNITY:"; + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + Debug.Log($"{Prefix} {message}"); + } + + public static void Warn(string message) + { + Debug.LogWarning($"{Prefix} {message}"); + } + + public static void Error(string message) + { + Debug.LogError($"{Prefix} {message}"); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpLog.cs.meta b/MCPForUnity/Editor/Helpers/McpLog.cs.meta new file mode 100644 index 00000000..b9e0fc38 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpLog.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: 9e2c3f8a4f4f48d8a4c1b7b8e3f5a1c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs b/MCPForUnity/Editor/Helpers/McpPathResolver.cs new file mode 100644 index 00000000..8e683965 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpPathResolver.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for resolving Python server directory paths with support for + /// development mode, embedded servers, and installed packages + /// + public static class McpPathResolver + { + private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; + + /// + /// Resolves the Python server directory path with comprehensive logic + /// including development mode support and fallback mechanisms + /// + public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) + { + string pythonDir = McpConfigFileHelper.ResolveServerSource(); + + try + { + // Only check dev paths if we're using a file-based package (development mode) + bool isDevelopmentMode = IsDevelopmentMode(); + if (isDevelopmentMode) + { + string currentPackagePath = Path.GetDirectoryName(Application.dataPath); + string[] devPaths = { + 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"))) + { + if (debugLogsEnabled) + { + Debug.Log($"Currently in development mode. Package: {devPath}"); + } + return devPath; + } + } + } + + // Resolve via shared helper (handles local registry and older fallback) only if dev override on + if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, 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) + { + Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); + } + } + } + catch (Exception e) + { + Debug.LogError($"Error finding package path: {e.Message}"); + } + + return pythonDir; + } + + /// + /// Checks if the current Unity project is in development mode + /// (i.e., the package is referenced as a local file path in manifest.json) + /// + private static bool IsDevelopmentMode() + { + try + { + // Only treat as development if manifest explicitly references a local file path for the package + string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); + if (!File.Exists(manifestPath)) return false; + + string manifestContent = File.ReadAllText(manifestPath); + // Look specifically for our package dependency set to a file: URL + // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk + if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); + // Crude but effective: check for "file:" in the same line/value + if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 + && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + catch + { + return false; + } + } + + /// + /// Gets the appropriate PATH prepend for the current platform when running external processes + /// + public static string GetPathPrepend() + { + if (Application.platform == RuntimePlatform.OSXEditor) + return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + else if (Application.platform == RuntimePlatform.LinuxEditor) + return "/usr/local/bin:/usr/bin:/bin"; + return null; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta new file mode 100644 index 00000000..38f19973 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/McpPathResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c76f0c7ff138ba4a952481e04bc3974 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs b/MCPForUnity/Editor/Helpers/PackageDetector.cs new file mode 100644 index 00000000..bb8861fe --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PackageDetector.cs @@ -0,0 +1,107 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Auto-runs legacy/older install detection on package load/update (log-only). + /// Runs once per embedded server version using an EditorPrefs version-scoped key. + /// + [InitializeOnLoad] + public static class PackageDetector + { + private const string DetectOnceFlagKeyPrefix = "MCPForUnity.LegacyDetectLogged:"; + + static PackageDetector() + { + try + { + string pkgVer = ReadPackageVersionOrFallback(); + string key = DetectOnceFlagKeyPrefix + pkgVer; + + // Always force-run if legacy roots exist or canonical install is missing + bool legacyPresent = LegacyRootsExist(); + bool canonicalMissing = !System.IO.File.Exists(System.IO.Path.Combine(ServerInstaller.GetServerPath(), "server.py")); + + if (!EditorPrefs.GetBool(key, false) || legacyPresent || canonicalMissing) + { + // Marshal the entire flow to the main thread. EnsureServerInstalled may touch Unity APIs. + EditorApplication.delayCall += () => + { + string error = null; + System.Exception capturedEx = null; + try + { + // Ensure any UnityEditor API usage inside runs on the main thread + ServerInstaller.EnsureServerInstalled(); + } + catch (System.Exception ex) + { + error = ex.Message; + capturedEx = ex; + } + + // Unity APIs must stay on main thread + try { EditorPrefs.SetBool(key, true); } catch { } + // Ensure prefs cleanup happens on main thread + try { EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); } catch { } + try { EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); } catch { } + + if (!string.IsNullOrEmpty(error)) + { + Debug.LogWarning($"MCP for Unity: Auto-detect on load failed: {capturedEx}"); + // Alternatively: Debug.LogException(capturedEx); + } + }; + } + } + catch { /* ignore */ } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = System.IO.Path.Combine(embeddedSrc, "server_version.txt"); + if (System.IO.File.Exists(p)) + return (System.IO.File.ReadAllText(p)?.Trim() ?? "unknown"); + } + } + catch { } + return "unknown"; + } + + private static string ReadPackageVersionOrFallback() + { + try + { + var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(PackageDetector).Assembly); + if (info != null && !string.IsNullOrEmpty(info.version)) return info.version; + } + catch { } + // Fallback to embedded server version if package info unavailable + return ReadEmbeddedVersionOrFallback(); + } + + private static bool LegacyRootsExist() + { + try + { + string home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] roots = + { + System.IO.Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer", "src"), + System.IO.Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer", "src") + }; + foreach (var r in roots) + { + try { if (System.IO.File.Exists(System.IO.Path.Combine(r, "server.py"))) return true; } catch { } + } + } + catch { } + return false; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PackageDetector.cs.meta b/MCPForUnity/Editor/Helpers/PackageDetector.cs.meta new file mode 100644 index 00000000..f1a5dbe4 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PackageDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b82eaef548d164ca095f17db64d15af8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PackageInstaller.cs b/MCPForUnity/Editor/Helpers/PackageInstaller.cs new file mode 100644 index 00000000..031a6aed --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PackageInstaller.cs @@ -0,0 +1,43 @@ +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Handles automatic installation of the Python server when the package is first installed. + /// + [InitializeOnLoad] + public static class PackageInstaller + { + private const string InstallationFlagKey = "MCPForUnity.ServerInstalled"; + + static PackageInstaller() + { + // Check if this is the first time the package is loaded + if (!EditorPrefs.GetBool(InstallationFlagKey, false)) + { + // Schedule the installation for after Unity is fully loaded + 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) + { + Debug.LogError($"MCP-FOR-UNITY: Failed to install Python server: {ex.Message}"); + Debug.LogWarning("MCP-FOR-UNITY: You may need to manually install the Python server. Check the MCP For Unity Window for instructions."); + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PackageInstaller.cs.meta b/MCPForUnity/Editor/Helpers/PackageInstaller.cs.meta new file mode 100644 index 00000000..156e75fb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PackageInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19e6eaa637484e9fa19f9a0459809de2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs new file mode 100644 index 00000000..09d85798 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -0,0 +1,319 @@ +using System; +using System.IO; +using UnityEditor; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Manages dynamic port allocation and persistent storage for MCP for Unity + /// + public static class PortManager + { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } + catch { return false; } + } + + private const int DefaultPort = 6400; + private const int MaxPortAttempts = 100; + private const string RegistryFileName = "unity-mcp-port.json"; + + [Serializable] + public class PortConfig + { + public int unity_port; + public string created_date; + public string project_path; + } + + /// + /// Get the port to use - either from storage or discover a new one + /// Will try stored port first, then fallback to discovering new port + /// + /// Port number to use + 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 && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + IsPortAvailable(storedConfig.unity_port)) + { + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using stored port {storedConfig.unity_port} for current project"); + return storedConfig.unity_port; + } + + // If stored port exists but is currently busy, wait briefly for release + if (storedConfig != null && storedConfig.unity_port > 0) + { + if (WaitForPortRelease(storedConfig.unity_port, 1500)) + { + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Stored port {storedConfig.unity_port} became available after short wait"); + return storedConfig.unity_port; + } + // Prefer sticking to the same port; let the caller handle bind retries/fallbacks + return storedConfig.unity_port; + } + + // If no valid stored port, find a new one and save it + int newPort = FindAvailablePort(); + SavePort(newPort); + return newPort; + } + + /// + /// Discover and save a new available port (used by Auto-Connect button) + /// + /// New available port + public static int DiscoverNewPort() + { + int newPort = FindAvailablePort(); + SavePort(newPort); + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Discovered and saved new port: {newPort}"); + return newPort; + } + + /// + /// Find an available port starting from the default port + /// + /// Available port number + private static int FindAvailablePort() + { + // Always try default port first + if (IsPortAvailable(DefaultPort)) + { + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Using default port {DefaultPort}"); + return DefaultPort; + } + + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Default port {DefaultPort} is in use, searching for alternative..."); + + // Search for alternatives + for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) + { + if (IsPortAvailable(port)) + { + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Found available port {port}"); + return port; + } + } + + throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); + } + + /// + /// Check if a specific port is available for binding + /// + /// Port to check + /// True if port is available + public static bool IsPortAvailable(int port) + { + try + { + var testListener = new TcpListener(IPAddress.Loopback, port); + testListener.Start(); + testListener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } + + /// + /// Check if a port is currently being used by MCP for Unity + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by MCP for Unity + public static bool IsPortUsedByMCPForUnity(int port) + { + try + { + // Try to make a quick connection to see if it's an MCP for Unity server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the MCP for Unity server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by an MCP instance, continue waiting briefly + if (!IsPortUsedByMCPForUnity(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + + /// + /// Save port to persistent storage + /// + /// Port to save + private static void SavePort(int port) + { + try + { + var portConfig = new PortConfig + { + unity_port = port, + created_date = DateTime.UtcNow.ToString("O"), + project_path = Application.dataPath + }; + + string registryDir = GetRegistryDirectory(); + Directory.CreateDirectory(registryDir); + + string registryFile = GetRegistryFilePath(); + string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to hashed, project-scoped file + File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + // Also write to legacy stable filename to avoid hash/case drift across reloads + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); + + if (IsDebugEnabled()) Debug.Log($"MCP-FOR-UNITY: Saved port {port} to storage"); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not save port to storage: {ex.Message}"); + } + } + + /// + /// Load port from persistent storage + /// + /// Stored port number, or 0 if not found + private static int LoadStoredPort() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file name + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return 0; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + var portConfig = JsonConvert.DeserializeObject(json); + + return portConfig?.unity_port ?? 0; + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load port from storage: {ex.Message}"); + return 0; + } + } + + /// + /// Get the current stored port configuration + /// + /// Port configuration if exists, null otherwise + public static PortConfig GetStoredPortConfig() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy file + string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); + if (!File.Exists(legacy)) + { + return null; + } + registryFile = legacy; + } + + string json = File.ReadAllText(registryFile); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load port config: {ex.Message}"); + return null; + } + } + + private static string GetRegistryDirectory() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + string hash = ComputeProjectHash(Application.dataPath); + string fileName = $"unity-mcp-port-{hash}.json"; + return Path.Combine(dir, fileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs.meta b/MCPForUnity/Editor/Helpers/PortManager.cs.meta new file mode 100644 index 00000000..ee3f667c --- /dev/null +++ b/MCPForUnity/Editor/Helpers/PortManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f6789012345678901234ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/Response.cs b/MCPForUnity/Editor/Helpers/Response.cs new file mode 100644 index 00000000..cfcd2efb --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Response.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides static methods for creating standardized success and error response objects. + /// Ensures consistent JSON structure for communication back to the Python server. + /// + public static class Response + { + /// + /// Creates a standardized success response object. + /// + /// A message describing the successful operation. + /// Optional additional data to include in the response. + /// An object representing the success response. + public static object Success(string message, object data = null) + { + if (data != null) + { + return new + { + success = true, + message = message, + data = data, + }; + } + else + { + return new { success = true, message = message }; + } + } + + /// + /// Creates a standardized error response object. + /// + /// A message describing the error. + /// Optional additional data (e.g., error details) to include. + /// An object representing the error response. + public static object Error(string errorCodeOrMessage, object data = null) + { + if (data != null) + { + // Note: The key is "error" for error messages, not "message" + return new + { + success = false, + // Preserve original behavior while adding a machine-parsable code field. + // If callers pass a code string, it will be echoed in both code and error. + code = errorCodeOrMessage, + error = errorCodeOrMessage, + data = data, + }; + } + else + { + return new { success = false, code = errorCodeOrMessage, error = errorCodeOrMessage }; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/Response.cs.meta b/MCPForUnity/Editor/Helpers/Response.cs.meta new file mode 100644 index 00000000..6fd11e39 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Response.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80c09a76b944f8c4691e06c4d76c4be8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs b/MCPForUnity/Editor/Helpers/ServerInstaller.cs new file mode 100644 index 00000000..f41e03c3 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs @@ -0,0 +1,700 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ServerInstaller + { + private const string RootFolder = "UnityMCP"; + private const string ServerFolder = "UnityMcpServer"; + private const string VersionFileName = "server_version.txt"; + + /// + /// Ensures the mcp-for-unity-server is installed locally by copying from the embedded package source. + /// No network calls or Git operations are performed. + /// + public static void EnsureServerInstalled() + { + try + { + string saveLocation = GetSaveLocation(); + TryCreateMacSymlinkForAppSupport(); + string destRoot = Path.Combine(saveLocation, ServerFolder); + string destSrc = Path.Combine(destRoot, "src"); + + // Detect legacy installs and version state (logs) + DetectAndLogLegacyInstallStates(destRoot); + + // Resolve embedded source and versions + if (!TryGetEmbeddedServerSource(out string embeddedSrc)) + { + throw new Exception("Could not find embedded UnityMcpServer/src in the package."); + } + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + string installedVer = ReadVersionFile(Path.Combine(destSrc, VersionFileName)); + + bool destHasServer = File.Exists(Path.Combine(destSrc, "server.py")); + bool needOverwrite = !destHasServer + || string.IsNullOrEmpty(installedVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0); + + // Ensure destination exists + Directory.CreateDirectory(destRoot); + + if (needOverwrite) + { + // Copy the entire UnityMcpServer folder (parent of src) + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; // go up from src to UnityMcpServer + CopyDirectoryRecursive(embeddedRoot, destRoot); + // Write/refresh version file + try { File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer ?? "unknown"); } catch { } + McpLog.Info($"Installed/updated server to {destRoot} (version {embeddedVer})."); + } + + // Cleanup legacy installs that are missing version or older than embedded + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + try + { + string legacySrc = Path.Combine(legacyRoot, "src"); + if (!File.Exists(Path.Combine(legacySrc, "server.py"))) continue; + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + bool legacyOlder = string.IsNullOrEmpty(legacyVer) + || (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0); + if (legacyOlder) + { + TryKillUvForPath(legacySrc); + try + { + Directory.Delete(legacyRoot, recursive: true); + McpLog.Info($"Removed legacy server at '{legacyRoot}'."); + } + catch (Exception ex) + { + McpLog.Warn($"Failed to remove legacy server at '{legacyRoot}': {ex.Message}"); + } + } + } + catch { } + } + + // Clear overrides that might point at legacy locations + try + { + EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); + EditorPrefs.DeleteKey("MCPForUnity.PythonDirOverride"); + } + catch { } + return; + } + catch (Exception ex) + { + // If a usable server is already present (installed or embedded), don't fail hard—just warn. + bool hasInstalled = false; + try { hasInstalled = File.Exists(Path.Combine(GetServerPath(), "server.py")); } catch { } + + if (hasInstalled || TryGetEmbeddedServerSource(out _)) + { + McpLog.Warn($"Using existing server; skipped install. Details: {ex.Message}"); + return; + } + + McpLog.Error($"Failed to ensure server installation: {ex.Message}"); + } + } + + public static string GetServerPath() + { + return Path.Combine(GetSaveLocation(), ServerFolder, "src"); + } + + /// + /// Gets the platform-specific save location for the server. + /// + private static string GetSaveLocation() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Use per-user LocalApplicationData for canonical install location + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, "AppData", "Local"); + return Path.Combine(localAppData, RootFolder); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrEmpty(xdg)) + { + xdg = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty, + ".local", "share"); + } + return Path.Combine(xdg, RootFolder); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // On macOS, use LocalApplicationData (~/Library/Application Support) + var localAppSupport = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + // Unity/Mono may map LocalApplicationData to ~/.local/share on macOS; normalize to Application Support + bool looksLikeXdg = !string.IsNullOrEmpty(localAppSupport) && localAppSupport.Replace('\\', '/').Contains("/.local/share"); + if (string.IsNullOrEmpty(localAppSupport) || looksLikeXdg) + { + // Fallback: construct from $HOME + var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + localAppSupport = Path.Combine(home, "Library", "Application Support"); + } + TryCreateMacSymlinkForAppSupport(); + return Path.Combine(localAppSupport, RootFolder); + } + throw new Exception("Unsupported operating system."); + } + + /// + /// On macOS, create a no-spaces symlink ~/Library/AppSupport -> ~/Library/Application Support + /// to mitigate arg parsing and quoting issues in some MCP clients. + /// Safe to call repeatedly. + /// + private static void TryCreateMacSymlinkForAppSupport() + { + try + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return; + string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; + if (string.IsNullOrEmpty(home)) return; + + string canonical = Path.Combine(home, "Library", "Application Support"); + string symlink = Path.Combine(home, "Library", "AppSupport"); + + // If symlink exists already, nothing to do + if (Directory.Exists(symlink) || File.Exists(symlink)) return; + + // Create symlink only if canonical exists + if (!Directory.Exists(canonical)) return; + + // Use 'ln -s' to create a directory symlink (macOS) + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/ln", + Arguments = $"-s \"{canonical}\" \"{symlink}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + p?.WaitForExit(2000); + } + catch { /* best-effort */ } + } + + private static bool IsDirectoryWritable(string path) + { + try + { + File.Create(Path.Combine(path, "test.txt")).Dispose(); + File.Delete(Path.Combine(path, "test.txt")); + return true; + } + catch + { + return false; + } + } + + /// + /// Checks if the server is installed at the specified location. + /// + private static bool IsServerInstalled(string location) + { + return Directory.Exists(location) + && File.Exists(Path.Combine(location, ServerFolder, "src", "server.py")); + } + + /// + /// Detects legacy installs or older versions and logs findings (no deletion yet). + /// + private static void DetectAndLogLegacyInstallStates(string canonicalRoot) + { + try + { + string canonicalSrc = Path.Combine(canonicalRoot, "src"); + // Normalize canonical root for comparisons + string normCanonicalRoot = NormalizePathSafe(canonicalRoot); + string embeddedSrc = null; + TryGetEmbeddedServerSource(out embeddedSrc); + + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc ?? string.Empty, VersionFileName)); + string installedVer = ReadVersionFile(Path.Combine(canonicalSrc, VersionFileName)); + + // Legacy paths (macOS/Linux .config; Windows roaming as example) + foreach (var legacyRoot in GetLegacyRootsForDetection()) + { + // Skip logging for the canonical root itself + if (PathsEqualSafe(legacyRoot, normCanonicalRoot)) + continue; + string legacySrc = Path.Combine(legacyRoot, "src"); + bool hasServer = File.Exists(Path.Combine(legacySrc, "server.py")); + string legacyVer = ReadVersionFile(Path.Combine(legacySrc, VersionFileName)); + + if (hasServer) + { + // Case 1: No version file + if (string.IsNullOrEmpty(legacyVer)) + { + McpLog.Info("Detected legacy install without version file at: " + legacyRoot, always: false); + } + + // Case 2: Lives in legacy path + McpLog.Info("Detected legacy install path: " + legacyRoot, always: false); + + // Case 3: Has version but appears older than embedded + if (!string.IsNullOrEmpty(embeddedVer) && !string.IsNullOrEmpty(legacyVer) && CompareSemverSafe(legacyVer, embeddedVer) < 0) + { + McpLog.Info($"Legacy install version {legacyVer} is older than embedded {embeddedVer}", always: false); + } + } + } + + // Also log if canonical is missing version (treated as older) + if (Directory.Exists(canonicalRoot)) + { + if (string.IsNullOrEmpty(installedVer)) + { + McpLog.Info("Canonical install missing version file (treat as older). Path: " + canonicalRoot, always: false); + } + else if (!string.IsNullOrEmpty(embeddedVer) && CompareSemverSafe(installedVer, embeddedVer) < 0) + { + McpLog.Info($"Canonical install version {installedVer} is older than embedded {embeddedVer}", always: false); + } + } + } + catch (Exception ex) + { + McpLog.Warn("Detect legacy/version state failed: " + ex.Message); + } + } + + private static string NormalizePathSafe(string path) + { + try { return string.IsNullOrEmpty(path) ? path : Path.GetFullPath(path.Trim()); } + catch { return path; } + } + + private static bool PathsEqualSafe(string a, string b) + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; + string na = NormalizePathSafe(a); + string nb = NormalizePathSafe(b); + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + return string.Equals(na, nb, StringComparison.Ordinal); + } + catch { return false; } + } + + private static IEnumerable GetLegacyRootsForDetection() + { + var roots = new System.Collections.Generic.List(); + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + // macOS/Linux legacy + roots.Add(Path.Combine(home, ".config", "UnityMCP", "UnityMcpServer")); + roots.Add(Path.Combine(home, ".local", "share", "UnityMCP", "UnityMcpServer")); + // Windows roaming example + try + { + string roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + if (!string.IsNullOrEmpty(roaming)) + roots.Add(Path.Combine(roaming, "UnityMCP", "UnityMcpServer")); + // Windows legacy: early installers/dev scripts used %LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer + // Detect this location so we can clean up older copies during install/update. + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + if (!string.IsNullOrEmpty(localAppData)) + roots.Add(Path.Combine(localAppData, "Programs", "UnityMCP", "UnityMcpServer")); + } + catch { } + return roots; + } + + private static void TryKillUvForPath(string serverSrcPath) + { + try + { + if (string.IsNullOrEmpty(serverSrcPath)) return; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/pgrep", + Arguments = $"-f \"uv .*--directory {serverSrcPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return; + string outp = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1500); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + foreach (var line in outp.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(line.Trim(), out int pid)) + { + try { System.Diagnostics.Process.GetProcessById(pid).Kill(); } catch { } + } + } + } + } + catch { } + } + + private static string ReadVersionFile(string path) + { + try + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null; + string v = File.ReadAllText(path).Trim(); + return string.IsNullOrEmpty(v) ? null : v; + } + catch { return null; } + } + + private static int CompareSemverSafe(string a, string b) + { + try + { + if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; + var ap = a.Split('.'); + var bp = b.Split('.'); + for (int i = 0; i < Math.Max(ap.Length, bp.Length); i++) + { + int ai = (i < ap.Length && int.TryParse(ap[i], out var t1)) ? t1 : 0; + int bi = (i < bp.Length && int.TryParse(bp[i], out var t2)) ? t2 : 0; + if (ai != bi) return ai.CompareTo(bi); + } + return 0; + } + catch { return 0; } + } + + /// + /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package + /// or common development locations. + /// + private static bool TryGetEmbeddedServerSource(out string srcPath) + { + return ServerPathResolver.TryFindEmbeddedServerSource(out srcPath); + } + + private static readonly string[] _skipDirs = { ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".git" }; + private static void CopyDirectoryRecursive(string sourceDir, string destinationDir) + { + Directory.CreateDirectory(destinationDir); + + foreach (string filePath in Directory.GetFiles(sourceDir)) + { + string fileName = Path.GetFileName(filePath); + string destFile = Path.Combine(destinationDir, fileName); + File.Copy(filePath, destFile, overwrite: true); + } + + foreach (string dirPath in Directory.GetDirectories(sourceDir)) + { + string dirName = Path.GetFileName(dirPath); + foreach (var skip in _skipDirs) + { + if (dirName.Equals(skip, StringComparison.OrdinalIgnoreCase)) + goto NextDir; + } + try { if ((File.GetAttributes(dirPath) & FileAttributes.ReparsePoint) != 0) continue; } catch { } + string destSubDir = Path.Combine(destinationDir, dirName); + CopyDirectoryRecursive(dirPath, destSubDir); + NextDir:; + } + } + + public static bool RebuildMcpServer() + { + try + { + // Find embedded source + if (!TryGetEmbeddedServerSource(out string embeddedSrc)) + { + Debug.LogError("RebuildMcpServer: Could not find embedded server source."); + return false; + } + + string saveLocation = GetSaveLocation(); + string destRoot = Path.Combine(saveLocation, ServerFolder); + string destSrc = Path.Combine(destRoot, "src"); + + // Kill any running uv processes for this server + TryKillUvForPath(destSrc); + + // Delete the entire installed server directory + if (Directory.Exists(destRoot)) + { + try + { + Directory.Delete(destRoot, recursive: true); + Debug.Log($"MCP-FOR-UNITY: Deleted existing server at {destRoot}"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to delete existing server: {ex.Message}"); + return false; + } + } + + // Re-copy from embedded source + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; + Directory.CreateDirectory(destRoot); + CopyDirectoryRecursive(embeddedRoot, destRoot); + + // Write version file + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + try + { + File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to write version file: {ex.Message}"); + } + + Debug.Log($"MCP-FOR-UNITY: Server rebuilt successfully at {destRoot} (version {embeddedVer})"); + return true; + } + catch (Exception ex) + { + Debug.LogError($"RebuildMcpServer failed: {ex.Message}"); + return false; + } + } + + internal static string FindUvPath() + { + // Allow user override via EditorPrefs + try + { + string overridePath = EditorPrefs.GetString("MCPForUnity.UvPath", string.Empty); + if (!string.IsNullOrEmpty(overridePath) && File.Exists(overridePath)) + { + if (ValidateUvBinary(overridePath)) return overridePath; + } + } + catch { } + + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + + // Platform-specific candidate lists + string[] candidates; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles) ?? string.Empty; + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + + // Fast path: resolve from PATH first + try + { + var wherePsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "where", + Arguments = "uv.exe", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var wp = System.Diagnostics.Process.Start(wherePsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(1500); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + string path = line.Trim(); + if (File.Exists(path) && ValidateUvBinary(path)) return path; + } + } + } + catch { } + + // Windows Store (PythonSoftwareFoundation) install location probe + // Example: %LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.13_*\LocalCache\local-packages\Python313\Scripts\uv.exe + try + { + string pkgsRoot = Path.Combine(localAppData, "Packages"); + if (Directory.Exists(pkgsRoot)) + { + var pythonPkgs = Directory.GetDirectories(pkgsRoot, "PythonSoftwareFoundation.Python.*", SearchOption.TopDirectoryOnly) + .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase); + foreach (var pkg in pythonPkgs) + { + string localCache = Path.Combine(pkg, "LocalCache", "local-packages"); + if (!Directory.Exists(localCache)) continue; + var pyRoots = Directory.GetDirectories(localCache, "Python*", SearchOption.TopDirectoryOnly) + .OrderByDescending(d => d, StringComparer.OrdinalIgnoreCase); + foreach (var pyRoot in pyRoots) + { + string uvExe = Path.Combine(pyRoot, "Scripts", "uv.exe"); + if (File.Exists(uvExe) && ValidateUvBinary(uvExe)) return uvExe; + } + } + } + } + catch { } + + candidates = new[] + { + // Preferred: WinGet Links shims (stable entrypoints) + // Per-user shim (LOCALAPPDATA) → machine-wide shim (Program Files\WinGet\Links) + Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "uv.exe"), + Path.Combine(programFiles, "WinGet", "Links", "uv.exe"), + + // Common per-user installs + Path.Combine(localAppData, @"Programs\Python\Python313\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python312\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python311\Scripts\uv.exe"), + Path.Combine(localAppData, @"Programs\Python\Python310\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python313\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python312\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python311\Scripts\uv.exe"), + Path.Combine(appData, @"Python\Python310\Scripts\uv.exe"), + + // Program Files style installs (if a native installer was used) + Path.Combine(programFiles, @"uv\uv.exe"), + + // Try simple name resolution later via PATH + "uv.exe", + "uv" + }; + } + else + { + candidates = new[] + { + "/opt/homebrew/bin/uv", + "/usr/local/bin/uv", + "/usr/bin/uv", + "/opt/local/bin/uv", + Path.Combine(home, ".local", "bin", "uv"), + "/opt/homebrew/opt/uv/bin/uv", + // Framework Python installs + "/Library/Frameworks/Python.framework/Versions/3.13/bin/uv", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/uv", + // Fallback to PATH resolution by name + "uv" + }; + } + + foreach (string c in candidates) + { + try + { + if (File.Exists(c) && ValidateUvBinary(c)) return c; + } + catch { /* ignore */ } + } + + // Use platform-appropriate which/where to resolve from PATH (non-Windows handled here; Windows tried earlier) + try + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var whichPsi = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "uv", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + try + { + // Prepend common user-local and package manager locations so 'which' can see them in Unity's GUI env + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string prepend = string.Join(":", new[] + { + System.IO.Path.Combine(homeDir, ".local", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin" + }); + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + whichPsi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) ? prepend : (prepend + ":" + currentPath); + } + catch { } + using var wp = System.Diagnostics.Process.Start(whichPsi); + string output = wp.StandardOutput.ReadToEnd().Trim(); + wp.WaitForExit(3000); + if (wp.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + if (ValidateUvBinary(output)) return output; + } + } + } + catch { } + + // Manual PATH scan + try + { + string pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + string[] parts = pathEnv.Split(Path.PathSeparator); + foreach (string part in parts) + { + try + { + // Check both uv and uv.exe + string candidateUv = Path.Combine(part, "uv"); + string candidateUvExe = Path.Combine(part, "uv.exe"); + if (File.Exists(candidateUv) && ValidateUvBinary(candidateUv)) return candidateUv; + if (File.Exists(candidateUvExe) && ValidateUvBinary(candidateUvExe)) return candidateUvExe; + } + catch { } + } + } + catch { } + + return null; + } + + private static bool ValidateUvBinary(string uvPath) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (!p.WaitForExit(5000)) { try { p.Kill(); } catch { } return false; } + if (p.ExitCode == 0) + { + string output = p.StandardOutput.ReadToEnd().Trim(); + return output.StartsWith("uv "); + } + } + catch { } + return false; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta b/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta new file mode 100644 index 00000000..dfd9023b --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ServerInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5862c6a6d0a914f4d83224f8d039cf7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs new file mode 100644 index 00000000..0e462945 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + public static class ServerPathResolver + { + /// + /// Attempts to locate the embedded UnityMcpServer/src directory inside the installed package + /// or common development locations. Returns true if found and sets srcPath to the folder + /// containing server.py. + /// + public static bool TryFindEmbeddedServerSource(out string srcPath) + { + // 1) Repo development layouts commonly used alongside this package + try + { + string projectRoot = Path.GetDirectoryName(Application.dataPath); + string[] devCandidates = + { + Path.Combine(projectRoot ?? string.Empty, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(projectRoot ?? string.Empty, "..", "unity-mcp", "UnityMcpServer", "src"), + }; + foreach (string candidate in devCandidates) + { + string full = Path.GetFullPath(candidate); + if (Directory.Exists(full) && File.Exists(Path.Combine(full, "server.py"))) + { + srcPath = full; + return true; + } + } + } + catch { /* ignore */ } + + // 2) Resolve via local package info (no network). Fall back to Client.List on older editors. + try + { +#if UNITY_2021_2_OR_NEWER + // Primary: the package that owns this assembly + var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); + if (owner != null) + { + if (TryResolveWithinPackage(owner, out srcPath)) + { + return true; + } + } + + // Secondary: scan all registered packages locally + foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) + { + if (TryResolveWithinPackage(p, out srcPath)) + { + return true; + } + } +#else + // Older Unity versions: use Package Manager Client.List as a fallback + var list = UnityEditor.PackageManager.Client.List(); + while (!list.IsCompleted) { } + if (list.Status == UnityEditor.PackageManager.StatusCode.Success) + { + foreach (var pkg in list.Result) + { + if (TryResolveWithinPackage(pkg, out srcPath)) + { + return true; + } + } + } +#endif + } + catch { /* ignore */ } + + // 3) Fallback to previous common install locations + try + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + Path.Combine(home, "unity-mcp", "UnityMcpServer", "src"), + Path.Combine(home, "Applications", "UnityMCP", "UnityMcpServer", "src"), + }; + foreach (string candidate in candidates) + { + if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "server.py"))) + { + srcPath = candidate; + return true; + } + } + } + catch { /* ignore */ } + + srcPath = null; + return false; + } + + private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) + { + const string CurrentId = "com.coplaydev.unity-mcp"; + + srcPath = null; + if (p == null || p.name != CurrentId) + { + return false; + } + + string packagePath = p.resolvedPath; + + // Preferred tilde folder (embedded but excluded from import) + string embeddedTilde = Path.Combine(packagePath, "UnityMcpServer~", "src"); + if (Directory.Exists(embeddedTilde) && File.Exists(Path.Combine(embeddedTilde, "server.py"))) + { + srcPath = embeddedTilde; + return true; + } + + // Legacy non-tilde folder + string embedded = Path.Combine(packagePath, "UnityMcpServer", "src"); + if (Directory.Exists(embedded) && File.Exists(Path.Combine(embedded, "server.py"))) + { + srcPath = embedded; + return true; + } + + // Dev-linked sibling of the package folder + string sibling = Path.Combine(Path.GetDirectoryName(packagePath) ?? string.Empty, "UnityMcpServer", "src"); + if (Directory.Exists(sibling) && File.Exists(Path.Combine(sibling, "server.py"))) + { + srcPath = sibling; + return true; + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta new file mode 100644 index 00000000..d02df608 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/ServerPathResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs new file mode 100644 index 00000000..6440a675 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Unity Bridge telemetry helper for collecting usage analytics + /// Following privacy-first approach with easy opt-out mechanisms + /// + 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) + /// + public static bool IsEnabled + { + get + { + // Check environment variables first + var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(envDisable) && + (envDisable.ToLower() == "true" || envDisable == "1")) + { + return false; + } + + var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(unityMcpDisable) && + (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) + { + return false; + } + + // Honor protocol-wide opt-out as well + var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(mcpDisable) && + (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) + { + return false; + } + + // Check EditorPrefs + return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); + } + } + + /// + /// Get or generate customer UUID for anonymous tracking + /// + public static string GetCustomerUUID() + { + var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); + if (string.IsNullOrEmpty(uuid)) + { + uuid = System.Guid.NewGuid().ToString(); + UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); + } + return uuid; + } + + /// + /// Disable telemetry (stored in EditorPrefs) + /// + public static void DisableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); + } + + /// + /// Enable telemetry (stored in EditorPrefs) + /// + 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 + /// + public static void RecordEvent(string eventType, Dictionary data = null) + { + if (!IsEnabled) + return; + + try + { + var telemetryData = new Dictionary + { + ["event_type"] = eventType, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ["customer_uuid"] = GetCustomerUUID(), + ["unity_version"] = Application.unityVersion, + ["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); + } + catch (Exception e) + { + // Never let telemetry errors interfere with functionality + if (IsDebugEnabled()) + { + Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}"); + } + } + } + + /// + /// Allows the bridge to register a concrete sender for telemetry payloads. + /// + public static void RegisterTelemetrySender(Action> sender) + { + Interlocked.Exchange(ref s_sender, sender); + } + + public static void UnregisterTelemetrySender() + { + Interlocked.Exchange(ref s_sender, null); + } + + /// + /// Record bridge startup event + /// + public static void RecordBridgeStartup() + { + RecordEvent("bridge_startup", new Dictionary + { + ["bridge_version"] = "3.0.2", + ["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode() + }); + } + + /// + /// Record bridge connection event + /// + public static void RecordBridgeConnection(bool success, string error = null) + { + var data = new Dictionary + { + ["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 + /// + public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) + { + var data = new Dictionary + { + ["tool_name"] = toolName, + ["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); + if (sender != null) + { + try + { + sender(telemetryData); + return; + } + catch (Exception e) + { + if (IsDebugEnabled()) + { + Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}"); + } + } + } + + // Fallback: log when debug is enabled + if (IsDebugEnabled()) + { + Debug.Log($"MCP-TELEMETRY: {telemetryData["event_type"]}"); + } + } + + private static bool IsDebugEnabled() + { + try + { + return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + } + catch + { + return false; + } + } + } +} diff --git a/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta new file mode 100644 index 00000000..d7fd7b1f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/TelemetryHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Helpers/Vector3Helper.cs b/MCPForUnity/Editor/Helpers/Vector3Helper.cs new file mode 100644 index 00000000..41566188 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Vector3Helper.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Helper class for Vector3 operations + /// + public static class Vector3Helper + { + /// + /// Parses a JArray into a Vector3 + /// + /// The array containing x, y, z coordinates + /// A Vector3 with the parsed coordinates + /// Thrown when array is invalid + public static Vector3 ParseVector3(JArray array) + { + if (array == null || array.Count != 3) + throw new System.Exception("Vector3 must be an array of 3 floats [x, y, z]."); + return new Vector3((float)array[0], (float)array[1], (float)array[2]); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/Vector3Helper.cs.meta b/MCPForUnity/Editor/Helpers/Vector3Helper.cs.meta new file mode 100644 index 00000000..280381ca --- /dev/null +++ b/MCPForUnity/Editor/Helpers/Vector3Helper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f8514fd42f23cb641a36e52550825b35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef new file mode 100644 index 00000000..88448922 --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef @@ -0,0 +1,19 @@ +{ + "name": "MCPForUnity.Editor", + "rootNamespace": "MCPForUnity.Editor", + "references": [ + "MCPForUnity.Runtime", + "GUID:560b04d1a97f54a46a2660c3cc343a6f" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta new file mode 100644 index 00000000..b819bd4d --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnity.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 98f702da6ca044be59a864a9419c4eab +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs b/MCPForUnity/Editor/MCPForUnityBridge.cs new file mode 100644 index 00000000..dcc469b0 --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnityBridge.cs @@ -0,0 +1,1194 @@ +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.MenuItems; +using MCPForUnity.Editor.Tools.Prefabs; + +namespace MCPForUnity.Editor +{ + [InitializeOnLoad] + public static partial class MCPForUnityBridge + { + private static TcpListener listener; + private static bool isRunning = false; + private static readonly object lockObj = new(); + private static readonly object startStopLock = new(); + private static readonly object clientsLock = new(); + private static readonly System.Collections.Generic.HashSet activeClients = new(); + // Single-writer outbox for framed responses + private class Outbound + { + public byte[] Payload; + public string Tag; + public int? ReqId; + } + private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); + private static CancellationTokenSource cts; + private static Task listenerTask; + private static int processingCommands = 0; + private static bool initScheduled = false; + private static bool ensureUpdateHooked = false; + private static bool isStarting = false; + private static double nextStartAt = 0.0f; + private static double nextHeartbeatAt = 0.0f; + private static int heartbeatSeq = 0; + private static Dictionary< + string, + (string commandJson, TaskCompletionSource tcs) + > commandQueue = new(); + private static int mainThreadId; + private static int currentUnityPort = 6400; // Dynamic port, starts with default + private static bool isAutoConnectMode = false; + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads + private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients + + // IO diagnostics + private static long _ioSeq = 0; + private static void IoInfo(string s) { McpLog.Info(s, always: false); } + + // Debug helpers + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } + } + + private static void LogBreadcrumb(string stage) + { + if (IsDebugEnabled()) + { + McpLog.Info($"[{stage}]", always: false); + } + } + + public static bool IsRunning => isRunning; + public static int GetCurrentPort() => currentUnityPort; + public static bool IsAutoConnectMode() => isAutoConnectMode; + + /// + /// Start with Auto-Connect mode - discovers new port and saves it + /// + public static void StartAutoConnect() + { + Stop(); // Stop current connection + + try + { + // Prefer stored project port and start using the robust Start() path (with retries/options) + 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; + } + } + + public static bool FolderExists(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + string fullPath = Path.Combine( + Application.dataPath, + path.StartsWith("Assets/") ? path[7..] : path + ); + return Directory.Exists(fullPath); + } + + static MCPForUnityBridge() + { + // Record the main thread ID for safe thread checks + try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } + // Start single writer thread for framed responses + try + { + var writerThread = new Thread(() => + { + foreach (var item in _outbox.GetConsumingEnumerable()) + { + try + { + long seq = Interlocked.Increment(ref _ioSeq); + IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + // Note: We currently have a per-connection 'stream' in the client handler. For simplicity, + // writes are performed inline there. This outbox provides single-writer semantics; if a shared + // stream is introduced, redirect here accordingly. + // No-op: actual write happens in client loop using WriteFrameAsync + sw.Stop(); + IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}"); + } + catch (Exception ex) + { + IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}"); + } + } + }) + { IsBackground = true, Name = "MCP-Writer" }; + writerThread.Start(); + } + catch { } + + // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env + // CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode + if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) + { + return; + } + // Defer start until the editor is idle and not compiling + ScheduleInitRetry(); + // Add a safety net update hook in case delayCall is missed during reload churn + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + EditorApplication.quitting += Stop; + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + // Also coalesce play mode transitions into a deferred init + EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); + } + + /// + /// Initialize the MCP bridge after Unity is fully loaded and compilation is complete. + /// This prevents repeated restarts during script compilation that cause port hopping. + /// + private static void InitializeAfterCompilation() + { + initScheduled = false; + + // Play-mode friendly: allow starting in play mode; only defer while compiling + if (IsCompiling()) + { + ScheduleInitRetry(); + return; + } + + if (!isRunning) + { + Start(); + if (!isRunning) + { + // If a race prevented start, retry later + ScheduleInitRetry(); + } + } + } + + private static void ScheduleInitRetry() + { + if (initScheduled) + { + return; + } + initScheduled = true; + // Debounce: start ~200ms after the last trigger + nextStartAt = EditorApplication.timeSinceStartup + 0.20f; + // Ensure the update pump is active + if (!ensureUpdateHooked) + { + ensureUpdateHooked = true; + EditorApplication.update += EnsureStartedOnEditorIdle; + } + // Keep the original delayCall as a secondary path + EditorApplication.delayCall += InitializeAfterCompilation; + } + + // Safety net: ensure the bridge starts shortly after domain reload when editor is idle + private static void EnsureStartedOnEditorIdle() + { + // Do nothing while compiling + if (IsCompiling()) + { + return; + } + + // If already running, remove the hook + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + return; + } + + // Debounced start: wait until the scheduled time + if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) + { + return; + } + + if (isStarting) + { + return; + } + + isStarting = true; + try + { + // Attempt start; if it succeeds, remove the hook to avoid overhead + Start(); + } + finally + { + isStarting = false; + } + if (isRunning) + { + EditorApplication.update -= EnsureStartedOnEditorIdle; + ensureUpdateHooked = false; + } + } + + // Helper to check compilation status across Unity versions + private static bool IsCompiling() + { + if (EditorApplication.isCompiling) + { + return true; + } + try + { + System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) + { + return (bool)prop.GetValue(null); + } + } + catch { } + return false; + } + + public static void Start() + { + lock (startStopLock) + { + // Don't restart if already running on a working port + if (isRunning && listener != null) + { + if (IsDebugEnabled()) + { + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge already running on port {currentUnityPort}"); + } + return; + } + + Stop(); + + // Attempt fast bind with stored-port preference (sticky per-project) + try + { + // Always consult PortManager first so we prefer the persisted project port + currentUnityPort = PortManager.GetPortWithFallback(); + + // Breadcrumb: Start + LogBreadcrumb("Start"); + + const int maxImmediateRetries = 3; + const int retrySleepMs = 75; + int attempt = 0; + for (; ; ) + { + try + { + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif + // Minimize TIME_WAIT by sending RST on close + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + // Ignore if not supported on platform + } + listener.Start(); + break; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) + { + attempt++; + Thread.Sleep(retrySleepMs); + continue; + } + catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) + { + currentUnityPort = PortManager.GetPortWithFallback(); + listener = new TcpListener(IPAddress.Loopback, currentUnityPort); + listener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true + ); +#if UNITY_EDITOR_WIN + try + { + listener.ExclusiveAddressUse = false; + } + catch { } +#endif + try + { + listener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + } + listener.Start(); + break; + } + } + + isRunning = true; + isAutoConnectMode = false; + string platform = Application.platform.ToString(); + string serverVer = ReadInstalledServerVersionSafe(); + Debug.Log($"MCP-FOR-UNITY: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); + // Start background listener with cooperative cancellation + cts = new CancellationTokenSource(); + listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); + CommandRegistry.Initialize(); + EditorApplication.update += ProcessCommands; + // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain + try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } + try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { } + try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } + try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { } + try { EditorApplication.quitting -= Stop; } catch { } + try { EditorApplication.quitting += Stop; } catch { } + // Write initial heartbeat immediately + heartbeatSeq++; + WriteHeartbeat(false, "ready"); + nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; + } + catch (SocketException ex) + { + Debug.LogError($"Failed to start TCP listener: {ex.Message}"); + } + } + } + + public static void Stop() + { + Task toWait = null; + lock (startStopLock) + { + if (!isRunning) + { + return; + } + + try + { + // Mark as stopping early to avoid accept logging during disposal + isRunning = false; + + // Quiesce background listener quickly + var cancel = cts; + cts = null; + try { cancel?.Cancel(); } catch { } + + try { listener?.Stop(); } catch { } + listener = null; + + // Capture background task to wait briefly outside the lock + toWait = listenerTask; + listenerTask = null; + } + catch (Exception ex) + { + Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}"); + } + } + + // Proactively close all active client sockets to unblock any pending reads + TcpClient[] toClose; + lock (clientsLock) + { + toClose = activeClients.ToArray(); + activeClients.Clear(); + } + foreach (var c in toClose) + { + try { c.Close(); } catch { } + } + + // Give the background loop a short window to exit without blocking the editor + if (toWait != null) + { + try { toWait.Wait(100); } catch { } + } + + // Now unhook editor events safely + try { EditorApplication.update -= ProcessCommands; } catch { } + try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } + try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { } + try { EditorApplication.quitting -= Stop; } catch { } + + if (IsDebugEnabled()) Debug.Log("MCP-FOR-UNITY: MCPForUnityBridge stopped."); + } + + private static async Task ListenerLoopAsync(CancellationToken token) + { + while (isRunning && !token.IsCancellationRequested) + { + try + { + TcpClient client = await listener.AcceptTcpClientAsync(); + // Enable basic socket keepalive + client.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.KeepAlive, + true + ); + + // Set longer receive timeout to prevent quick disconnections + client.ReceiveTimeout = 60000; // 60 seconds + + // Fire and forget each client connection + _ = Task.Run(() => HandleClientAsync(client, token), token); + } + catch (ObjectDisposedException) + { + // Listener was disposed during stop/reload; exit quietly + if (!isRunning || token.IsCancellationRequested) + { + break; + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + if (isRunning && !token.IsCancellationRequested) + { + if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); + } + } + } + } + + private static async Task HandleClientAsync(TcpClient client, CancellationToken token) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + lock (clientsLock) { activeClients.Add(client); } + try + { + // Framed I/O only; legacy mode removed + try + { + 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); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); +#else + await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); +#endif + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); + } + catch (Exception ex) + { + if (IsDebugEnabled()) MCPForUnity.Editor.Helpers.McpLog.Warn($"Handshake failed: {ex.Message}"); + return; // abort this client + } + + while (isRunning && !token.IsCancellationRequested) + { + try + { + // Strict framed mode only: enforced framed I/O for this connection + string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); + + try + { + if (IsDebugEnabled()) + { + var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; + MCPForUnity.Editor.Helpers.McpLog.Info($"recv framed: {preview}", always: false); + } + } + catch { } + string commandId = Guid.NewGuid().ToString(); + 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; + } + + 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) + { + // 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); + } + } + 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; + } + + 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) + { + // 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; + } + } + } + finally + { + lock (clientsLock) { activeClients.Remove(client); } + } + } + } + + // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks + private static async System.Threading.Tasks.Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + { + byte[] buffer = new byte[count]; + int offset = 0; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (offset < count) + { + int remaining = count - offset; + int remainingTimeout = timeoutMs <= 0 + ? Timeout.Infinite + : timeoutMs - (int)stopwatch.ElapsedMilliseconds; + + // If a finite timeout is configured and already elapsed, fail immediately + if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) + { + throw new System.IO.IOException("Read timed out"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); + if (remainingTimeout != Timeout.Infinite) + { + cts.CancelAfter(remainingTimeout); + } + + try + { +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); +#endif + if (read == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += read; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + throw new System.IO.IOException("Read timed out"); + } + } + + return buffer; + } + + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload) + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + await WriteFrameAsync(stream, payload, cts.Token); + } + + private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + { + if (payload == null) + { + throw new System.ArgumentNullException(nameof(payload)); + } + if ((ulong)payload.LongLength > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + } + byte[] header = new byte[8]; + WriteUInt64BigEndian(header, (ulong)payload.LongLength); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); + await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); +#else + await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); +#endif + } + + private static async System.Threading.Tasks.Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) + { + byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen > MaxFrameBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + if (payloadLen == 0UL) + throw new System.IO.IOException("Zero-length frames are not allowed"); + if (payloadLen > int.MaxValue) + { + throw new System.IO.IOException("Frame too large for buffer"); + } + int count = (int)payloadLen; + byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); + return System.Text.Encoding.UTF8.GetString(payload); + } + + private static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + private static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new System.ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + + private static void ProcessCommands() + { + if (!isRunning) return; + if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard + try + { + // Heartbeat without holding the queue lock + double now = EditorApplication.timeSinceStartup; + if (now >= nextHeartbeatAt) + { + 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(); + } + + foreach (var item in work) + { + string id = item.id; + string commandText = item.text; + TaskCompletionSource tcs = item.tcs; + + try + { + // Special case handling + if (string.IsNullOrEmpty(commandText)) + { + 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(); + + // Non-JSON direct commands handling (like ping) + if (commandText == "ping") + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; + tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + // Check if the command is valid JSON before attempting to deserialize + if (!IsValidJson(commandText)) + { + var invalidJsonResponse = new + { + status = "error", + error = "Invalid JSON format", + receivedText = commandText.Length > 50 + ? commandText[..50] + "..." + : commandText, + }; + tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); + lock (lockObj) { commandQueue.Remove(id); } + continue; + } + + // Normal JSON command processing + Command command = JsonConvert.DeserializeObject(commandText); + + if (command == null) + { + var nullCommandResponse = new + { + status = "error", + error = "Command deserialized to null", + details = "The command was valid JSON but could not be deserialized to a Command object", + }; + tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); + } + else + { + string responseJson = ExecuteCommand(command); + tcs.SetResult(responseJson); + } + } + catch (Exception ex) + { + Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); + + var response = new + { + status = "error", + error = ex.Message, + commandType = "Unknown (error during processing)", + receivedText = commandText?.Length > 50 + ? commandText[..50] + "..." + : commandText, + }; + string responseJson = JsonConvert.SerializeObject(response); + tcs.SetResult(responseJson); + } + + // Remove quickly under lock + lock (lockObj) { commandQueue.Remove(id); } + } + } + finally + { + Interlocked.Exchange(ref processingCommands, 0); + } + } + + // Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. + // Returns null on timeout or error; caller should provide a fallback error response. + private static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) + { + if (func == null) return null; + try + { + // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. + if (mainThreadId == 0) + { + try { return func(); } + catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } + } + // If we are already on the main thread, execute directly to avoid deadlocks + try + { + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + return func(); + } + } + catch { } + + object result = null; + Exception captured = null; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EditorApplication.delayCall += () => + { + try + { + result = func(); + } + catch (Exception ex) + { + captured = ex; + } + finally + { + try { tcs.TrySetResult(true); } catch { } + } + }; + + // Wait for completion with timeout (Editor thread will pump delayCall) + bool completed = tcs.Task.Wait(timeoutMs); + if (!completed) + { + return null; // timeout + } + if (captured != null) + { + throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); + } + return result; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); + } + } + + // Helper method to check if a string is valid JSON + private static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ( + (text.StartsWith("{") && text.EndsWith("}")) + || // Object + (text.StartsWith("[") && text.EndsWith("]")) + ) // Array + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + + private static string ExecuteCommand(Command command) + { + try + { + if (string.IsNullOrEmpty(command.type)) + { + var errorResponse = new + { + status = "error", + error = "Command type cannot be empty", + details = "A valid command type is required for processing", + }; + return JsonConvert.SerializeObject(errorResponse); + } + + // Handle ping command for connection verification + if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) + { + var pingResponse = new + { + status = "success", + result = new { message = "pong" }, + }; + return JsonConvert.SerializeObject(pingResponse); + } + + // Use JObject for parameters as the new handlers likely expect this + JObject paramsObject = command.@params ?? new JObject(); + object result = CommandRegistry.GetHandler(command.type)(paramsObject); + + // Standard success response format + var response = new { status = "success", result }; + return JsonConvert.SerializeObject(response); + } + catch (Exception ex) + { + // Log the detailed error in Unity for debugging + Debug.LogError( + $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}" + ); + + // Standard error response format + var response = new + { + status = "error", + error = ex.Message, // Provide the specific error message + command = command?.type ?? "Unknown", // Include the command type if available + stackTrace = ex.StackTrace, // Include stack trace for detailed debugging + paramsSummary = command?.@params != null + ? GetParamsSummary(command.@params) + : "No parameters", // Summarize parameters for context + }; + return JsonConvert.SerializeObject(response); + } + } + + private static object HandleManageScene(JObject paramsObject) + { + try + { + if (IsDebugEnabled()) Debug.Log("[MCP] manage_scene: dispatching to main thread"); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var r = InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); + sw.Stop(); + if (IsDebugEnabled()) Debug.Log($"[MCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); + return r ?? Response.Error("manage_scene returned null (timeout or error)"); + } + catch (Exception ex) + { + return Response.Error($"manage_scene dispatch error: {ex.Message}"); + } + } + + // Helper method to get a summary of parameters for error reporting + private static string GetParamsSummary(JObject @params) + { + try + { + return @params == null || !@params.HasValues + ? "No parameters" + : string.Join( + ", ", + @params + .Properties() + .Select(static p => + $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" + ) + ); + } + catch + { + return "Could not summarize parameters"; + } + } + + // Heartbeat/status helpers + private static void OnBeforeAssemblyReload() + { + // Stop cleanly before reload so sockets close and clients see 'reloading' + try { Stop(); } catch { } + // Avoid file I/O or heavy work here + } + + private static void OnAfterAssemblyReload() + { + // Will be overwritten by Start(), but mark as alive quickly + WriteHeartbeat(false, "idle"); + LogBreadcrumb("Idle"); + // Schedule a safe restart after reload to avoid races during compilation + ScheduleInitRetry(); + } + + private static void WriteHeartbeat(bool reloading, string reason = null) + { + try + { + // Allow override of status directory (useful in CI/containers) + string dir = Environment.GetEnvironmentVariable("UNITY_MCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(dir)) + { + dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); + } + Directory.CreateDirectory(dir); + string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + var payload = new + { + unity_port = currentUnityPort, + reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, + project_path = Application.dataPath, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); + } + catch (Exception) + { + // Best-effort only + } + } + + private static string ReadInstalledServerVersionSafe() + { + try + { + string serverSrc = ServerInstaller.GetServerPath(); + string verFile = Path.Combine(serverSrc, "server_version.txt"); + if (File.Exists(verFile)) + { + string v = File.ReadAllText(verFile)?.Trim(); + if (!string.IsNullOrEmpty(v)) return v; + } + } + catch { } + return "unknown"; + } + + private static string ComputeProjectHash(string input) + { + try + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new System.Text.StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; + } + catch + { + return "default"; + } + } + } +} diff --git a/MCPForUnity/Editor/MCPForUnityBridge.cs.meta b/MCPForUnity/Editor/MCPForUnityBridge.cs.meta new file mode 100644 index 00000000..f8d1f46e --- /dev/null +++ b/MCPForUnity/Editor/MCPForUnityBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 96dc847eb7f7a45e0b91241db934a4be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models.meta b/MCPForUnity/Editor/Models.meta new file mode 100644 index 00000000..85404561 --- /dev/null +++ b/MCPForUnity/Editor/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 16d3ab36890b6c14f9afeabee30e03e3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/Command.cs b/MCPForUnity/Editor/Models/Command.cs new file mode 100644 index 00000000..02a89d88 --- /dev/null +++ b/MCPForUnity/Editor/Models/Command.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Models +{ + /// + /// Represents a command received from the MCP client + /// + public class Command + { + /// + /// The type of command to execute + /// + public string type { get; set; } + + /// + /// The parameters for the command + /// + public JObject @params { get; set; } + } +} + diff --git a/MCPForUnity/Editor/Models/Command.cs.meta b/MCPForUnity/Editor/Models/Command.cs.meta new file mode 100644 index 00000000..63618f53 --- /dev/null +++ b/MCPForUnity/Editor/Models/Command.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6754c84e5deb74749bc3a19e0c9aa280 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/MCPConfigServer.cs b/MCPForUnity/Editor/Models/MCPConfigServer.cs new file mode 100644 index 00000000..fbffed37 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServer.cs @@ -0,0 +1,19 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServer + { + [JsonProperty("command")] + public string command; + + [JsonProperty("args")] + public string[] args; + + // VSCode expects a transport type; include only when explicitly set + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] + public string type; + } +} diff --git a/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta b/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta new file mode 100644 index 00000000..0574c5a6 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5fae9d995f514e9498e9613e2cdbeca9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/MCPConfigServers.cs b/MCPForUnity/Editor/Models/MCPConfigServers.cs new file mode 100644 index 00000000..d5065a16 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServers.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfigServers + { + [JsonProperty("unityMCP")] + public McpConfigServer unityMCP; + } +} diff --git a/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta b/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta new file mode 100644 index 00000000..1fb5f0b2 --- /dev/null +++ b/MCPForUnity/Editor/Models/MCPConfigServers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcb583553e8173b49be71a5c43bd9502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpClient.cs b/MCPForUnity/Editor/Models/McpClient.cs new file mode 100644 index 00000000..a32f7f59 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpClient.cs @@ -0,0 +1,47 @@ +namespace MCPForUnity.Editor.Models +{ + public class McpClient + { + public string name; + public string windowsConfigPath; + public string macConfigPath; + public string linuxConfigPath; + public McpTypes mcpType; + public string configStatus; + public McpStatus status = McpStatus.NotConfigured; + + // Helper method to convert the enum to a display string + public string GetStatusDisplayString() + { + return status switch + { + McpStatus.NotConfigured => "Not Configured", + McpStatus.Configured => "Configured", + McpStatus.Running => "Running", + McpStatus.Connected => "Connected", + McpStatus.IncorrectPath => "Incorrect Path", + McpStatus.CommunicationError => "Communication Error", + McpStatus.NoResponse => "No Response", + McpStatus.UnsupportedOS => "Unsupported OS", + McpStatus.MissingConfig => "Missing MCPForUnity Config", + McpStatus.Error => configStatus.StartsWith("Error:") ? configStatus : "Error", + _ => "Unknown", + }; + } + + // Helper method to set both status enum and string for backward compatibility + public void SetStatus(McpStatus newStatus, string errorDetails = null) + { + status = newStatus; + + if (newStatus == McpStatus.Error && !string.IsNullOrEmpty(errorDetails)) + { + configStatus = $"Error: {errorDetails}"; + } + else + { + configStatus = GetStatusDisplayString(); + } + } + } +} diff --git a/MCPForUnity/Editor/Models/McpClient.cs.meta b/MCPForUnity/Editor/Models/McpClient.cs.meta new file mode 100644 index 00000000..b08dcf3b --- /dev/null +++ b/MCPForUnity/Editor/Models/McpClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1afa56984aec0d41808edcebf805e6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpConfig.cs b/MCPForUnity/Editor/Models/McpConfig.cs new file mode 100644 index 00000000..9ddf9d09 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpConfig.cs @@ -0,0 +1,12 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class McpConfig + { + [JsonProperty("mcpServers")] + public McpConfigServers mcpServers; + } +} diff --git a/MCPForUnity/Editor/Models/McpConfig.cs.meta b/MCPForUnity/Editor/Models/McpConfig.cs.meta new file mode 100644 index 00000000..2a407c31 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c17c09908f0c1524daa8b6957ce1f7f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpStatus.cs b/MCPForUnity/Editor/Models/McpStatus.cs new file mode 100644 index 00000000..d041667d --- /dev/null +++ b/MCPForUnity/Editor/Models/McpStatus.cs @@ -0,0 +1,18 @@ +namespace MCPForUnity.Editor.Models +{ + // Enum representing the various status states for MCP clients + public enum McpStatus + { + NotConfigured, // Not set up yet + Configured, // Successfully configured + Running, // Service is running + Connected, // Successfully connected + IncorrectPath, // Configuration has incorrect paths + CommunicationError, // Connected but communication issues + NoResponse, // Connected but not responding + MissingConfig, // Config file exists but missing required elements + UnsupportedOS, // OS is not supported + Error, // General error state + } +} + diff --git a/MCPForUnity/Editor/Models/McpStatus.cs.meta b/MCPForUnity/Editor/Models/McpStatus.cs.meta new file mode 100644 index 00000000..e8e930d3 --- /dev/null +++ b/MCPForUnity/Editor/Models/McpStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa63057c9e5282d4887352578bf49971 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/McpTypes.cs b/MCPForUnity/Editor/Models/McpTypes.cs new file mode 100644 index 00000000..a5a03dec --- /dev/null +++ b/MCPForUnity/Editor/Models/McpTypes.cs @@ -0,0 +1,13 @@ +namespace MCPForUnity.Editor.Models +{ + public enum McpTypes + { + ClaudeCode, + ClaudeDesktop, + Codex, + Cursor, + Kiro, + VSCode, + Windsurf, + } +} diff --git a/MCPForUnity/Editor/Models/McpTypes.cs.meta b/MCPForUnity/Editor/Models/McpTypes.cs.meta new file mode 100644 index 00000000..377a6d0b --- /dev/null +++ b/MCPForUnity/Editor/Models/McpTypes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ca97c5ff5ed74c4fbb65cfa9d2bfed1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Models/ServerConfig.cs b/MCPForUnity/Editor/Models/ServerConfig.cs new file mode 100644 index 00000000..4b185f1f --- /dev/null +++ b/MCPForUnity/Editor/Models/ServerConfig.cs @@ -0,0 +1,36 @@ +using System; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Models +{ + [Serializable] + public class ServerConfig + { + [JsonProperty("unity_host")] + public string unityHost = "localhost"; + + [JsonProperty("unity_port")] + public int unityPort; + + [JsonProperty("mcp_port")] + public int mcpPort; + + [JsonProperty("connection_timeout")] + public float connectionTimeout; + + [JsonProperty("buffer_size")] + public int bufferSize; + + [JsonProperty("log_level")] + public string logLevel; + + [JsonProperty("log_format")] + public string logFormat; + + [JsonProperty("max_retries")] + public int maxRetries; + + [JsonProperty("retry_delay")] + public float retryDelay; + } +} diff --git a/MCPForUnity/Editor/Models/ServerConfig.cs.meta b/MCPForUnity/Editor/Models/ServerConfig.cs.meta new file mode 100644 index 00000000..6e675e9e --- /dev/null +++ b/MCPForUnity/Editor/Models/ServerConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e4e45386fcc282249907c2e3c7e5d9c6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Setup.meta b/MCPForUnity/Editor/Setup.meta new file mode 100644 index 00000000..1157b1e9 --- /dev/null +++ b/MCPForUnity/Editor/Setup.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 600c9cb20c329d761bfa799158a87bac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs b/MCPForUnity/Editor/Setup/SetupWizard.cs new file mode 100644 index 00000000..a97926ea --- /dev/null +++ b/MCPForUnity/Editor/Setup/SetupWizard.cs @@ -0,0 +1,150 @@ +using System; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Handles automatic triggering of the setup wizard + /// + [InitializeOnLoad] + public static class SetupWizard + { + private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; + private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; + private static bool _hasCheckedThisSession = false; + + static SetupWizard() + { + // Skip in batch mode + if (Application.isBatchMode) + return; + + // Show setup wizard on package import + EditorApplication.delayCall += CheckSetupNeeded; + } + + /// + /// Check if setup wizard should be shown + /// + private static void CheckSetupNeeded() + { + if (_hasCheckedThisSession) + return; + + _hasCheckedThisSession = true; + + try + { + // Check if setup was already completed or dismissed in previous sessions + bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); + bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); + + // Only show setup wizard if it hasn't been completed or dismissed before + if (!(setupCompleted || setupDismissed)) + { + McpLog.Info("Package imported - showing setup wizard", always: false); + + var dependencyResult = DependencyManager.CheckAllDependencies(); + EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); + } + else + { + McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); + } + } + catch (Exception ex) + { + McpLog.Error($"Error checking setup status: {ex.Message}"); + } + } + + /// + /// Show the setup wizard window + /// + public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) + { + try + { + dependencyResult ??= DependencyManager.CheckAllDependencies(); + SetupWizardWindow.ShowWindow(dependencyResult); + } + catch (Exception ex) + { + McpLog.Error($"Error showing setup wizard: {ex.Message}"); + } + } + + /// + /// Mark setup as completed + /// + public static void MarkSetupCompleted() + { + EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); + McpLog.Info("Setup marked as completed"); + } + + /// + /// Mark setup as dismissed + /// + public static void MarkSetupDismissed() + { + EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); + McpLog.Info("Setup marked as dismissed"); + } + + /// + /// Force show setup wizard (for manual invocation) + /// + [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] + public static void ShowSetupWizardManual() + { + ShowSetupWizard(); + } + + /// + /// Check dependencies and show status + /// + [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] + public static void CheckDependencies() + { + var result = DependencyManager.CheckAllDependencies(); + + if (!result.IsSystemReady) + { + bool showWizard = EditorUtility.DisplayDialog( + "MCP for Unity - Dependencies", + $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", + "Open Setup Wizard", + "Close" + ); + + if (showWizard) + { + ShowSetupWizard(result); + } + } + else + { + EditorUtility.DisplayDialog( + "MCP for Unity - Dependencies", + "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", + "OK" + ); + } + } + + /// + /// Open MCP Client Configuration window + /// + [MenuItem("Window/MCP For Unity/Open MCP Window", priority = 4)] + public static void OpenClientConfiguration() + { + Windows.MCPForUnityEditorWindow.ShowWindow(); + } + } +} diff --git a/MCPForUnity/Editor/Setup/SetupWizard.cs.meta b/MCPForUnity/Editor/Setup/SetupWizard.cs.meta new file mode 100644 index 00000000..1a0e4e5f --- /dev/null +++ b/MCPForUnity/Editor/Setup/SetupWizard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 345678901234abcdef0123456789abcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs new file mode 100644 index 00000000..7229be97 --- /dev/null +++ b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs @@ -0,0 +1,726 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Setup wizard window for guiding users through dependency installation + /// + public class SetupWizardWindow : EditorWindow + { + private DependencyCheckResult _dependencyResult; + private Vector2 _scrollPosition; + private int _currentStep = 0; + private McpClients _mcpClients; + private int _selectedClientIndex = 0; + + private readonly string[] _stepTitles = { + "Setup", + "Configure", + "Complete" + }; + + public static void ShowWindow(DependencyCheckResult dependencyResult = null) + { + var window = GetWindow("MCP for Unity Setup"); + window.minSize = new Vector2(500, 400); + window.maxSize = new Vector2(800, 600); + window._dependencyResult = dependencyResult ?? DependencyManager.CheckAllDependencies(); + window.Show(); + } + + private void OnEnable() + { + if (_dependencyResult == null) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + + _mcpClients = new McpClients(); + + // Check client configurations on startup + foreach (var client in _mcpClients.clients) + { + CheckClientConfiguration(client); + } + } + + private void OnGUI() + { + DrawHeader(); + DrawProgressBar(); + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + switch (_currentStep) + { + case 0: DrawSetupStep(); break; + case 1: DrawConfigureStep(); break; + case 2: DrawCompleteStep(); break; + } + + EditorGUILayout.EndScrollView(); + + DrawFooter(); + } + + private void DrawHeader() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + GUILayout.Label("MCP for Unity Setup Wizard", EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + GUILayout.Label($"Step {_currentStep + 1} of {_stepTitles.Length}"); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // Step title + var titleStyle = new GUIStyle(EditorStyles.largeLabel) + { + fontSize = 16, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(_stepTitles[_currentStep], titleStyle); + EditorGUILayout.Space(); + } + + private void DrawProgressBar() + { + var rect = EditorGUILayout.GetControlRect(false, 4); + var progress = (_currentStep + 1) / (float)_stepTitles.Length; + EditorGUI.ProgressBar(rect, progress, ""); + EditorGUILayout.Space(); + } + + private void DrawSetupStep() + { + // Welcome section + DrawSectionTitle("MCP for Unity Setup"); + + EditorGUILayout.LabelField( + "This wizard will help you set up MCP for Unity to connect AI assistants with your Unity Editor.", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + // Dependency check section + EditorGUILayout.BeginHorizontal(); + DrawSectionTitle("System Check", 14); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Refresh", GUILayout.Width(60), GUILayout.Height(20))) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + EditorGUILayout.EndHorizontal(); + + // Show simplified dependency status + foreach (var dep in _dependencyResult.Dependencies) + { + DrawSimpleDependencyStatus(dep); + } + + // Overall status and installation guidance + EditorGUILayout.Space(); + if (!_dependencyResult.IsSystemReady) + { + // Only show critical warnings when dependencies are actually missing + EditorGUILayout.HelpBox( + "⚠️ Missing Dependencies: MCP for Unity requires Python 3.10+ and UV package manager to function properly.", + MessageType.Warning + ); + + EditorGUILayout.Space(); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + DrawErrorStatus("Installation Required"); + + var recommendations = DependencyManager.GetInstallationRecommendations(); + EditorGUILayout.LabelField(recommendations, EditorStyles.wordWrappedLabel); + + EditorGUILayout.Space(); + if (GUILayout.Button("Open Installation Links", GUILayout.Height(25))) + { + OpenInstallationUrls(); + } + EditorGUILayout.EndVertical(); + } + else + { + DrawSuccessStatus("System Ready"); + EditorGUILayout.LabelField("All requirements are met. You can proceed to configure your AI clients.", EditorStyles.wordWrappedLabel); + } + } + + + + private void DrawCompleteStep() + { + DrawSectionTitle("Setup Complete"); + + // Refresh dependency check with caching to avoid heavy operations on every repaint + if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + + if (_dependencyResult.IsSystemReady) + { + DrawSuccessStatus("MCP for Unity Ready!"); + + EditorGUILayout.HelpBox( + "🎉 MCP for Unity is now set up and ready to use!\n\n" + + "• Dependencies verified\n" + + "• MCP server ready\n" + + "• Client configuration accessible", + MessageType.Info + ); + + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Documentation", GUILayout.Height(30))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp"); + } + if (GUILayout.Button("Client Settings", GUILayout.Height(30))) + { + Windows.MCPForUnityEditorWindow.ShowWindow(); + } + EditorGUILayout.EndHorizontal(); + } + else + { + DrawErrorStatus("Setup Incomplete - Package Non-Functional"); + + EditorGUILayout.HelpBox( + "🚨 MCP for Unity CANNOT work - dependencies still missing!\n\n" + + "Install ALL required dependencies before the package will function.", + MessageType.Error + ); + + var missingDeps = _dependencyResult.GetMissingRequired(); + if (missingDeps.Count > 0) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Still Missing:", EditorStyles.boldLabel); + foreach (var dep in missingDeps) + { + EditorGUILayout.LabelField($"✗ {dep.Name}", EditorStyles.label); + } + } + + EditorGUILayout.Space(); + if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) + { + _currentStep = 0; + } + } + } + + // Helper methods for consistent UI components + private void DrawSectionTitle(string title, int fontSize = 16) + { + var titleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = fontSize, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(title, titleStyle); + EditorGUILayout.Space(); + } + + private void DrawSuccessStatus(string message) + { + var originalColor = GUI.color; + GUI.color = Color.green; + EditorGUILayout.LabelField($"✓ {message}", EditorStyles.boldLabel); + GUI.color = originalColor; + EditorGUILayout.Space(); + } + + private void DrawErrorStatus(string message) + { + var originalColor = GUI.color; + GUI.color = Color.red; + EditorGUILayout.LabelField($"✗ {message}", EditorStyles.boldLabel); + GUI.color = originalColor; + EditorGUILayout.Space(); + } + + private void DrawSimpleDependencyStatus(DependencyStatus dep) + { + EditorGUILayout.BeginHorizontal(); + + var statusIcon = dep.IsAvailable ? "✓" : "✗"; + var statusColor = dep.IsAvailable ? Color.green : Color.red; + + var originalColor = GUI.color; + GUI.color = statusColor; + GUILayout.Label(statusIcon, GUILayout.Width(20)); + EditorGUILayout.LabelField(dep.Name, EditorStyles.boldLabel); + GUI.color = originalColor; + + if (!dep.IsAvailable && !string.IsNullOrEmpty(dep.ErrorMessage)) + { + EditorGUILayout.LabelField($"({dep.ErrorMessage})", EditorStyles.miniLabel); + } + + EditorGUILayout.EndHorizontal(); + } + + private void DrawConfigureStep() + { + DrawSectionTitle("AI Client Configuration"); + + // Check dependencies first (with caching to avoid heavy operations on every repaint) + if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + if (!_dependencyResult.IsSystemReady) + { + DrawErrorStatus("Cannot Configure - System Requirements Not Met"); + + EditorGUILayout.HelpBox( + "Client configuration requires system dependencies to be installed first. Please complete setup before proceeding.", + MessageType.Warning + ); + + if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) + { + _currentStep = 0; + } + return; + } + + EditorGUILayout.LabelField( + "Configure your AI assistants to work with Unity. Select a client below to set it up:", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + // Client selection and configuration + if (_mcpClients.clients.Count > 0) + { + // Client selector dropdown + string[] clientNames = _mcpClients.clients.Select(c => c.name).ToArray(); + EditorGUI.BeginChangeCheck(); + _selectedClientIndex = EditorGUILayout.Popup("Select AI Client:", _selectedClientIndex, clientNames); + if (EditorGUI.EndChangeCheck()) + { + _selectedClientIndex = Mathf.Clamp(_selectedClientIndex, 0, _mcpClients.clients.Count - 1); + // Refresh client status when selection changes + CheckClientConfiguration(_mcpClients.clients[_selectedClientIndex]); + } + + EditorGUILayout.Space(); + + var selectedClient = _mcpClients.clients[_selectedClientIndex]; + DrawClientConfigurationInWizard(selectedClient); + + EditorGUILayout.Space(); + + // Batch configuration option + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("Quick Setup", EditorStyles.boldLabel); + EditorGUILayout.LabelField( + "Automatically configure all detected AI clients at once:", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + if (GUILayout.Button("Configure All Detected Clients", GUILayout.Height(30))) + { + ConfigureAllClientsInWizard(); + } + EditorGUILayout.EndVertical(); + } + else + { + EditorGUILayout.HelpBox("No AI clients detected. Make sure you have Claude Code, Cursor, or VSCode installed.", MessageType.Info); + } + + EditorGUILayout.Space(); + EditorGUILayout.HelpBox( + "💡 You might need to restart your AI client after configuring.", + MessageType.Info + ); + } + + private void DrawFooter() + { + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + + // Back button + GUI.enabled = _currentStep > 0; + if (GUILayout.Button("Back", GUILayout.Width(60))) + { + _currentStep--; + } + + GUILayout.FlexibleSpace(); + + // Skip button + if (GUILayout.Button("Skip", GUILayout.Width(60))) + { + bool dismiss = EditorUtility.DisplayDialog( + "Skip Setup", + "⚠️ Skipping setup will leave MCP for Unity non-functional!\n\n" + + "You can restart setup from: Window > MCP for Unity > Setup Wizard (Required)", + "Skip Anyway", + "Cancel" + ); + + if (dismiss) + { + SetupWizard.MarkSetupDismissed(); + Close(); + } + } + + // Next/Done button + GUI.enabled = true; + string buttonText = _currentStep == _stepTitles.Length - 1 ? "Done" : "Next"; + + if (GUILayout.Button(buttonText, GUILayout.Width(80))) + { + if (_currentStep == _stepTitles.Length - 1) + { + SetupWizard.MarkSetupCompleted(); + Close(); + } + else + { + _currentStep++; + } + } + + GUI.enabled = true; + EditorGUILayout.EndHorizontal(); + } + + private void DrawClientConfigurationInWizard(McpClient client) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"{client.name} Configuration", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // Show current status + var statusColor = GetClientStatusColor(client); + var originalColor = GUI.color; + GUI.color = statusColor; + EditorGUILayout.LabelField($"Status: {client.configStatus}", EditorStyles.label); + GUI.color = originalColor; + + EditorGUILayout.Space(); + + // Configuration buttons + EditorGUILayout.BeginHorizontal(); + + if (client.mcpType == McpTypes.ClaudeCode) + { + // Special handling for Claude Code + bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); + if (claudeAvailable) + { + bool isConfigured = client.status == McpStatus.Configured; + string buttonText = isConfigured ? "Unregister" : "Register"; + if (GUILayout.Button($"{buttonText} with Claude Code")) + { + if (isConfigured) + { + UnregisterFromClaudeCode(client); + } + else + { + RegisterWithClaudeCode(client); + } + } + } + else + { + EditorGUILayout.HelpBox("Claude Code not found. Please install Claude Code first.", MessageType.Warning); + if (GUILayout.Button("Open Claude Code Website")) + { + Application.OpenURL("https://claude.ai/download"); + } + } + } + else + { + // Standard client configuration + if (GUILayout.Button($"Configure {client.name}")) + { + ConfigureClientInWizard(client); + } + + if (GUILayout.Button("Manual Setup")) + { + ShowManualSetupInWizard(client); + } + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + + private Color GetClientStatusColor(McpClient client) + { + return client.status switch + { + McpStatus.Configured => Color.green, + McpStatus.Running => Color.green, + McpStatus.Connected => Color.green, + McpStatus.IncorrectPath => Color.yellow, + McpStatus.CommunicationError => Color.yellow, + McpStatus.NoResponse => Color.yellow, + _ => Color.red + }; + } + + private void ConfigureClientInWizard(McpClient client) + { + try + { + string result = PerformClientConfiguration(client); + + EditorUtility.DisplayDialog( + $"{client.name} Configuration", + result, + "OK" + ); + + // Refresh client status + CheckClientConfiguration(client); + Repaint(); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog( + "Configuration Error", + $"Failed to configure {client.name}: {ex.Message}", + "OK" + ); + } + } + + private void ConfigureAllClientsInWizard() + { + int successCount = 0; + int totalCount = _mcpClients.clients.Count; + + foreach (var client in _mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!string.IsNullOrEmpty(ExecPath.ResolveClaude()) && client.status != McpStatus.Configured) + { + RegisterWithClaudeCode(client); + successCount++; + } + else if (client.status == McpStatus.Configured) + { + successCount++; // Already configured + } + } + else + { + string result = PerformClientConfiguration(client); + if (result.Contains("success", System.StringComparison.OrdinalIgnoreCase)) + { + successCount++; + } + } + + CheckClientConfiguration(client); + } + catch (System.Exception ex) + { + McpLog.Error($"Failed to configure {client.name}: {ex.Message}"); + } + } + + EditorUtility.DisplayDialog( + "Batch Configuration Complete", + $"Successfully configured {successCount} out of {totalCount} clients.\n\n" + + "Restart your AI clients for changes to take effect.", + "OK" + ); + + Repaint(); + } + + private void RegisterWithClaudeCode(McpClient client) + { + try + { + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + string claudePath = ExecPath.ResolveClaude(); + string uvPath = ExecPath.ResolveUv() ?? "uv"; + + string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + + if (!ExecPath.TryRun(claudePath, args, null, out var stdout, out var stderr, 15000, McpPathResolver.GetPathPrepend())) + { + if ((stdout + stderr).Contains("already exists", System.StringComparison.OrdinalIgnoreCase)) + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "MCP for Unity is already registered with Claude Code.", "OK"); + } + else + { + throw new System.Exception($"Registration failed: {stderr}"); + } + } + else + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "Successfully registered MCP for Unity with Claude Code!", "OK"); + } + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Registration Error", $"Failed to register with Claude Code: {ex.Message}", "OK"); + } + } + + private void UnregisterFromClaudeCode(McpClient client) + { + try + { + string claudePath = ExecPath.ResolveClaude(); + if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", null, out var stdout, out var stderr, 10000, McpPathResolver.GetPathPrepend())) + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "Successfully unregistered MCP for Unity from Claude Code.", "OK"); + } + else + { + throw new System.Exception($"Unregistration failed: {stderr}"); + } + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Unregistration Error", $"Failed to unregister from Claude Code: {ex.Message}", "OK"); + } + } + + private string PerformClientConfiguration(McpClient client) + { + // This mirrors the logic from MCPForUnityEditorWindow.ConfigureMcpClient + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + + if (string.IsNullOrEmpty(pythonDir)) + { + return "Manual configuration required - Python server directory not found."; + } + + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + return McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + } + + private void ShowManualSetupInWizard(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + string uvPath = ServerInstaller.FindUvPath(); + + if (string.IsNullOrEmpty(uvPath)) + { + EditorUtility.DisplayDialog("Manual Setup", "UV package manager not found. Please install UV first.", "OK"); + return; + } + + // Build manual configuration using the sophisticated helper logic + string result = McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + string manualConfig; + + if (result == "Configured successfully") + { + // Read back the configuration that was written + try + { + manualConfig = System.IO.File.ReadAllText(configPath); + } + catch + { + manualConfig = "Configuration written successfully, but could not read back for display."; + } + } + else + { + manualConfig = $"Configuration failed: {result}"; + } + + EditorUtility.DisplayDialog( + $"Manual Setup - {client.name}", + $"Configuration file location:\n{configPath}\n\n" + + $"Configuration result:\n{manualConfig}", + "OK" + ); + } + + private void CheckClientConfiguration(McpClient client) + { + // Basic status check - could be enhanced to mirror MCPForUnityEditorWindow logic + try + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (System.IO.File.Exists(configPath)) + { + client.configStatus = "Configured"; + client.status = McpStatus.Configured; + } + else + { + client.configStatus = "Not Configured"; + client.status = McpStatus.NotConfigured; + } + } + catch + { + client.configStatus = "Error"; + client.status = McpStatus.Error; + } + } + + private void OpenInstallationUrls() + { + var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); + + bool openPython = EditorUtility.DisplayDialog( + "Open Installation URLs", + "Open Python installation page?", + "Yes", + "No" + ); + + if (openPython) + { + Application.OpenURL(pythonUrl); + } + + bool openUV = EditorUtility.DisplayDialog( + "Open Installation URLs", + "Open UV installation page?", + "Yes", + "No" + ); + + if (openUV) + { + Application.OpenURL(uvUrl); + } + } + } +} diff --git a/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta new file mode 100644 index 00000000..5361de3d --- /dev/null +++ b/MCPForUnity/Editor/Setup/SetupWizardWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45678901234abcdef0123456789abcde +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools.meta b/MCPForUnity/Editor/Tools.meta new file mode 100644 index 00000000..2bc55f08 --- /dev/null +++ b/MCPForUnity/Editor/Tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c97b83a6ac92a704b864eef27c3d285b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs new file mode 100644 index 00000000..79003d55 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Registry for all MCP command handlers via reflection. + /// + public static class CommandRegistry + { + private static readonly Dictionary> _handlers = new(); + private static bool _initialized = false; + + /// + /// Initialize and auto-discover all tools marked with [McpForUnityTool] + /// + public static void Initialize() + { + if (_initialized) return; + + AutoDiscoverTools(); + _initialized = true; + } + + /// + /// Convert PascalCase or camelCase to snake_case + /// + private static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) return name; + + // Insert underscore before uppercase letters (except first) + var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); + var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); + return s2.ToLower(); + } + + /// + /// Auto-discover all types with [McpForUnityTool] attribute + /// + private static void AutoDiscoverTools() + { + try + { + var toolTypes = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic) + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return new Type[0]; } + }) + .Where(t => t.GetCustomAttribute() != null); + + foreach (var type in toolTypes) + { + RegisterToolType(type); + } + + McpLog.Info($"Auto-discovered {_handlers.Count} tools"); + } + catch (Exception ex) + { + McpLog.Error($"Failed to auto-discover MCP tools: {ex.Message}"); + } + } + + private static void RegisterToolType(Type type) + { + var attr = type.GetCustomAttribute(); + + // Get command name (explicit or auto-generated) + string commandName = attr.CommandName; + if (string.IsNullOrEmpty(commandName)) + { + commandName = ToSnakeCase(type.Name); + } + + // Check for duplicate command names + if (_handlers.ContainsKey(commandName)) + { + McpLog.Warn( + $"Duplicate command name '{commandName}' detected. " + + $"Tool {type.Name} will override previously registered handler." + ); + } + + // Find HandleCommand method + var method = type.GetMethod( + "HandleCommand", + BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(JObject) }, + null + ); + + if (method == null) + { + McpLog.Warn( + $"MCP tool {type.Name} is marked with [McpForUnityTool] " + + $"but has no public static HandleCommand(JObject) method" + ); + return; + } + + try + { + var handler = (Func)Delegate.CreateDelegate( + typeof(Func), + method + ); + _handlers[commandName] = handler; + } + catch (Exception ex) + { + McpLog.Error($"Failed to register tool {type.Name}: {ex.Message}"); + } + } + + /// + /// Get a command handler by name + /// + public static Func GetHandler(string commandName) + { + if (!_handlers.TryGetValue(commandName, out var handler)) + { + throw new InvalidOperationException( + $"Unknown or unsupported command type: {commandName}" + ); + } + return handler; + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta b/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta new file mode 100644 index 00000000..15ec884b --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b61b5a84813b5749a5c64422694a0fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs new file mode 100644 index 00000000..1a952f37 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -0,0 +1,1331 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; // For Response class +using static MCPForUnity.Editor.Tools.ManageGameObject; + +#if UNITY_6000_0_OR_NEWER +using PhysicsMaterialType = UnityEngine.PhysicsMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; +#else +using PhysicsMaterialType = UnityEngine.PhysicMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; +#endif + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles asset management operations within the Unity project. + /// + [McpForUnityTool("manage_asset")] + public static class ManageAsset + { + // --- Main Handler --- + + // Define the list of valid actions + private static readonly List ValidActions = new List + { + "import", + "create", + "modify", + "delete", + "duplicate", + "move", + "rename", + "search", + "get_info", + "create_folder", + "get_components", + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Check if the action is valid before switching + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + // Common parameters + string path = @params["path"]?.ToString(); + + try + { + switch (action) + { + case "import": + // Note: Unity typically auto-imports. This might re-import or configure import settings. + return ReimportAsset(path, @params["properties"] as JObject); + case "create": + return CreateAsset(@params); + case "modify": + return ModifyAsset(path, @params["properties"] as JObject); + case "delete": + return DeleteAsset(path); + case "duplicate": + return DuplicateAsset(path, @params["destination"]?.ToString()); + case "move": // Often same as rename if within Assets/ + case "rename": + return MoveOrRenameAsset(path, @params["destination"]?.ToString()); + case "search": + return SearchAssets(@params); + case "get_info": + return GetAssetInfo( + path, + @params["generatePreview"]?.ToObject() ?? false + ); + case "create_folder": // Added specific action for clarity + return CreateFolder(path); + case "get_components": + return GetComponentsFromAsset(path); + + default: + // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. + string validActionsListDefault = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); + return Response.Error( + $"Internal error processing action '{action}' on '{path}': {e.Message}" + ); + } + } + + // --- Action Implementations --- + + private static object ReimportAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for reimport."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + // TODO: Apply importer properties before reimporting? + // This is complex as it requires getting the AssetImporter, casting it, + // applying properties via reflection or specific methods, saving, then reimporting. + if (properties != null && properties.HasValues) + { + Debug.LogWarning( + "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." + ); + // AssetImporter importer = AssetImporter.GetAtPath(fullPath); + // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } + } + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh + return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + } + catch (Exception e) + { + return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); + } + } + + private static object CreateAsset(JObject @params) + { + string path = @params["path"]?.ToString(); + string assetType = @params["assetType"]?.ToString(); + JObject properties = @params["properties"] as JObject; + + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create."); + if (string.IsNullOrEmpty(assetType)) + return Response.Error("'assetType' is required for create."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + string directory = Path.GetDirectoryName(fullPath); + + // Ensure directory exists + if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) + { + Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); + AssetDatabase.Refresh(); // Make sure Unity knows about the new folder + } + + if (AssetExists(fullPath)) + return Response.Error($"Asset already exists at path: {fullPath}"); + + try + { + UnityEngine.Object newAsset = null; + string lowerAssetType = assetType.ToLowerInvariant(); + + // Handle common asset types + if (lowerAssetType == "folder") + { + return CreateFolder(path); // Use dedicated method + } + else if (lowerAssetType == "material") + { + // Prefer provided shader; fall back to common pipelines + var requested = properties?["shader"]?.ToString(); + Shader shader = + (!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null) + ?? Shader.Find("Universal Render Pipeline/Lit") + ?? Shader.Find("HDRP/Lit") + ?? Shader.Find("Standard") + ?? Shader.Find("Unlit/Color"); + if (shader == null) + return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); + + var mat = new Material(shader); + if (properties != null) + ApplyMaterialProperties(mat, properties); + AssetDatabase.CreateAsset(mat, fullPath); + newAsset = mat; + } + else if (lowerAssetType == "physicsmaterial") + { + PhysicsMaterialType pmat = new PhysicsMaterialType(); + if (properties != null) + ApplyPhysicsMaterialProperties(pmat, properties); + AssetDatabase.CreateAsset(pmat, fullPath); + newAsset = pmat; + } + else if (lowerAssetType == "scriptableobject") + { + string scriptClassName = properties?["scriptClass"]?.ToString(); + if (string.IsNullOrEmpty(scriptClassName)) + return Response.Error( + "'scriptClass' property required when creating ScriptableObject asset." + ); + + Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null; + if ( + scriptType == null + || !typeof(ScriptableObject).IsAssignableFrom(scriptType) + ) + { + var reason = scriptType == null + ? (string.IsNullOrEmpty(error) ? "Type not found." : error) + : "Type found but does not inherit from ScriptableObject."; + return Response.Error($"Script class '{scriptClassName}' invalid: {reason}"); + } + + ScriptableObject so = ScriptableObject.CreateInstance(scriptType); + // TODO: Apply properties from JObject to the ScriptableObject instance? + AssetDatabase.CreateAsset(so, fullPath); + newAsset = so; + } + else if (lowerAssetType == "prefab") + { + // Creating prefabs usually involves saving an existing GameObject hierarchy. + // A common pattern is to create an empty GameObject, configure it, and then save it. + return Response.Error( + "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." + ); + // Example (conceptual): + // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); + // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); + } + // TODO: Add more asset types (Animation Controller, Scene, etc.) + else + { + // Generic creation attempt (might fail or create empty files) + // For some types, just creating the file might be enough if Unity imports it. + // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); + // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it + // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); + return Response.Error( + $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." + ); + } + + if ( + newAsset == null + && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) + ) // Check if it wasn't a folder and asset wasn't created + { + return Response.Error( + $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." + ); + } + + AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); // CreateAsset often handles refresh + return Response.Success( + $"Asset '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); + } + } + + private static object CreateFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create_folder."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + string parentDir = Path.GetDirectoryName(fullPath); + string folderName = Path.GetFileName(fullPath); + + if (AssetExists(fullPath)) + { + // Check if it's actually a folder already + if (AssetDatabase.IsValidFolder(fullPath)) + { + return Response.Success( + $"Folder already exists at path: {fullPath}", + GetAssetData(fullPath) + ); + } + else + { + return Response.Error( + $"An asset (not a folder) already exists at path: {fullPath}" + ); + } + } + + try + { + // Ensure parent exists + if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) + { + // Recursively create parent folders if needed (AssetDatabase handles this internally) + // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); + } + + string guid = AssetDatabase.CreateFolder(parentDir, folderName); + if (string.IsNullOrEmpty(guid)) + { + return Response.Error( + $"Failed to create folder '{fullPath}'. Check logs and permissions." + ); + } + + // AssetDatabase.Refresh(); // CreateFolder usually handles refresh + return Response.Success( + $"Folder '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); + } + } + + private static object ModifyAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for modify."); + if (properties == null || !properties.HasValues) + return Response.Error("'properties' are required for modify."); + + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + bool modified = false; // Flag to track if any changes were made + + // --- NEW: Handle GameObject / Prefab Component Modification --- + if (asset is GameObject gameObject) + { + // Iterate through the properties JSON: keys are component names, values are properties objects for that component + foreach (var prop in properties.Properties()) + { + string componentName = prop.Name; // e.g., "Collectible" + // Check if the value associated with the component name is actually an object containing properties + if ( + prop.Value is JObject componentProperties + && componentProperties.HasValues + ) // e.g., {"bobSpeed": 2.0} + { + // Resolve component type via ComponentResolver, then fetch by Type + Component targetComponent = null; + bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); + if (resolved) + { + targetComponent = gameObject.GetComponent(compType); + } + + // Only warn about resolution failure if component also not found + if (targetComponent == null && !resolved) + { + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" + ); + } + + if (targetComponent != null) + { + // Apply the nested properties (e.g., bobSpeed) to the found component instance + // Use |= to ensure 'modified' becomes true if any component is successfully modified + modified |= ApplyObjectProperties( + targetComponent, + componentProperties + ); + } + else + { + // Log a warning if a specified component couldn't be found + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." + ); + } + } + else + { + // Log a warning if the structure isn't {"ComponentName": {"prop": value}} + // We could potentially try to apply this property directly to the GameObject here if needed, + // but the primary goal is component modification. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." + ); + } + } + // Note: 'modified' is now true if ANY component property was successfully changed. + } + // --- End NEW --- + + // --- Existing logic for other asset types (now as else-if) --- + // Example: Modifying a Material + else if (asset is Material material) + { + // Apply properties directly to the material. If this modifies, it sets modified=true. + // Use |= in case the asset was already marked modified by previous logic (though unlikely here) + modified |= ApplyMaterialProperties(material, properties); + } + // Example: Modifying a ScriptableObject + else if (asset is ScriptableObject so) + { + // Apply properties directly to the ScriptableObject. + modified |= ApplyObjectProperties(so, properties); // General helper + } + // Example: Modifying TextureImporter settings + else if (asset is Texture) + { + AssetImporter importer = AssetImporter.GetAtPath(fullPath); + if (importer is TextureImporter textureImporter) + { + bool importerModified = ApplyObjectProperties(textureImporter, properties); + if (importerModified) + { + // Importer settings need saving and reimporting + AssetDatabase.WriteImportSettingsIfDirty(fullPath); + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes + modified = true; // Mark overall operation as modified + } + } + else + { + Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); + } + } + // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) + else // Fallback for other asset types OR direct properties on non-GameObject assets + { + // This block handles non-GameObject/Material/ScriptableObject/Texture assets. + // Attempts to apply properties directly to the asset itself. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." + ); + modified |= ApplyObjectProperties(asset, properties); + } + // --- End Existing Logic --- + + // Check if any modification happened (either component or direct asset modification) + if (modified) + { + // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. + EditorUtility.SetDirty(asset); + // Save all modified assets to disk. + AssetDatabase.SaveAssets(); + // Refresh might be needed in some edge cases, but SaveAssets usually covers it. + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{fullPath}' modified successfully.", + GetAssetData(fullPath) + ); + } + else + { + // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. + return Response.Success( + $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", + GetAssetData(fullPath) + ); + // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + } + } + catch (Exception e) + { + // Log the detailed error internally + Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); + // Return a user-friendly error message + return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + } + } + + private static object DeleteAsset(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for delete."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + { + // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh + return Response.Success($"Asset '{fullPath}' deleted successfully."); + } + else + { + // This might happen if the file couldn't be deleted (e.g., locked) + return Response.Error( + $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); + } + } + + private static object DuplicateAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for duplicate."); + + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(sourcePath)) + return Response.Error($"Source asset not found at path: {sourcePath}"); + + string destPath; + if (string.IsNullOrEmpty(destinationPath)) + { + // Generate a unique path if destination is not provided + destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); + } + else + { + destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); + if (AssetExists(destPath)) + return Response.Error($"Asset already exists at destination path: {destPath}"); + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + } + + try + { + bool success = AssetDatabase.CopyAsset(sourcePath, destPath); + if (success) + { + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{sourcePath}' duplicated to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + return Response.Error( + $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); + } + } + + private static object MoveOrRenameAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for move/rename."); + if (string.IsNullOrEmpty(destinationPath)) + return Response.Error("'destination' path is required for move/rename."); + + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); + + if (!AssetExists(sourcePath)) + return Response.Error($"Source asset not found at path: {sourcePath}"); + if (AssetExists(destPath)) + return Response.Error( + $"An asset already exists at the destination path: {destPath}" + ); + + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + + try + { + // Validate will return an error string if failed, null if successful + string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(error)) + { + return Response.Error( + $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}" + ); + } + + string guid = AssetDatabase.MoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success + { + // AssetDatabase.Refresh(); // MoveAsset usually handles refresh + return Response.Success( + $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + // This case might not be reachable if ValidateMoveAsset passes, but good to have + return Response.Error( + $"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + } + } + + private static object SearchAssets(JObject @params) + { + string searchPattern = @params["searchPattern"]?.ToString(); + string filterType = @params["filterType"]?.ToString(); + string pathScope = @params["path"]?.ToString(); // Use path as folder scope + string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); + int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size + int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) + bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; + + List searchFilters = new List(); + if (!string.IsNullOrEmpty(searchPattern)) + searchFilters.Add(searchPattern); + if (!string.IsNullOrEmpty(filterType)) + searchFilters.Add($"t:{filterType}"); + + string[] folderScope = null; + if (!string.IsNullOrEmpty(pathScope)) + { + folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; + if (!AssetDatabase.IsValidFolder(folderScope[0])) + { + // Maybe the user provided a file path instead of a folder? + // We could search in the containing folder, or return an error. + Debug.LogWarning( + $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." + ); + folderScope = null; // Search everywhere if path isn't a folder + } + } + + DateTime? filterDateAfter = null; + if (!string.IsNullOrEmpty(filterDateAfterStr)) + { + if ( + DateTime.TryParse( + filterDateAfterStr, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate + ) + ) + { + filterDateAfter = parsedDate; + } + else + { + Debug.LogWarning( + $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." + ); + } + } + + try + { + string[] guids = AssetDatabase.FindAssets( + string.Join(" ", searchFilters), + folderScope + ); + List results = new List(); + int totalFound = 0; + + foreach (string guid in guids) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(assetPath)) + continue; + + // Apply date filter if present + if (filterDateAfter.HasValue) + { + DateTime lastWriteTime = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), assetPath) + ); + if (lastWriteTime <= filterDateAfter.Value) + { + continue; // Skip assets older than or equal to the filter date + } + } + + totalFound++; // Count matching assets before pagination + results.Add(GetAssetData(assetPath, generatePreview)); + } + + // Apply pagination + int startIndex = (pageNumber - 1) * pageSize; + var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); + + return Response.Success( + $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", + new + { + totalAssets = totalFound, + pageSize = pageSize, + pageNumber = pageNumber, + assets = pagedResults, + } + ); + } + catch (Exception e) + { + return Response.Error($"Error searching assets: {e.Message}"); + } + } + + private static object GetAssetInfo(string path, bool generatePreview) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_info."); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + return Response.Success( + "Asset info retrieved.", + GetAssetData(fullPath, generatePreview) + ); + } + catch (Exception e) + { + return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); + } + } + + /// + /// Retrieves components attached to a GameObject asset (like a Prefab). + /// + /// The asset path of the GameObject or Prefab. + /// A response object containing a list of component type names or an error. + private static object GetComponentsFromAsset(string path) + { + // 1. Validate input path + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_components."); + + // 2. Sanitize and check existence + string fullPath = AssetPathUtility.SanitizeAssetPath(path); + if (!AssetExists(fullPath)) + return Response.Error($"Asset not found at path: {fullPath}"); + + try + { + // 3. Load the asset + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + // 4. Check if it's a GameObject (Prefabs load as GameObjects) + GameObject gameObject = asset as GameObject; + if (gameObject == null) + { + // Also check if it's *directly* a Component type (less common for primary assets) + Component componentAsset = asset as Component; + if (componentAsset != null) + { + // If the asset itself *is* a component, maybe return just its info? + // This is an edge case. Let's stick to GameObjects for now. + return Response.Error( + $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." + ); + } + return Response.Error( + $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." + ); + } + + // 5. Get components + Component[] components = gameObject.GetComponents(); + + // 6. Format component data + List componentList = components + .Select(comp => new + { + typeName = comp.GetType().FullName, + instanceID = comp.GetInstanceID(), + // TODO: Add more component-specific details here if needed in the future? + // Requires reflection or specific handling per component type. + }) + .ToList(); // Explicit cast for clarity if needed + + // 7. Return success response + return Response.Success( + $"Found {componentList.Count} component(s) on asset '{fullPath}'.", + componentList + ); + } + catch (Exception e) + { + Debug.LogError( + $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" + ); + return Response.Error( + $"Error getting components for asset '{fullPath}': {e.Message}" + ); + } + } + + // --- Internal Helpers --- + + /// + /// Ensures the asset path starts with "Assets/". + /// + /// + /// Checks if an asset exists at the given path (file or folder). + /// + private static bool AssetExists(string sanitizedPath) + { + // AssetDatabase APIs are generally preferred over raw File/Directory checks for assets. + // Check if it's a known asset GUID. + if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath))) + { + return true; + } + // AssetPathToGUID might not work for newly created folders not yet refreshed. + // Check directory explicitly for folders. + if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + // Check if it's considered a *valid* folder by Unity + return AssetDatabase.IsValidFolder(sanitizedPath); + } + // Check file existence for non-folder assets. + if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath))) + { + return true; // Assume if file exists, it's an asset or will be imported + } + + return false; + // Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)); + } + + /// + /// Ensures the directory for a given asset path exists, creating it if necessary. + /// + private static void EnsureDirectoryExists(string directoryPath) + { + if (string.IsNullOrEmpty(directoryPath)) + return; + string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); + if (!Directory.Exists(fullDirPath)) + { + Directory.CreateDirectory(fullDirPath); + AssetDatabase.Refresh(); // Let Unity know about the new folder + } + } + + /// + /// Applies properties from JObject to a Material. + /// + private static bool ApplyMaterialProperties(Material mat, JObject properties) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Example: Set shader + if (properties["shader"]?.Type == JTokenType.String) + { + Shader newShader = Shader.Find(properties["shader"].ToString()); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + // Example: Set color property + if (properties["color"] is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = new Color( + colArr[0].ToObject(), + colArr[1].ToObject(), + colArr[2].ToObject(), + colArr.Count > 3 ? colArr[3].ToObject() : 1.0f + ); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing color property '{propName}': {ex.Message}" + ); + } + } + } + else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py + { + string propName = "_Color"; + try + { + if (colorArr.Count >= 3) + { + Color newColor = new Color( + colorArr[0].ToObject(), + colorArr[1].ToObject(), + colorArr[2].ToObject(), + colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f + ); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing color property '{propName}': {ex.Message}" + ); + } + } + // Example: Set float property + if (properties["float"] is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if ( + !string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) + ) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing float property '{propName}': {ex.Message}" + ); + } + } + } + // Example: Set texture property + if (properties["texture"] is JObject texProps) + { + string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture + string texPath = texProps["path"]?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + Texture newTex = AssetDatabase.LoadAssetAtPath( + AssetPathUtility.SanitizeAssetPath(texPath) + ); + if ( + newTex != null + && mat.HasProperty(propName) + && mat.GetTexture(propName) != newTex + ) + { + mat.SetTexture(propName, newTex); + modified = true; + } + else if (newTex == null) + { + Debug.LogWarning($"Texture not found at path: {texPath}"); + } + } + } + + // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + return modified; + } + + /// + /// Applies properties from JObject to a PhysicsMaterial. + /// + private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) + { + if (pmat == null || properties == null) + return false; + bool modified = false; + + // Example: Set dynamic friction + if (properties["dynamicFriction"]?.Type == JTokenType.Float) + { + float dynamicFriction = properties["dynamicFriction"].ToObject(); + pmat.dynamicFriction = dynamicFriction; + modified = true; + } + + // Example: Set static friction + if (properties["staticFriction"]?.Type == JTokenType.Float) + { + float staticFriction = properties["staticFriction"].ToObject(); + pmat.staticFriction = staticFriction; + modified = true; + } + + // Example: Set bounciness + if (properties["bounciness"]?.Type == JTokenType.Float) + { + float bounciness = properties["bounciness"].ToObject(); + pmat.bounciness = bounciness; + modified = true; + } + + List averageList = new List { "ave", "Ave", "average", "Average" }; + List multiplyList = new List { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; + List minimumList = new List { "min", "Min", "minimum", "Minimum" }; + List maximumList = new List { "max", "Max", "maximum", "Maximum" }; + + // Example: Set friction combine + if (properties["frictionCombine"]?.Type == JTokenType.String) + { + string frictionCombine = properties["frictionCombine"].ToString(); + if (averageList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + // Example: Set bounce combine + if (properties["bounceCombine"]?.Type == JTokenType.String) + { + string bounceCombine = properties["bounceCombine"].ToString(); + if (averageList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + return modified; + } + + /// + /// Generic helper to set properties on any UnityEngine.Object using reflection. + /// + private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) + { + if (target == null || properties == null) + return false; + bool modified = false; + Type type = target.GetType(); + + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + if (SetPropertyOrField(target, propName, propValue, type)) + { + modified = true; + } + } + return modified; + } + + /// + /// Helper to set a property or field via reflection, handling basic types and Unity objects. + /// + private static bool SetPropertyOrField( + object target, + string memberName, + JToken value, + Type type = null + ) + { + type = type ?? target.GetType(); + System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.IgnoreCase; + + try + { + System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); + if ( + convertedValue != null + && !object.Equals(propInfo.GetValue(target), convertedValue) + ) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); + if ( + convertedValue != null + && !object.Equals(fieldInfo.GetValue(target), convertedValue) + ) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" + ); + } + return false; + } + + /// + /// Simple JToken to Type conversion for common Unity types and primitives. + /// + private static object ConvertJTokenToType(JToken token, Type targetType) + { + try + { + if (token == null || token.Type == JTokenType.Null) + return null; + + if (targetType == typeof(string)) + return token.ToObject(); + if (targetType == typeof(int)) + return token.ToObject(); + if (targetType == typeof(float)) + return token.ToObject(); + if (targetType == typeof(bool)) + return token.ToObject(); + if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) + return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); + if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) + return new Vector3( + arrV3[0].ToObject(), + arrV3[1].ToObject(), + arrV3[2].ToObject() + ); + if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) + return new Vector4( + arrV4[0].ToObject(), + arrV4[1].ToObject(), + arrV4[2].ToObject(), + arrV4[3].ToObject() + ); + if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) + return new Quaternion( + arrQ[0].ToObject(), + arrQ[1].ToObject(), + arrQ[2].ToObject(), + arrQ[3].ToObject() + ); + if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA + return new Color( + arrC[0].ToObject(), + arrC[1].ToObject(), + arrC[2].ToObject(), + arrC.Count > 3 ? arrC[3].ToObject() : 1.0f + ); + if (targetType.IsEnum) + return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing + + // Handle loading Unity Objects (Materials, Textures, etc.) by path + if ( + typeof(UnityEngine.Object).IsAssignableFrom(targetType) + && token.Type == JTokenType.String + ) + { + string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( + assetPath, + targetType + ); + if (loadedAsset == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" + ); + } + return loadedAsset; + } + + // Fallback: Try direct conversion (might work for other simple value types) + return token.ToObject(targetType); + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" + ); + return null; + } + } + + + // --- Data Serialization --- + + /// + /// Creates a serializable representation of an asset. + /// + private static object GetAssetData(string path, bool generatePreview = false) + { + if (string.IsNullOrEmpty(path) || !AssetExists(path)) + return null; + + string guid = AssetDatabase.AssetPathToGUID(path); + Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); + string previewBase64 = null; + int previewWidth = 0; + int previewHeight = 0; + + if (generatePreview && asset != null) + { + Texture2D preview = AssetPreview.GetAssetPreview(asset); + + if (preview != null) + { + try + { + // Ensure texture is readable for EncodeToPNG + // Creating a temporary readable copy is safer + RenderTexture rt = null; + Texture2D readablePreview = null; + RenderTexture previous = RenderTexture.active; + try + { + rt = RenderTexture.GetTemporary(preview.width, preview.height); + Graphics.Blit(preview, rt); + RenderTexture.active = rt; + readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); + readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readablePreview.Apply(); + + var pngData = readablePreview.EncodeToPNG(); + if (pngData != null && pngData.Length > 0) + { + previewBase64 = Convert.ToBase64String(pngData); + previewWidth = readablePreview.width; + previewHeight = readablePreview.height; + } + } + finally + { + RenderTexture.active = previous; + if (rt != null) RenderTexture.ReleaseTemporary(rt); + if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." + ); + // Fallback: Try getting static preview if available? + // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); + } + } + else + { + Debug.LogWarning( + $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" + ); + } + } + + return new + { + path = path, + guid = guid, + assetType = assetType?.FullName ?? "Unknown", + name = Path.GetFileNameWithoutExtension(path), + fileName = Path.GetFileName(path), + isFolder = AssetDatabase.IsValidFolder(path), + instanceID = asset?.GetInstanceID() ?? 0, + lastWriteTimeUtc = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), path) + ) + .ToString("o"), // ISO 8601 + // --- Preview Data --- + previewBase64 = previewBase64, // PNG data as Base64 string + previewWidth = previewWidth, + previewHeight = previewHeight, + // TODO: Add more metadata? Importer settings? Dependencies? + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs.meta b/MCPForUnity/Editor/Tools/ManageAsset.cs.meta new file mode 100644 index 00000000..3dbc2e2f --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de90a1d9743a2874cb235cf0b83444b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs new file mode 100644 index 00000000..f8255224 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -0,0 +1,645 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; // Required for tag management +using UnityEditor.SceneManagement; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles operations related to controlling and querying the Unity Editor state, + /// including managing Tags and Layers. + /// + [McpForUnityTool("manage_editor")] + public static class ManageEditor + { + // Constant for starting user layer index + private const int FirstUserLayerIndex = 8; + + // Constant for total layer count + private const int TotalLayerCount = 32; + + /// + /// Main handler for editor management actions. + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + // Parameters for specific actions + string tagName = @params["tagName"]?.ToString(); + string layerName = @params["layerName"]?.ToString(); + bool waitForCompletion = @params["waitForCompletion"]?.ToObject() ?? false; // Example - not used everywhere + + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Route action + switch (action) + { + // Play Mode Control + case "play": + try + { + if (!EditorApplication.isPlaying) + { + EditorApplication.isPlaying = true; + return Response.Success("Entered play mode."); + } + return Response.Success("Already in play mode."); + } + catch (Exception e) + { + return Response.Error($"Error entering play mode: {e.Message}"); + } + case "pause": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPaused = !EditorApplication.isPaused; + return Response.Success( + EditorApplication.isPaused ? "Game paused." : "Game resumed." + ); + } + return Response.Error("Cannot pause/resume: Not in play mode."); + } + catch (Exception e) + { + return Response.Error($"Error pausing/resuming game: {e.Message}"); + } + case "stop": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPlaying = false; + return Response.Success("Exited play mode."); + } + return Response.Success("Already stopped (not in play mode)."); + } + catch (Exception e) + { + return Response.Error($"Error stopping play mode: {e.Message}"); + } + + // Editor State/Info + case "get_state": + return GetEditorState(); + case "get_project_root": + return GetProjectRoot(); + case "get_windows": + return GetEditorWindows(); + case "get_active_tool": + return GetActiveTool(); + case "get_selection": + return GetSelection(); + case "get_prefab_stage": + return GetPrefabStageInfo(); + case "set_active_tool": + string toolName = @params["toolName"]?.ToString(); + if (string.IsNullOrEmpty(toolName)) + return Response.Error("'toolName' parameter required for set_active_tool."); + return SetActiveTool(toolName); + + // Tag Management + case "add_tag": + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for add_tag."); + return AddTag(tagName); + case "remove_tag": + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for remove_tag."); + return RemoveTag(tagName); + case "get_tags": + return GetTags(); // Helper to list current tags + + // Layer Management + case "add_layer": + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for add_layer."); + return AddLayer(layerName); + case "remove_layer": + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for remove_layer."); + return RemoveLayer(layerName); + case "get_layers": + return GetLayers(); // Helper to list current layers + + // --- Settings (Example) --- + // case "set_resolution": + // int? width = @params["width"]?.ToObject(); + // int? height = @params["height"]?.ToObject(); + // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); + // return SetGameViewResolution(width.Value, height.Value); + // case "set_quality": + // // Handle string name or int index + // return SetQualityLevel(@params["qualityLevel"]); + + default: + return Response.Error( + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + ); + } + } + + // --- Editor State/Info Methods --- + private static object GetEditorState() + { + try + { + var state = new + { + isPlaying = EditorApplication.isPlaying, + isPaused = EditorApplication.isPaused, + isCompiling = EditorApplication.isCompiling, + isUpdating = EditorApplication.isUpdating, + applicationPath = EditorApplication.applicationPath, + applicationContentsPath = EditorApplication.applicationContentsPath, + timeSinceStartup = EditorApplication.timeSinceStartup, + }; + return Response.Success("Retrieved editor state.", state); + } + catch (Exception e) + { + return Response.Error($"Error getting editor state: {e.Message}"); + } + } + + private static object GetProjectRoot() + { + try + { + // Application.dataPath points to /Assets + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + if (string.IsNullOrEmpty(projectRoot)) + { + return Response.Error("Could not determine project root from Application.dataPath"); + } + return Response.Success("Project root resolved.", new { projectRoot }); + } + catch (Exception e) + { + return Response.Error($"Error getting project root: {e.Message}"); + } + } + + private static object GetEditorWindows() + { + try + { + // Get all types deriving from EditorWindow + var windowTypes = AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => type.IsSubclassOf(typeof(EditorWindow))) + .ToList(); + + var openWindows = new List(); + + // Find currently open instances + // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows + EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll(); + + foreach (EditorWindow window in allWindows) + { + if (window == null) + continue; // Skip potentially destroyed windows + + try + { + openWindows.Add( + new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height, + }, + instanceID = window.GetInstanceID(), + } + ); + } + catch (Exception ex) + { + Debug.LogWarning( + $"Could not get info for window {window.GetType().Name}: {ex.Message}" + ); + } + } + + return Response.Success("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return Response.Error($"Error getting editor windows: {e.Message}"); + } + } + + private static object GetPrefabStageInfo() + { + try + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Success + ("No prefab stage is currently open.", new { isOpen = false }); + } + + return Response.Success( + "Prefab stage info retrieved.", + new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + } + ); + } + catch (Exception e) + { + return Response.Error($"Error getting prefab stage info: {e.Message}"); + } + } + + private static object GetActiveTool() + { + try + { + Tool currentTool = UnityEditor.Tools.current; + string toolName = currentTool.ToString(); // Enum to string + bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active + string activeToolName = customToolActive + ? EditorTools.GetActiveToolName() + : toolName; // Get custom name if needed + + var toolInfo = new + { + activeTool = activeToolName, + isCustom = customToolActive, + pivotMode = UnityEditor.Tools.pivotMode.ToString(), + pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), + handleRotation = UnityEditor.Tools.handleRotation.eulerAngles, // Euler for simplicity + handlePosition = UnityEditor.Tools.handlePosition, + }; + + return Response.Success("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting active tool: {e.Message}"); + } + } + + private static object SetActiveTool(string toolName) + { + try + { + Tool targetTool; + if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse + { + // Check if it's a valid built-in tool + if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool + { + UnityEditor.Tools.current = targetTool; + return Response.Success($"Set active tool to '{targetTool}'."); + } + else + { + return Response.Error( + $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." + ); + } + } + else + { + // Potentially try activating a custom tool by name here if needed + // This often requires specific editor scripting knowledge for that tool. + return Response.Error( + $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error setting active tool: {e.Message}"); + } + } + + private static object GetSelection() + { + try + { + var selectionInfo = new + { + activeObject = Selection.activeObject?.name, + activeGameObject = Selection.activeGameObject?.name, + activeTransform = Selection.activeTransform?.name, + activeInstanceID = Selection.activeInstanceID, + count = Selection.count, + objects = Selection + .objects.Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceID(), + }) + .ToList(), + gameObjects = Selection + .gameObjects.Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceID(), + }) + .ToList(), + assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view + }; + + return Response.Success("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting selection: {e.Message}"); + } + } + + // --- Tag Management Methods --- + + private static object AddTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + + // Check if tag already exists + if (InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' already exists."); + } + + try + { + // Add the tag using the internal utility + InternalEditorUtility.AddTag(tagName); + // Force save assets to ensure the change persists in the TagManager asset + AssetDatabase.SaveAssets(); + return Response.Success($"Tag '{tagName}' added successfully."); + } + catch (Exception e) + { + return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); + } + } + + private static object RemoveTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) + return Response.Error("Cannot remove the built-in 'Untagged' tag."); + + // Check if tag exists before attempting removal + if (!InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' does not exist."); + } + + try + { + // Remove the tag using the internal utility + InternalEditorUtility.RemoveTag(tagName); + // Force save assets + AssetDatabase.SaveAssets(); + return Response.Success($"Tag '{tagName}' removed successfully."); + } + catch (Exception e) + { + // Catch potential issues if the tag is somehow in use or removal fails + return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); + } + } + + private static object GetTags() + { + try + { + string[] tags = InternalEditorUtility.tags; + return Response.Success("Retrieved current tags.", tags); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve tags: {e.Message}"); + } + } + + // --- Layer Management Methods --- + + private static object AddLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); + + // Check if layer name already exists (case-insensitive check recommended) + for (int i = 0; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + return Response.Error($"Layer '{layerName}' already exists at index {i}."); + } + } + + // Find the first empty user layer slot (indices 8 to 31) + int firstEmptyUserLayer = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) + { + firstEmptyUserLayer = i; + break; + } + } + + if (firstEmptyUserLayer == -1) + { + return Response.Error("No empty User Layer slots available (8-31 are full)."); + } + + // Assign the name to the found slot + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + firstEmptyUserLayer + ); + targetLayerSP.stringValue = layerName; + // Apply the changes to the TagManager asset + tagManager.ApplyModifiedProperties(); + // Save assets to make sure it's written to disk + AssetDatabase.SaveAssets(); + return Response.Success( + $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." + ); + } + catch (Exception e) + { + return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); + } + } + + private static object RemoveLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); + + // Find the layer by name (must be user layer) + int layerIndexToRemove = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + // Case-insensitive comparison is safer + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + layerIndexToRemove = i; + break; + } + } + + if (layerIndexToRemove == -1) + { + return Response.Error($"User layer '{layerName}' not found."); + } + + // Clear the name for that index + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + layerIndexToRemove + ); + targetLayerSP.stringValue = string.Empty; // Set to empty string to remove + // Apply the changes + tagManager.ApplyModifiedProperties(); + // Save assets + AssetDatabase.SaveAssets(); + return Response.Success( + $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." + ); + } + catch (Exception e) + { + return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); + } + } + + private static object GetLayers() + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names + { + layers.Add(i, layerName); + } + } + return Response.Success("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve layers: {e.Message}"); + } + } + + // --- Helper Methods --- + + /// + /// Gets the SerializedObject for the TagManager asset. + /// + private static SerializedObject GetTagManager() + { + try + { + // Load the TagManager asset from the ProjectSettings folder + UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( + "ProjectSettings/TagManager.asset" + ); + if (tagManagerAssets == null || tagManagerAssets.Length == 0) + { + Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); + return null; + } + // The first object in the asset file should be the TagManager + return new SerializedObject(tagManagerAssets[0]); + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); + return null; + } + } + + // --- Example Implementations for Settings --- + /* + private static object SetGameViewResolution(int width, int height) { ... } + private static object SetQualityLevel(JToken qualityLevelToken) { ... } + */ + } + + // Helper class to get custom tool names (remains the same) + internal static class EditorTools + { + public static string GetActiveToolName() + { + // This is a placeholder. Real implementation depends on how custom tools + // are registered and tracked in the specific Unity project setup. + // It might involve checking static variables, calling methods on specific tool managers, etc. + if (UnityEditor.Tools.current == Tool.Custom) + { + // Example: Check a known custom tool manager + // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; + return "Unknown Custom Tool"; + } + return UnityEditor.Tools.current.ToString(); + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs.meta b/MCPForUnity/Editor/Tools/ManageEditor.cs.meta new file mode 100644 index 00000000..8b55fb87 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 43ac60aa36b361b4dbe4a038ae9f35c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs new file mode 100644 index 00000000..40504a87 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -0,0 +1,2548 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; // Added for JsonSerializationException +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.Compilation; // For CompilationPipeline +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.SceneManagement; +using MCPForUnity.Editor.Helpers; // For Response class +using MCPForUnity.Runtime.Serialization; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles GameObject manipulation within the current scene (CRUD, find, components). + /// + [McpForUnityTool("manage_gameobject")] + public static class ManageGameObject + { + // Shared JsonSerializer to avoid per-call allocation overhead + private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() + } + }); + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return Response.Error("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Parameters used by various actions + JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); + + // Get common parameters (consolidated) + string name = @params["name"]?.ToString(); + string tag = @params["tag"]?.ToString(); + string layer = @params["layer"]?.ToString(); + JToken parentToken = @params["parent"]; + + // --- Add parameter for controlling non-public field inclusion --- + bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true + // --- End add parameter --- + + // --- Prefab Redirection Check --- + string targetPath = + targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; + if ( + !string.IsNullOrEmpty(targetPath) + && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) + if (action == "modify" || action == "set_component_property") + { + Debug.Log( + $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." + ); + // Prepare params for ManageAsset.ModifyAsset + JObject assetParams = new JObject(); + assetParams["action"] = "modify"; // ManageAsset uses "modify" + assetParams["path"] = targetPath; + + // Extract properties. + // For 'set_component_property', combine componentName and componentProperties. + // For 'modify', directly use componentProperties. + JObject properties = null; + if (action == "set_component_property") + { + string compName = @params["componentName"]?.ToString(); + JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting + if (string.IsNullOrEmpty(compName)) + return Response.Error( + "Missing 'componentName' for 'set_component_property' on prefab." + ); + if (compProps == null) + return Response.Error( + $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." + ); + + properties = new JObject(); + properties[compName] = compProps; + } + else // action == "modify" + { + properties = @params["componentProperties"] as JObject; + if (properties == null) + return Response.Error( + "Missing 'componentProperties' for 'modify' action on prefab." + ); + } + + assetParams["properties"] = properties; + + // Call ManageAsset handler + return ManageAsset.HandleCommand(assetParams); + } + else if ( + action == "delete" + || action == "add_component" + || action == "remove_component" + || action == "get_components" + ) // Added get_components here too + { + // Explicitly block other modifications on the prefab asset itself via manage_gameobject + return Response.Error( + $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." + ); + } + // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. + // No specific handling needed here, the code below will run. + } + // --- End Prefab Redirection Check --- + + try + { + switch (action) + { + case "create": + return CreateGameObject(@params); + case "modify": + return ModifyGameObject(@params, targetToken, searchMethod); + case "delete": + return DeleteGameObject(targetToken, searchMethod); + case "find": + return FindGameObjects(@params, targetToken, searchMethod); + case "get_components": + string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string + if (getCompTarget == null) + return Response.Error( + "'target' parameter required for get_components." + ); + // Pass the includeNonPublicSerialized flag here + return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); + case "get_component": + string getSingleCompTarget = targetToken?.ToString(); + if (getSingleCompTarget == null) + return Response.Error( + "'target' parameter required for get_component." + ); + string componentName = @params["componentName"]?.ToString(); + if (string.IsNullOrEmpty(componentName)) + return Response.Error( + "'componentName' parameter required for get_component." + ); + return GetSingleComponentFromTarget(getSingleCompTarget, searchMethod, componentName, includeNonPublicSerialized); + case "add_component": + return AddComponentToTarget(@params, targetToken, searchMethod); + case "remove_component": + return RemoveComponentFromTarget(@params, targetToken, searchMethod); + case "set_component_property": + return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); + + default: + return Response.Error($"Unknown action: '{action}'."); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object CreateGameObject(JObject @params) + { + string name = @params["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return Response.Error("'name' parameter is required for 'create' action."); + } + + // Get prefab creation parameters + bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; + string prefabPath = @params["prefabPath"]?.ToString(); + string tag = @params["tag"]?.ToString(); // Get tag for creation + string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check + GameObject newGo = null; // Initialize as null + + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; // Keep original for messages + if (!string.IsNullOrEmpty(prefabPath)) + { + // If no extension, search for the prefab by name + if ( + !prefabPath.Contains("/") + && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + string prefabNameOnly = prefabPath; + Debug.Log( + $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" + ); + string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); + if (guids.Length == 0) + { + return Response.Error( + $"Prefab named '{prefabNameOnly}' not found anywhere in the project." + ); + } + else if (guids.Length > 1) + { + string foundPaths = string.Join( + ", ", + guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) + ); + return Response.Error( + $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." + ); + } + else // Exactly one found + { + prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path + Debug.Log( + $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" + ); + } + } + else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. + Debug.LogWarning( + $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." + ); + prefabPath += ".prefab"; + // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. + } + // The logic above now handles finding or assuming the .prefab extension. + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) + { + try + { + // Instantiate the prefab, initially place it at the root + // Parent will be set later if specified + newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; + + if (newGo == null) + { + // This might happen if the asset exists but isn't a valid GameObject prefab somehow + Debug.LogError( + $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." + ); + return Response.Error( + $"Failed to instantiate prefab at '{prefabPath}'." + ); + } + // Name the instance based on the 'name' parameter, not the prefab's default name + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + // Register Undo for prefab instantiation + Undo.RegisterCreatedObjectUndo( + newGo, + $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" + ); + Debug.Log( + $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." + ); + } + catch (Exception e) + { + return Response.Error( + $"Error instantiating prefab '{prefabPath}': {e.Message}" + ); + } + } + else + { + // Only return error if prefabPath was specified but not found. + // If prefabPath was empty/null, we proceed to create primitive/empty. + Debug.LogWarning( + $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." + ); + // Do not return error here, allow fallback to primitive/empty creation + } + } + + // --- Fallback: Create Primitive or Empty GameObject --- + bool createdNewObject = false; // Flag to track if we created (not instantiated) + if (newGo == null) // Only proceed if prefab instantiation didn't happen + { + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType) + Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + // Set name *after* creation for primitives + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak + return Response.Error( + "'name' parameter is required when creating a primitive." + ); + } + createdNewObject = true; + } + catch (ArgumentException) + { + return Response.Error( + $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" + ); + } + catch (Exception e) + { + return Response.Error( + $"Failed to create primitive '{primitiveType}': {e.Message}" + ); + } + } + else // Create empty GameObject + { + if (string.IsNullOrEmpty(name)) + { + return Response.Error( + "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." + ); + } + newGo = new GameObject(name); + createdNewObject = true; + } + // Record creation for Undo *only* if we created a new object + if (createdNewObject) + { + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); + } + } + // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- + if (newGo == null) + { + // Should theoretically not happen if logic above is correct, but safety check. + return Response.Error("Failed to create or instantiate the GameObject."); + } + + // Record potential changes to the existing prefab instance or the new GO + // Record transform separately in case parent changes affect it + Undo.RecordObject(newGo.transform, "Set GameObject Transform"); + Undo.RecordObject(newGo, "Set GameObject Properties"); + + // Set Parent + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding + if (parentGo == null) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object + return Response.Error($"Parent specified ('{parentToken}') but not found."); + } + newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true + } + + // Set Transform + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue) + newGo.transform.localPosition = position.Value; + if (rotation.HasValue) + newGo.transform.localEulerAngles = rotation.Value; + if (scale.HasValue) + newGo.transform.localScale = scale.Value; + + // Set Tag (added for create action) + if (!string.IsNullOrEmpty(tag)) + { + // Similar logic as in ModifyGameObject for setting/creating tags + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + newGo.tag = tagToSet; + } + catch (UnityException ex) + { + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + InternalEditorUtility.AddTag(tagToSet); + newGo.tag = tagToSet; // Retry + Debug.Log( + $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." + ); + } + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." + ); + } + } + } + + // Set Layer (new for create action) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newGo.layer = layerId; + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." + ); + } + } + + // Add Components + if (@params["componentsToAdd"] is JArray componentsToAddArray) + { + foreach (var compToken in componentsToAddArray) + { + string typeName = null; + JObject properties = null; + + if (compToken.Type == JTokenType.String) + { + typeName = compToken.ToString(); + } + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(newGo, typeName, properties); + if (addResult != null) // Check if AddComponentInternal returned an error object + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return addResult; // Return the error response + } + } + else + { + Debug.LogWarning( + $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" + ); + } + } + } + + // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true + GameObject finalInstance = newGo; // Use this for selection and return data + if (createdNewObject && saveAsPrefab) + { + string finalPrefabPath = prefabPath; // Use a separate variable for saving path + // This check should now happen *before* attempting to save + if (string.IsNullOrEmpty(finalPrefabPath)) + { + // Clean up the created object before returning error + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." + ); + } + // Ensure the *saving* path ends with .prefab + if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + Debug.Log( + $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" + ); + finalPrefabPath += ".prefab"; + } + + try + { + // Ensure directory exists using the final saving path + string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); + if ( + !string.IsNullOrEmpty(directoryPath) + && !System.IO.Directory.Exists(directoryPath) + ) + { + System.IO.Directory.CreateDirectory(directoryPath); + AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder + Debug.Log( + $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" + ); + } + // Use SaveAsPrefabAssetAndConnect with the final saving path + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + newGo, + finalPrefabPath, + InteractionMode.UserAction + ); + + if (finalInstance == null) + { + // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." + ); + } + Debug.Log( + $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." + ); + // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. + // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect + } + catch (Exception e) + { + // Clean up the instance if prefab saving fails + UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt + return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + } + } + + // Select the instance in the scene (either prefab instance or newly created/saved one) + Selection.activeGameObject = finalInstance; + + // Determine appropriate success message using the potentially updated or original path + string messagePrefabPath = + finalInstance == null + ? originalPrefabPath + : AssetDatabase.GetAssetPath( + PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) + ?? (UnityEngine.Object)finalInstance + ); + string successMessage; + if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab + { + successMessage = + $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; + } + else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; + } + else // Created new primitive or empty GO, didn't save as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created successfully in scene."; + } + + // Use the new serializer helper + //return Response.Success(successMessage, GetGameObjectData(finalInstance)); + return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + } + + private static object ModifyGameObject( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + // Record state for Undo *before* modifications + Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); + Undo.RecordObject(targetGo, "Modify GameObject Properties"); + + bool modified = false; + + // Rename (using consolidated 'name' parameter) + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) + { + targetGo.name = name; + modified = true; + } + + // Change Parent (using consolidated 'parent' parameter) + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); + // Check for hierarchy loops + if ( + newParentGo == null + && !( + parentToken.Type == JTokenType.Null + || ( + parentToken.Type == JTokenType.String + && string.IsNullOrEmpty(parentToken.ToString()) + ) + ) + ) + { + return Response.Error($"New parent ('{parentToken}') not found."); + } + if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) + { + return Response.Error( + $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." + ); + } + if (targetGo.transform.parent != (newParentGo?.transform)) + { + targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true + modified = true; + } + } + + // Set Active State + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + // Change Tag (using consolidated 'tag' parameter) + string tag = @params["tag"]?.ToString(); + // Only attempt to change tag if a non-null tag is provided and it's different from the current one. + // Allow setting an empty string to remove the tag (Unity uses "Untagged"). + if (tag != null && targetGo.tag != tag) + { + // Ensure the tag is not empty, if empty, it means "Untagged" implicitly + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + targetGo.tag = tagToSet; + modified = true; + } + catch (UnityException ex) + { + // Check if the error is specifically because the tag doesn't exist + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + // Attempt to create the tag using internal utility + InternalEditorUtility.AddTag(tagToSet); + // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. + // yield return null; // Cannot yield here, editor script limitation + + // Retry setting the tag immediately after creation + targetGo.tag = tagToSet; + modified = true; + Debug.Log( + $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + // Handle failure during tag creation or the second assignment attempt + Debug.LogError( + $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" + ); + return Response.Error( + $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." + ); + } + } + else + { + // If the exception was for a different reason, return the original error + return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); + } + } + } + + // Change Layer (using consolidated 'layer' parameter) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1 && layerName != "Default") + { + return Response.Error( + $"Invalid layer specified: '{layerName}'. Use a valid layer name." + ); + } + if (layerId != -1 && targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + // Transform Modifications + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + // --- Component Modifications --- + // Note: These might need more specific Undo recording per component + + // Remove Components + if (@params["componentsToRemove"] is JArray componentsToRemoveArray) + { + foreach (var compToken in componentsToRemoveArray) + { + // ... (parsing logic as in CreateGameObject) ... + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error if removal failed + modified = true; + } + } + } + + // Add Components (similar to create) + if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) + { + foreach (var compToken in componentsToAddArrayModify) + { + string typeName = null; + JObject properties = null; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; + modified = true; + } + } + } + + // Set Component Properties + var componentErrors = new List(); + if (@params["componentProperties"] is JObject componentPropertiesObj) + { + foreach (var prop in componentPropertiesObj.Properties()) + { + string compName = prop.Name; + JObject propertiesToSet = prop.Value as JObject; + if (propertiesToSet != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + compName, + propertiesToSet + ); + if (setResult != null) + { + componentErrors.Add(setResult); + } + else + { + modified = true; + } + } + } + } + + // Return component errors if any occurred (after processing all components) + if (componentErrors.Count > 0) + { + // Aggregate flattened error strings to make tests/API assertions simpler + var aggregatedErrors = new System.Collections.Generic.List(); + foreach (var errorObj in componentErrors) + { + try + { + var dataProp = errorObj?.GetType().GetProperty("data"); + var dataVal = dataProp?.GetValue(errorObj); + if (dataVal != null) + { + var errorsProp = dataVal.GetType().GetProperty("errors"); + var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; + if (errorsEnum != null) + { + foreach (var item in errorsEnum) + { + var s = item?.ToString(); + if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); + } + } + } + } + catch { } + } + + return Response.Error( + $"One or more component property operations failed on '{targetGo.name}'.", + new { componentErrors = componentErrors, errors = aggregatedErrors } + ); + } + + if (!modified) + { + // Use the new serializer helper + // return Response.Success( + // $"No modifications applied to GameObject '{targetGo.name}'.", + // GetGameObjectData(targetGo)); + + return Response.Success( + $"No modifications applied to GameObject '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + EditorUtility.SetDirty(targetGo); // Mark scene as dirty + // Use the new serializer helper + return Response.Success( + $"GameObject '{targetGo.name}' modified successfully.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + // return Response.Success( + // $"GameObject '{targetGo.name}' modified successfully.", + // GetGameObjectData(targetGo)); + + } + + private static object DeleteGameObject(JToken targetToken, string searchMethod) + { + // Find potentially multiple objects if name/tag search is used without find_all=false implicitly + List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety + + if (targets.Count == 0) + { + return Response.Error( + $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + List deletedObjects = new List(); + foreach (var targetGo in targets) + { + if (targetGo != null) + { + string goName = targetGo.name; + int goId = targetGo.GetInstanceID(); + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(targetGo); + deletedObjects.Add(new { name = goName, instanceID = goId }); + } + } + + if (deletedObjects.Count > 0) + { + string message = + targets.Count == 1 + ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." + : $"{deletedObjects.Count} GameObjects deleted successfully."; + return Response.Success(message, deletedObjects); + } + else + { + // Should not happen if targets.Count > 0 initially, but defensive check + return Response.Error("Failed to delete target GameObject(s)."); + } + } + + private static object FindGameObjects( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + bool findAll = @params["findAll"]?.ToObject() ?? false; + List foundObjects = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + @params + ); + + if (foundObjects.Count == 0) + { + return Response.Success("No matching GameObjects found.", new List()); + } + + // Use the new serializer helper + //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); + var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); + return Response.Success($"Found {results.Count} GameObject(s).", results); + } + + private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) + { + GameObject targetGo = FindObjectInternal(target, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." + ); + } + + try + { + // --- 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; + 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 --- + + 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) + { + // 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 + { + var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); + if (data != null) // Ensure GetComponentData didn't return null + { + componentData.Insert(0, data); // Insert at beginning to maintain original order in final list + } + // else + // { + // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); + // } + } + catch (Exception ex) + { + Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); + // Optionally add placeholder data or just skip + componentData.Insert(0, new JObject( // Insert error marker at beginning + new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), + new JProperty("instanceID", c.GetInstanceID()), + new JProperty("error", ex.Message) + )); + } + } + // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); + + // Cleanup the list we created + componentsToIterate.Clear(); + componentsToIterate = null; + + return Response.Success( + $"Retrieved {componentData.Count} components from '{targetGo.name}'.", + componentData // List was built in original order + ); + } + catch (Exception e) + { + return Response.Error( + $"Error getting components from '{targetGo.name}': {e.Message}" + ); + } + } + + private static object GetSingleComponentFromTarget(string target, string searchMethod, string componentName, bool includeNonPublicSerialized = true) + { + GameObject targetGo = FindObjectInternal(target, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." + ); + } + + try + { + // Try to find the component by name + Component targetComponent = targetGo.GetComponent(componentName); + + // If not found directly, try to find by type name (handle namespaces) + if (targetComponent == null) + { + Component[] allComponents = targetGo.GetComponents(); + foreach (Component comp in allComponents) + { + if (comp != null) + { + string typeName = comp.GetType().Name; + string fullTypeName = comp.GetType().FullName; + + if (typeName == componentName || fullTypeName == componentName) + { + targetComponent = comp; + break; + } + } + } + } + + if (targetComponent == null) + { + return Response.Error( + $"Component '{componentName}' not found on GameObject '{targetGo.name}'." + ); + } + + var componentData = Helpers.GameObjectSerializer.GetComponentData(targetComponent, includeNonPublicSerialized); + + if (componentData == null) + { + return Response.Error( + $"Failed to serialize component '{componentName}' on GameObject '{targetGo.name}'." + ); + } + + return Response.Success( + $"Retrieved component '{componentName}' from '{targetGo.name}'.", + componentData + ); + } + catch (Exception e) + { + return Response.Error( + $"Error getting component '{componentName}' from '{targetGo.name}': {e.Message}" + ); + } + } + + private static object AddComponentToTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + JObject properties = null; + + // Allow adding component specified directly or via componentsToAdd array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name + } + else if ( + @params["componentsToAdd"] is JArray componentsToAddArray + && componentsToAddArray.Count > 0 + ) + { + var compToken = componentsToAddArray.First; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToAdd') is required." + ); + } + + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Component '{typeName}' added to '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); // Return updated GO data + } + + private static object RemoveComponentFromTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + // Allow removing component specified directly or via componentsToRemove array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + } + else if ( + @params["componentsToRemove"] is JArray componentsToRemoveArray + && componentsToRemoveArray.Count > 0 + ) + { + typeName = componentsToRemoveArray.First?.ToString(); + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToRemove') is required." + ); + } + + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Component '{typeName}' removed from '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + private static object SetComponentPropertyOnTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string compName = @params["componentName"]?.ToString(); + JObject propertiesToSet = null; + + if (!string.IsNullOrEmpty(compName)) + { + // Properties might be directly under componentProperties or nested under the component name + if (@params["componentProperties"] is JObject compProps) + { + propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure + } + } + else + { + return Response.Error("'componentName' parameter is required."); + } + + if (propertiesToSet == null || !propertiesToSet.HasValues) + { + return Response.Error( + "'componentProperties' dictionary for the specified component is required and cannot be empty." + ); + } + + var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); + if (setResult != null) + return setResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Properties set for component '{compName}' on '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + // --- Internal Helpers --- + + /// + /// Parses a JArray like [x, y, z] into a Vector3. + /// + private static Vector3? ParseVector3(JArray array) + { + if (array != null && array.Count == 3) + { + try + { + return new Vector3( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); + } + } + return null; + } + + /// + /// Finds a single GameObject based on token (ID, name, path) and search method. + /// + private static GameObject FindObjectInternal( + JToken targetToken, + string searchMethod, + JObject findParams = null + ) + { + // If find_all is not explicitly false, we still want only one for most single-target operations. + bool findAll = findParams?["findAll"]?.ToObject() ?? false; + // If a specific target ID is given, always find just that one. + if ( + targetToken?.Type == JTokenType.Integer + || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) + ) + { + findAll = false; + } + List results = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + findParams + ); + return results.Count > 0 ? results[0] : null; + } + + /// + /// Core logic for finding GameObjects based on various criteria. + /// + private static List FindObjectsInternal( + JToken targetToken, + string searchMethod, + bool findAll, + JObject findParams = null + ) + { + List results = new List(); + string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself + bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; + bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; + + // Default search method if not specified + if (string.IsNullOrEmpty(searchMethod)) + { + if (targetToken?.Type == JTokenType.Integer) + searchMethod = "by_id"; + else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) + searchMethod = "by_path"; + else + searchMethod = "by_name"; // Default fallback + } + + GameObject rootSearchObject = null; + // If searching in children, find the initial target first + if (searchInChildren && targetToken != null) + { + rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search + if (rootSearchObject == null) + { + Debug.LogWarning( + $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." + ); + return results; // Return empty if root not found + } + } + + switch (searchMethod) + { + case "by_id": + if (int.TryParse(searchTerm, out int instanceId)) + { + // EditorUtility.InstanceIDToObject is slow, iterate manually if possible + // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + var allObjects = GetAllSceneObjects(searchInactive); // More efficient + GameObject obj = allObjects.FirstOrDefault(go => + go.GetInstanceID() == instanceId + ); + if (obj != null) + results.Add(obj); + } + break; + case "by_name": + var searchPoolName = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); + break; + case "by_path": + // Path is relative to scene root or rootSearchObject + Transform foundTransform = rootSearchObject + ? rootSearchObject.transform.Find(searchTerm) + : GameObject.Find(searchTerm)?.transform; + if (foundTransform != null) + results.Add(foundTransform.gameObject); + break; + case "by_tag": + var searchPoolTag = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); + break; + case "by_layer": + var searchPoolLayer = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + if (int.TryParse(searchTerm, out int layerIndex)) + { + results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); + } + else + { + int namedLayer = LayerMask.NameToLayer(searchTerm); + if (namedLayer != -1) + results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); + } + break; + case "by_component": + Type componentType = FindType(searchTerm); + if (componentType != null) + { + // Determine FindObjectsInactive based on the searchInactive flag + FindObjectsInactive findInactive = searchInactive + ? FindObjectsInactive.Include + : FindObjectsInactive.Exclude; + // Replace FindObjectsOfType with FindObjectsByType, specifying the sorting mode and inactive state + var searchPoolComp = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(componentType, searchInactive) + .Select(c => (c as Component).gameObject) + : UnityEngine + .Object.FindObjectsByType( + componentType, + findInactive, + FindObjectsSortMode.None + ) + .Select(c => (c as Component).gameObject); + results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Find] Component type not found: {searchTerm}" + ); + } + break; + case "by_id_or_name_or_path": // Helper method used internally + if (int.TryParse(searchTerm, out int id)) + { + var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup + GameObject objById = allObjectsId.FirstOrDefault(go => + go.GetInstanceID() == id + ); + if (objById != null) + { + results.Add(objById); + break; + } + } + GameObject objByPath = GameObject.Find(searchTerm); + if (objByPath != null) + { + results.Add(objByPath); + break; + } + + var allObjectsName = GetAllSceneObjects(true); + results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); + break; + default: + Debug.LogWarning( + $"[ManageGameObject.Find] Unknown search method: {searchMethod}" + ); + break; + } + + // If only one result is needed, return just the first one found. + if (!findAll && results.Count > 1) + { + return new List { results[0] }; + } + + return results.Distinct().ToList(); // Ensure uniqueness + } + + // Helper to get all scene objects efficiently + private static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() + var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + var allObjects = new List(); + foreach (var root in rootObjects) + { + allObjects.AddRange( + root.GetComponentsInChildren(includeInactive) + .Select(t => t.gameObject) + ); + } + return allObjects; + } + + /// + /// Adds a component by type name and optionally sets properties. + /// Returns null on success, or an error response object on failure. + /// + private static object AddComponentInternal( + GameObject targetGo, + string typeName, + JObject properties + ) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error( + $"Component type '{typeName}' not found or is not a valid Component." + ); + } + if (!typeof(Component).IsAssignableFrom(componentType)) + { + return Response.Error($"Type '{typeName}' is not a Component."); + } + + // Prevent adding Transform again + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot add another Transform component."); + } + + // Check for 2D/3D physics component conflicts + bool isAdding2DPhysics = + typeof(Rigidbody2D).IsAssignableFrom(componentType) + || typeof(Collider2D).IsAssignableFrom(componentType); + bool isAdding3DPhysics = + typeof(Rigidbody).IsAssignableFrom(componentType) + || typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + // Check if the GameObject already has any 3D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." + ); + } + } + else if (isAdding3DPhysics) + { + // Check if the GameObject already has any 2D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." + ); + } + } + + try + { + // Use Undo.AddComponent for undo support + Component newComponent = Undo.AddComponent(targetGo, componentType); + if (newComponent == null) + { + return Response.Error( + $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." + ); + } + + // Set default values for specific component types + if (newComponent is Light light) + { + // Default newly added lights to directional + light.type = LightType.Directional; + } + + // Set properties if provided + if (properties != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + typeName, + properties, + newComponent + ); // Pass the new component instance + if (setResult != null) + { + // If setting properties failed, maybe remove the added component? + Undo.DestroyObjectImmediate(newComponent); + return setResult; // Return the error from setting properties + } + } + + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Removes a component by type name. + /// Returns null on success, or an error response object on failure. + /// + private static object RemoveComponentInternal(GameObject targetGo, string typeName) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error($"Component type '{typeName}' not found for removal."); + } + + // Prevent removing essential components + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot remove the Transform component."); + } + + Component componentToRemove = targetGo.GetComponent(componentType); + if (componentToRemove == null) + { + return Response.Error( + $"Component '{typeName}' not found on '{targetGo.name}' to remove." + ); + } + + try + { + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(componentToRemove); + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Sets properties on a component. + /// Returns null on success, or an error response object on failure. + /// + private static object SetComponentPropertiesInternal( + GameObject targetGo, + string compName, + JObject propertiesToSet, + Component targetComponentInstance = null + ) + { + Component targetComponent = targetComponentInstance; + if (targetComponent == null) + { + if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) + { + targetComponent = targetGo.GetComponent(compType); + } + else + { + targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup + } + } + if (targetComponent == null) + { + return Response.Error( + $"Component '{compName}' not found on '{targetGo.name}' to set properties." + ); + } + + Undo.RecordObject(targetComponent, "Set Component Properties"); + + var failures = new List(); + foreach (var prop in propertiesToSet.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + + try + { + bool setResult = SetProperty(targetComponent, propName, propValue); + if (!setResult) + { + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); + var msg = suggestions.Any() + ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" + : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; + Debug.LogWarning($"[ManageGameObject] {msg}"); + failures.Add(msg); + } + } + catch (Exception e) + { + Debug.LogError( + $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" + ); + failures.Add($"Error setting '{propName}': {e.Message}"); + } + } + EditorUtility.SetDirty(targetComponent); + return failures.Count == 0 + ? null + : Response.Error($"One or more properties failed on '{compName}'.", new { errors = failures }); + } + + /// + /// Helper to set a property or field via reflection, handling basic types. + /// + private static bool SetProperty(object target, string memberName, JToken value) + { + Type type = target.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Use shared serializer to avoid per-call allocation + var inputSerializer = InputSerializer; + + try + { + // Handle special case for materials with dot notation (material.property) + // Examples: material.color, sharedMaterial.color, materials[0].color + if (memberName.Contains('.') || memberName.Contains('[')) + { + // Pass the inputSerializer down for nested conversions + return SetNestedProperty(target, memberName, value, inputSerializer); + } + + PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null + { + 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 + { + FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) // Check if !IsLiteral? + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); + 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 + { + // Try NonPublic [SerializeField] fields + var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (npField != null && npField.GetCustomAttribute() != null) + { + object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + npField.SetValue(target, convertedValue); + return true; + } + } + } + } + } + catch (Exception ex) + { + Debug.LogError( + $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}" + ); + } + return false; + } + + /// + /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") + /// + // Pass the input serializer for conversions + //Using the serializer helper + private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) + { + try + { + // Split the path into parts (handling both dot notation and array indexing) + string[] pathParts = SplitPropertyPath(path); + if (pathParts.Length == 0) + return false; + + object currentObject = target; + Type currentType = currentObject.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Traverse the path until we reach the final property + for (int i = 0; i < pathParts.Length - 1; i++) + { + string part = pathParts[i]; + bool isArray = false; + int arrayIndex = -1; + + // Check if this part contains array indexing + if (part.Contains("[")) + { + int startBracket = part.IndexOf('['); + int endBracket = part.IndexOf(']'); + if (startBracket > 0 && endBracket > startBracket) + { + string indexStr = part.Substring( + startBracket + 1, + endBracket - startBracket - 1 + ); + if (int.TryParse(indexStr, out arrayIndex)) + { + isArray = true; + part = part.Substring(0, startBracket); + } + } + } + // Get the property/field + PropertyInfo propInfo = currentType.GetProperty(part, flags); + FieldInfo fieldInfo = null; + if (propInfo == null) + { + fieldInfo = currentType.GetField(part, flags); + if (fieldInfo == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" + ); + return false; + } + } + + // Get the value + currentObject = + propInfo != null + ? propInfo.GetValue(currentObject) + : fieldInfo.GetValue(currentObject); + //Need to stop if current property is null + if (currentObject == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." + ); + return false; + } + // If this part was an array or list, access the specific index + if (isArray) + { + if (currentObject is Material[]) + { + var materials = currentObject as Material[]; + if (arrayIndex < 0 || arrayIndex >= materials.Length) + { + Debug.LogWarning( + $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" + ); + return false; + } + currentObject = materials[arrayIndex]; + } + else if (currentObject is System.Collections.IList) + { + var list = currentObject as System.Collections.IList; + if (arrayIndex < 0 || arrayIndex >= list.Count) + { + Debug.LogWarning( + $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" + ); + return false; + } + currentObject = list[arrayIndex]; + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." + ); + return false; + } + } + currentType = currentObject.GetType(); + } + + // Set the final property + string finalPart = pathParts[pathParts.Length - 1]; + + // Special handling for Material properties (shader properties) + if (currentObject is Material material && finalPart.StartsWith("_")) + { + // Use the serializer to convert the JToken value first + if (value is JArray jArray) + { + // Try converting to known types that SetColor/SetVector accept + 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) + { + try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color + } + else if (jArray.Count == 2) + { + try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + try { material.SetFloat(finalPart, value.ToObject(inputSerializer)); return true; } catch { } + } + else if (value.Type == JTokenType.Boolean) + { + try { material.SetFloat(finalPart, value.ToObject(inputSerializer) ? 1f : 0f); return true; } catch { } + } + else if (value.Type == JTokenType.String) + { + // Try converting to Texture using the serializer/converter + try + { + Texture texture = value.ToObject(inputSerializer); + if (texture != null) + { + material.SetTexture(finalPart, texture); + return true; + } + } + catch { } + } + + Debug.LogWarning( + $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" + ); + return false; + } + + // For standard properties (not shader specific) + PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); + if (finalPropInfo != null && finalPropInfo.CanWrite) + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalPropInfo.SetValue(currentObject, convertedValue); + return true; + } + else + { + Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); + } + } + else + { + FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); + if (finalFieldInfo != null) + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalFieldInfo.SetValue(currentObject, convertedValue); + return true; + } + else + { + Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); + } + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" + ); + } + } + } + catch (Exception ex) + { + Debug.LogError( + $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" + ); + } + + return false; + } + + + /// + /// Split a property path into parts, handling both dot notation and array indexers + /// + private static string[] SplitPropertyPath(string path) + { + // Handle complex paths with both dots and array indexers + List parts = new List(); + int startIndex = 0; + bool inBrackets = false; + + for (int i = 0; i < path.Length; i++) + { + char c = path[i]; + + if (c == '[') + { + inBrackets = true; + } + else if (c == ']') + { + inBrackets = false; + } + else if (c == '.' && !inBrackets) + { + // Found a dot separator outside of brackets + parts.Add(path.Substring(startIndex, i - startIndex)); + startIndex = i + 1; + } + } + if (startIndex < path.Length) + { + parts.Add(path.Substring(startIndex)); + } + return parts.ToArray(); + } + + /// + /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. + /// + // Pass the input serializer + private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) + { + if (token == null || token.Type == JTokenType.Null) + { + if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) + { + Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); + return Activator.CreateInstance(targetType); + } + return null; + } + + try + { + // Use the provided serializer instance which includes our custom converters + return token.ToObject(targetType, inputSerializer); + } + 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 + } + catch (ArgumentException argEx) + { + Debug.LogError($"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}"); + throw; + } + catch (Exception ex) + { + 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; + } + + // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- + // Keep them temporarily for reference or if specific fallback logic is ever needed. + + private static Vector3 ParseJTokenToVector3(JToken token) + { + // ... (implementation - likely replaced by Vector3Converter) ... + // Consider removing these if the serializer handles them reliably. + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) + { + return new Vector3(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject()); + } + if (token is JArray arr && arr.Count >= 3) + { + 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; + + } + private static Vector2 ParseJTokenToVector2(JToken token) + { + // ... (implementation - likely replaced by Vector2Converter) ... + 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()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); + return Vector2.zero; + } + private static Quaternion ParseJTokenToQuaternion(JToken token) + { + // ... (implementation - likely replaced by QuaternionConverter) ... + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Quaternion(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject(), obj["w"].ToObject()); + } + if (token is JArray arr && arr.Count >= 4) + { + 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) ... + 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()); + } + 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) ... + 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()); + } + 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) ... + 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) + return new Bounds(center, size); + } + // Array fallback for Bounds is less intuitive, maybe remove? + // if (token is JArray arr && arr.Count >= 6) + // { + // return new Bounds(new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()), new Vector3(arr[3].ToObject(), arr[4].ToObject(), arr[5].ToObject())); + // } + Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); + return new Bounds(Vector3.zero, Vector3.zero); + } + // --- 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 + { + // 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; + } + } + + + /// + /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. + /// Searches already-loaded assemblies, prioritizing runtime script assemblies. + /// + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + 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. + /// + internal static class ComponentResolver + { + private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); + private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); + + /// + /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. + /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. + /// Never uses Assembly.LoadFrom. + /// + public static bool TryResolve(string nameOrFullName, out Type type, out string error) + { + error = string.Empty; + type = null!; + + // Handle null/empty input + if (string.IsNullOrWhiteSpace(nameOrFullName)) + { + error = "Component name cannot be null or empty"; + return false; + } + + // 1) Exact cache hits + if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; + if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; + type = Type.GetType(nameOrFullName, throwOnError: false); + if (IsValidComponent(type)) { Cache(type); return true; } + + // 2) Search loaded assemblies (prefer Player assemblies) + var candidates = FindCandidates(nameOrFullName); + if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } + if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } + +#if UNITY_EDITOR + // 3) Last resort: Editor-only TypeCache (fast index) + var tc = TypeCache.GetTypesDerivedFrom() + .Where(t => NamesMatch(t, nameOrFullName)); + candidates = PreferPlayer(tc).ToList(); + if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } + if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } +#endif + + error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; + type = null!; + return false; + } + + private static bool NamesMatch(Type t, string q) => + t.Name.Equals(q, StringComparison.Ordinal) || + (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); + + private static bool IsValidComponent(Type t) => + t != null && typeof(Component).IsAssignableFrom(t); + + private static void Cache(Type t) + { + if (t.FullName != null) CacheByFqn[t.FullName] = t; + CacheByName[t.Name] = t; + } + + private static List FindCandidates(string query) + { + bool isShort = !query.Contains('.'); + var loaded = AppDomain.CurrentDomain.GetAssemblies(); + +#if UNITY_EDITOR + // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) + var playerAsmNames = new HashSet( + UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + IEnumerable playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); + IEnumerable editorAsms = loaded.Except(playerAsms); +#else + IEnumerable playerAsms = loaded; + IEnumerable editorAsms = Array.Empty(); +#endif + static IEnumerable SafeGetTypes(System.Reflection.Assembly a) + { + try { return a.GetTypes(); } + catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } + } + + Func match = isShort + ? (t => t.Name.Equals(query, StringComparison.Ordinal)) + : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); + + var fromPlayer = playerAsms.SelectMany(SafeGetTypes) + .Where(IsValidComponent) + .Where(match); + var fromEditor = editorAsms.SelectMany(SafeGetTypes) + .Where(IsValidComponent) + .Where(match); + + var list = new List(fromPlayer); + if (list.Count == 0) list.AddRange(fromEditor); + return list; + } + +#if UNITY_EDITOR + private static IEnumerable PreferPlayer(IEnumerable seq) + { + var player = new HashSet( + UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); + } +#endif + + private static string Ambiguity(string query, IEnumerable cands) + { + var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); + return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + + "\nProvide a fully qualified type name to disambiguate."; + } + + /// + /// Gets all accessible property and field names from a component type. + /// + public static List GetAllComponentProperties(Type componentType) + { + if (componentType == null) return new List(); + + var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => p.Name); + + var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) + .Where(f => !f.IsInitOnly && !f.IsLiteral) + .Select(f => f.Name); + + // Also include SerializeField private fields (common in Unity) + var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.GetCustomAttribute() != null) + .Select(f => f.Name); + + return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); + } + + /// + /// Uses AI to suggest the most likely property matches for a user's input. + /// + public static List GetAIPropertySuggestions(string userInput, List availableProperties) + { + if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) + return new List(); + + // Simple caching to avoid repeated AI calls for the same input + var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; + if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + + $"User requested: \"{userInput}\"\n" + + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + + $"Find 1-3 most likely matches considering:\n" + + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + + $"- camelCase vs PascalCase vs spaces\n" + + $"- Similar meaning/semantics\n" + + $"- Common Unity naming patterns\n\n" + + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + + $"If confidence is low (<70%), return empty string.\n\n" + + $"Examples:\n" + + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + + $"- \"Health Points\" → \"healthPoints, hp\"\n" + + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; + + // For now, we'll use a simple rule-based approach that mimics AI behavior + // This can be replaced with actual AI calls later + var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); + + PropertySuggestionCache[cacheKey] = suggestions; + return suggestions; + } + catch (Exception ex) + { + Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); + return new List(); + } + } + + private static readonly Dictionary> PropertySuggestionCache = new(); + + /// + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. + /// + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) + { + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) + { + var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + // Exact match after cleaning + if (cleanedProperty == cleanedInput) + { + suggestions.Add(property); + continue; + } + + // Check if property contains all words from input + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) + { + suggestions.Add(property); + continue; + } + + // Levenshtein distance for close matches + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); + } + } + + // Prioritize exact matches, then by similarity + return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) + .Take(3) + .ToList(); + } + + /// + /// Calculates Levenshtein distance between two strings for similarity matching. + /// + private static int LevenshteinDistance(string s1, string s2) + { + if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; + if (string.IsNullOrEmpty(s2)) return s1.Length; + + var matrix = new int[s1.Length + 1, s2.Length + 1]; + + for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; + for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; + + for (int i = 1; i <= s1.Length; i++) + { + for (int j = 1; j <= s2.Length; j++) + { + int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, // deletion + matrix[i, j - 1] + 1), // insertion + matrix[i - 1, j - 1] + cost); // substitution + } + } + + return matrix[s1.Length, s2.Length]; + } + + // Removed duplicate ParseVector3 - using the one at line 1114 + + // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. + // They are now in Helpers.GameObjectSerializer + } +} diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs.meta b/MCPForUnity/Editor/Tools/ManageGameObject.cs.meta new file mode 100644 index 00000000..5093c861 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7641d7388f0f6634b9d83d34de87b2ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs new file mode 100644 index 00000000..6a310d02 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using MCPForUnity.Editor.Helpers; // For Response class + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles scene management operations like loading, saving, creating, and querying hierarchy. + /// + [McpForUnityTool("manage_scene")] + public static class ManageScene + { + private sealed class SceneCommand + { + public string action { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string path { get; set; } = string.Empty; + public int? buildIndex { get; set; } + } + + private static SceneCommand ToSceneCommand(JObject p) + { + if (p == null) return new SceneCommand(); + int? BI(JToken t) + { + if (t == null || t.Type == JTokenType.Null) return null; + var s = t.ToString().Trim(); + if (s.Length == 0) return null; + if (int.TryParse(s, out var i)) return i; + if (double.TryParse(s, out var d)) return (int)d; + return t.Type == JTokenType.Integer ? t.Value() : (int?)null; + } + return new SceneCommand + { + action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), + name = p["name"]?.ToString() ?? string.Empty, + path = p["path"]?.ToString() ?? string.Empty, + buildIndex = BI(p["buildIndex"] ?? p["build_index"]) + }; + } + + /// + /// Main handler for scene management actions. + /// + public static object HandleCommand(JObject @params) + { + try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } + var cmd = ToSceneCommand(@params); + string action = cmd.action; + string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; + string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ + int? buildIndex = cmd.buildIndex; + // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension + + // Ensure path is relative to Assets/, removing any leading "Assets/" + string relativeDir = path ?? string.Empty; + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + + // Apply default *after* sanitizing, using the original path variable for the check + if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness + { + relativeDir = "Scenes"; // Default relative directory + } + + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}.unity"; + // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) + string fullPath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine(fullPathDir, sceneFileName); + // Ensure relativePath always starts with "Assets/" and uses forward slashes + string relativePath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); + + // Ensure directory exists for 'create' + if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route action + try { McpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } + switch (action) + { + case "create": + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) + return Response.Error( + "'name' and 'path' parameters are required for 'create' action." + ); + return CreateScene(fullPath, relativePath); + case "load": + // Loading can be done by path/name or build index + if (!string.IsNullOrEmpty(relativePath)) + return LoadScene(relativePath); + else if (buildIndex.HasValue) + return LoadScene(buildIndex.Value); + else + return Response.Error( + "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." + ); + case "save": + // Save current scene, optionally to a new path + return SaveScene(fullPath, relativePath); + case "get_hierarchy": + try { McpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } + var gh = GetSceneHierarchy(); + try { McpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } + return gh; + case "get_active": + try { McpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } + var ga = GetActiveSceneInfo(); + try { McpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } + return ga; + case "get_build_settings": + return GetBuildSettingsScenes(); + // Add cases for modifying build settings, additive loading, unloading etc. + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions: create, load, save, get_hierarchy, get_active, get_build_settings." + ); + } + } + + private static object CreateScene(string fullPath, string relativePath) + { + if (File.Exists(fullPath)) + { + return Response.Error($"Scene already exists at '{relativePath}'."); + } + + try + { + // Create a new empty scene + Scene newScene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, + NewSceneMode.Single + ); + // Save it to the specified path + bool saved = EditorSceneManager.SaveScene(newScene, relativePath); + + if (saved) + { + AssetDatabase.Refresh(); // Ensure Unity sees the new scene file + return Response.Success( + $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + else + { + // If SaveScene fails, it might leave an untitled scene open. + // Optionally try to close it, but be cautious. + return Response.Error($"Failed to save new scene to '{relativePath}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(string relativePath) + { + if ( + !File.Exists( + Path.Combine( + Application.dataPath.Substring( + 0, + Application.dataPath.Length - "Assets".Length + ), + relativePath + ) + ) + ) + { + return Response.Error($"Scene file not found at '{relativePath}'."); + } + + // Check for unsaved changes in the current scene + if (EditorSceneManager.GetActiveScene().isDirty) + { + // Optionally prompt the user or save automatically before loading + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); + // if (!saveOK) return Response.Error("Load cancelled by user."); + } + + try + { + EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); + return Response.Success( + $"Scene '{relativePath}' loaded successfully.", + new + { + path = relativePath, + name = Path.GetFileNameWithoutExtension(relativePath), + } + ); + } + catch (Exception e) + { + return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(int buildIndex) + { + if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) + { + return Response.Error( + $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." + ); + } + + // Check for unsaved changes + if (EditorSceneManager.GetActiveScene().isDirty) + { + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + } + + try + { + string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); + EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); + return Response.Success( + $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", + new + { + path = scenePath, + name = Path.GetFileNameWithoutExtension(scenePath), + buildIndex = buildIndex, + } + ); + } + catch (Exception e) + { + return Response.Error( + $"Error loading scene with build index {buildIndex}: {e.Message}" + ); + } + } + + private static object SaveScene(string fullPath, string relativePath) + { + try + { + Scene currentScene = EditorSceneManager.GetActiveScene(); + if (!currentScene.IsValid()) + { + return Response.Error("No valid scene is currently active to save."); + } + + bool saved; + string finalPath = currentScene.path; // Path where it was last saved or will be saved + + if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) + { + // Save As... + // Ensure directory exists + string dir = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + saved = EditorSceneManager.SaveScene(currentScene, relativePath); + finalPath = relativePath; + } + else + { + // Save (overwrite existing or save untitled) + if (string.IsNullOrEmpty(currentScene.path)) + { + // Scene is untitled, needs a path + return Response.Error( + "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." + ); + } + saved = EditorSceneManager.SaveScene(currentScene); + } + + if (saved) + { + AssetDatabase.Refresh(); + return Response.Success( + $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", + new { path = finalPath, name = currentScene.name } + ); + } + else + { + return Response.Error($"Failed to save scene '{currentScene.name}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error saving scene: {e.Message}"); + } + } + + private static object GetActiveSceneInfo() + { + try + { + try { McpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + Scene activeScene = EditorSceneManager.GetActiveScene(); + try { McpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid()) + { + return Response.Error("No active scene found."); + } + + var sceneInfo = new + { + name = activeScene.name, + path = activeScene.path, + buildIndex = activeScene.buildIndex, // -1 if not in build settings + isDirty = activeScene.isDirty, + isLoaded = activeScene.isLoaded, + rootCount = activeScene.rootCount, + }; + + return Response.Success("Retrieved active scene information.", sceneInfo); + } + catch (Exception e) + { + try { McpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } + return Response.Error($"Error getting active scene info: {e.Message}"); + } + } + + private static object GetBuildSettingsScenes() + { + try + { + var scenes = new List(); + for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) + { + var scene = EditorBuildSettings.scenes[i]; + scenes.Add( + new + { + path = scene.path, + guid = scene.guid.ToString(), + enabled = scene.enabled, + buildIndex = i, // Actual build index considering only enabled scenes might differ + } + ); + } + return Response.Success("Retrieved scenes from Build Settings.", scenes); + } + catch (Exception e) + { + return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); + } + } + + private static object GetSceneHierarchy() + { + try + { + try { McpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + Scene activeScene = EditorSceneManager.GetActiveScene(); + try { McpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return Response.Error( + "No valid and loaded scene is active to get hierarchy from." + ); + } + + try { McpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } + GameObject[] rootObjects = activeScene.GetRootGameObjects(); + try { McpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } + var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); + + var resp = Response.Success( + $"Retrieved hierarchy for scene '{activeScene.name}'.", + hierarchy + ); + try { McpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } + return resp; + } + catch (Exception e) + { + try { McpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } + return Response.Error($"Error getting scene hierarchy: {e.Message}"); + } + } + + /// + /// Recursively builds a data representation of a GameObject and its children. + /// + private static object GetGameObjectDataRecursive(GameObject go) + { + if (go == null) + return null; + + var childrenData = new List(); + foreach (Transform child in go.transform) + { + childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); + } + + var gameObjectData = new Dictionary + { + { "name", go.name }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "instanceID", go.GetInstanceID() }, // Useful unique identifier + { + "transform", + new + { + position = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, // Euler for simplicity + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + } + }, + { "children", childrenData }, + }; + + return gameObjectData; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs.meta b/MCPForUnity/Editor/Tools/ManageScene.cs.meta new file mode 100644 index 00000000..532618aa --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageScene.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6ddda47f4077e74fbb5092388cefcc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs new file mode 100644 index 00000000..2d970486 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -0,0 +1,2661 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; +using System.Threading; +using System.Security.Cryptography; + +#if USE_ROSLYN +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; +#endif + +#if UNITY_EDITOR +using UnityEditor.Compilation; +#endif + + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles CRUD operations for C# scripts within the Unity project. + /// + /// ROSLYN INSTALLATION GUIDE: + /// To enable advanced syntax validation with Roslyn compiler services: + /// + /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: + /// - Open Package Manager in Unity + /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity + /// + /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: + /// + /// 3. Alternative: Manual DLL installation: + /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies + /// - Place in Assets/Plugins/ folder + /// - Ensure .NET compatibility settings are correct + /// + /// 4. Define USE_ROSLYN symbol: + /// - Go to Player Settings > Scripting Define Symbols + /// - Add "USE_ROSLYN" to enable Roslyn-based validation + /// + /// 5. Restart Unity after installation + /// + /// Note: Without Roslyn, the system falls back to basic structural validation. + /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. + /// + [McpForUnityTool("manage_script")] + public static class ManageScript + { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = Application.dataPath.Replace('\\', '/'); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); + rel = rel.TrimStart('/'); + + string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); + string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + while (di != null) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + var atAssets = string.Equals( + di.FullName.Replace('\\', '/'), + assets, + StringComparison.OrdinalIgnoreCase + ); + if (atAssets) break; + di = di.Parent; + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } + /// + /// Main handler for script management actions. + /// + public static object HandleCommand(JObject @params) + { + // Handle null parameters + if (@params == null) + { + return Response.Error("invalid_params", "Parameters cannot be null."); + } + + // Extract parameters + string action = @params["action"]?.ToString()?.ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode script contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation + string namespaceName = @params["namespace"]?.ToString(); // For organizing code + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + return Response.Error("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) + { + return Response.Error( + $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) + { + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + } + + // Construct file paths + string scriptFileName = $"{name}.cs"; + string fullPath = Path.Combine(fullPathDir, scriptFileName); + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateScript( + fullPath, + relativePath, + name, + contents, + scriptType, + namespaceName + ); + case "read": + McpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); + case "update": + McpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + return UpdateScript(fullPath, relativePath, name, contents); + case "delete": + return DeleteScript(fullPath, relativePath); + case "apply_text_edits": + { + var textEdits = @params["edits"] as JArray; + string precondition = @params["precondition_sha256"]?.ToString(); + // Respect optional options + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + var m = Regex.Match( + s, + @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", + RegexOptions.CultureInvariant | RegexOptions.Multiline, + TimeSpan.FromMilliseconds(250) + ); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); + } + case "edit": + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var structEdits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, structEdits, options); + case "get_sha": + { + try + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + + string text = File.ReadAllText(fullPath); + string sha = ComputeSha256(text); + var fi = new FileInfo(fullPath); + long lengthBytes; + try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } + catch { lengthBytes = fi.Exists ? fi.Length : 0; } + var data = new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + sha256 = sha, + lengthBytes, + lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty + }; + return Response.Success($"SHA computed for '{relativePath}'.", data); + } + catch (Exception ex) + { + return Response.Error($"Failed to compute SHA: {ex.Message}"); + } + } + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateScript( + string fullPath, + string relativePath, + string name, + string contents, + string scriptType, + string namespaceName + ) + { + // Check if script already exists + if (File.Exists(fullPath)) + { + return Response.Error( + $"Script already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block creation + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Atomic create without BOM; schedule refresh after reply + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + try + { + File.Move(tmp, fullPath); + } + catch (IOException) + { + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } + + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( + $"Script '{name}.cs' created successfully at '{relativePath}'.", + new { uri, scheduledRefresh = false } + ); + + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + } + } + + private static object ReadScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"unity://path/{relativePath}"; + var responseData = new + { + uri, + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + } + } + + private static object UpdateScript( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return Response.Error( + $"Script not found at '{relativePath}'. Use 'create' action to add a new script." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block update + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Safe write with atomic replace when available, without BOM + var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (IOException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( + $"Script '{name}.cs' updated successfully at '{relativePath}'.", + new { uri, path = relativePath, scheduledRefresh = true } + ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + } + } + + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private const int MaxEditPayloadBytes = 64 * 1024; + + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits, + string preconditionSha256, + string refreshModeFromCaller = null, + string validateMode = null) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target or any ancestor is a symlink + try + { + var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); + while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase)) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + di = di.Parent; + } + } + catch + { + // If checking attributes fails, proceed without the symlink guard + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + // Require precondition to avoid drift on large files + string currentSha = ComputeSha256(original); + if (string.IsNullOrEmpty(preconditionSha256)) + return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) + return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + long totalBytes = 0; + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present + // Find first top-level using (supports alias, static, and dotted namespaces) + var mUsing = System.Text.RegularExpressions.Regex.Match( + original, + @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", + System.Text.RegularExpressions.RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(2) + ); + if (mUsing.Success) + { + headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); + } + foreach (var sp in spans) + { + if (sp.start < headerBoundary) + { + return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + } + } + + // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method + if (spans.Count == 1) + { + var sp = spans[0]; + // Heuristic: around the start of the edit, try to match a method header in original + int searchStart = Math.Max(0, sp.start - 200); + int searchEnd = Math.Min(original.Length, sp.start + 200); + string slice = original.Substring(searchStart, searchEnd - searchStart); + var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); + var mh = rx.Match(slice); + if (mh.Success) + { + string methodName = mh.Groups[1].Value; + // Find class span containing the edit + if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) + { + if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) + { + // If the edit overlaps the method span significantly, treat as replace_method + if (sp.start <= mStart + 2 && sp.end >= mStart + 1) + { + var structEdits = new JArray(); + + // Apply the edit to get a candidate string, then recompute method span on the edited text + string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string replacementText; + if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) + && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) + { + replacementText = candidate.Substring(m2Start, m2Len); + } + else + { + // Fallback: adjust method start by the net delta if the edit was before the method + int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); + int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); + adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); + + // If the edit was within the original method span, adjust the length by the delta within-method + int withinMethodDelta = 0; + if (sp.start >= mStart && sp.start <= mStart + mLen) + { + withinMethodDelta = delta; + } + int adjustedLen = mLen + withinMethodDelta; + adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); + replacementText = candidate.Substring(adjustedStart, adjustedLen); + } + + var op = new JObject + { + ["mode"] = "replace_method", + ["className"] = name, + ["methodName"] = methodName, + ["replacement"] = replacementText + }; + structEdits.Add(op); + // Reuse structured path + return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" }); + } + } + } + } + } + + if (totalBytes > MaxEditPayloadBytes) + { + return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + { + var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + + string working = original; + bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); + bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); + foreach (var sp in spans) + { + string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + if (relaxed) + { + // Scoped balance check: validate just around the changed region to avoid false positives + int originalLength = sp.end - sp.start; + int newLength = sp.text?.Length ?? 0; + int endPos = sp.start + newLength; + if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) + { + return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + } + } + working = next; + } + + // No-op guard: if resulting text is identical, avoid writes and return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + string noChangeSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = 0, + no_op = true, + sha256 = noChangeSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Always check final structural balance regardless of relaxed mode + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); + } + +#if USE_ROSLYN + if (!syntaxOnly) + { + var tree = CSharpSyntaxTree.ParseText(working); + var diagnostics = tree.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).Take(3) + .Select(d => new { + line = d.Location.GetLineSpan().StartLinePosition.Line + 1, + col = d.Location.GetLineSpan().StartLinePosition.Character + 1, + code = d.Id, + message = d.GetMessage() + }).ToArray(); + if (diagnostics.Length > 0) + { + int firstLine = diagnostics[0].line; + int startLineRos = Math.Max(1, firstLine - 5); + int endLineRos = firstLine + 5; + return Response.Error("syntax_error", new { status = "syntax_error", diagnostics, evidenceWindow = new { startLine = startLineRos, endLine = endLineRos } }); + } + + // Optional formatting + try + { + var root = tree.GetRoot(); + var workspace = new AdhocWorkspace(); + root = Microsoft.CodeAnalysis.Formatting.Formatter.Format(root, workspace); + working = root.ToFullString(); + } + catch { } + } +#endif + + string newSha = ComputeSha256(working); + + // Atomic write and schedule refresh + try + { + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + // Respect refresh mode: immediate vs debounced + bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || + string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); + if (immediate) + { + McpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + else + { + McpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + + return Response.Success( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = spans.Count, + sha256 = newSha, + scheduledRefresh = !immediate + } + ); + } + catch (Exception ex) + { + return Response.Error($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } + } + index = -1; + return false; + } + + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + + // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context + private static bool CheckScopedBalance(string text, int start, int end) + { + start = Math.Max(0, Math.Min(text.Length, start)); + end = Math.Max(start, Math.Min(text.Length, end)); + int brace = 0, paren = 0, bracket = 0; + bool inStr = false, inChr = false, esc = false; + for (int i = start; i < end; i++) + { + char c = text[i]; + char n = (i + 1 < end) ? text[i + 1] : '\0'; + if (inStr) + { + if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; + } + if (inChr) + { + if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; + } + if (c == '"') { inStr = true; esc = false; continue; } + if (c == '\'') { inChr = true; esc = false; continue; } + if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } + if (c == '{') brace++; + else if (c == '}') brace--; + else if (c == '(') paren++; + else if (c == ')') paren--; + else if (c == '[') bracket++; else if (c == ']') bracket--; + // Allow temporary negative balance - will check tolerance at end + } + return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region + } + + private static object DeleteScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + } + + try + { + // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) + bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); + if (deleted) + { + AssetDatabase.Refresh(); + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } + ); + } + else + { + // Fallback or error if MoveAssetToTrash fails + return Response.Error( + $"Failed to move script '{relativePath}' to trash. It might be locked or in use." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + } + } + + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // ignore failures checking attributes and proceed + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("replace_class requires 'className'."); + if (replacement == null) + return Response.Error("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return Response.Error($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return Response.Error($"Replacement snippet invalid: {vErr}"); + + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return Response.Error($"delete_class failed: {why}"); + + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); + if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + // Harden: refuse empty replacement for inserts + if (snippet == null || snippet.Trim().Length == 0) + return Response.Error("insert_method requires a non-empty 'replacement' text."); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); + if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + break; + } + + case "anchor_insert": + { + string anchor = op.Value("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("text") ?? ExtractReplacement(op); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + int insAt = position == "after" ? m.Index + m.Length : m.Index; + string norm = NormalizeNewlines(text); + if (!norm.EndsWith("\n")) + { + norm += "\n"; + } + + // Duplicate guard: if identical snippet already exists within this class, skip insert + if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + { + string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); + if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + { + // Do not insert duplicate; treat as no-op + break; + } + } + if (applySequentially) + { + working = working.Insert(insAt, norm); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_insert failed: {ex.Message}"); + } + break; + } + + case "anchor_delete": + { + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + int delAt = m.Index; + int delLen = m.Length; + if (applySequentially) + { + working = working.Remove(delAt, delLen); + appliedCount++; + } + else + { + replacements.Add((delAt, delLen, string.Empty)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_delete failed: {ex.Message}"); + } + break; + } + + case "anchor_replace": + { + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + int at = m.Index; + int len = m.Length; + string norm = NormalizeNewlines(replacement); + if (applySequentially) + { + working = working.Remove(at, len).Insert(at, norm); + appliedCount++; + } + else + { + replacements.Add((at, len, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_replace failed: {ex.Message}"); + } + break; + } + + default: + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); + } + } + + if (!applySequentially) + { + if (HasOverlaps(replacements)) + { + var ordered = replacements.OrderByDescending(r => r.start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + if (ordered[i].start + ordered[i].length > ordered[i - 1].start) + { + var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + return Response.Error("overlap", new { status = "overlap" }); + } + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } + + // Guard against structural imbalance before validation + if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) + return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); + + // No-op guard for structured edits: if text unchanged, return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + var sameSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = 0, + no_op = true, + sha256 = sameSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); + else if (errors != null && errors.Length > 0) + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + // Persist changes atomically (no BOM), then compute/return new file SHA + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + var backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + var newSha = ComputeSha256(working); + var ok = Response.Success( + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = appliedCount, + scheduledRefresh = !immediate, + sha256 = newSha + } + ); + + if (immediate) + { + McpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return Response.Error($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 1) Find the method header using a stricter regex (allows optional attributes above) + string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); + string namePattern = Regex.Escape(methodName); + // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so + // we can safely embed the signature inside our own parenthesis group without duplicating. + string paramsPattern; + if (string.IsNullOrEmpty(parametersSignature)) + { + paramsPattern = @"[\s\S]*?"; // permissive when not specified + } + else + { + string ps = parametersSignature.Trim(); + if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) + { + ps = ps.Substring(1, ps.Length - 2); + } + // Escape literal text of the signature + paramsPattern = Regex.Escape(ps); + } + string pattern = + @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (; ; ) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + + /// + /// Generates basic C# script content based on name and type. + /// + private static string GenerateDefaultScriptContent( + string name, + string scriptType, + string namespaceName + ) + { + string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; + string classDeclaration; + string body = + "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; + + string baseClass = ""; + if (!string.IsNullOrEmpty(scriptType)) + { + if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) + baseClass = " : MonoBehaviour"; + else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) + { + baseClass = " : ScriptableObject"; + body = ""; // ScriptableObjects don't usually need Start/Update + } + else if ( + scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) + || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) + ) + { + usingStatements += "using UnityEditor;\n"; + if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) + baseClass = " : Editor"; + else + baseClass = " : EditorWindow"; + body = ""; // Editor scripts have different structures + } + // Add more types as needed + } + + classDeclaration = $"public class {name}{baseClass}"; + + string fullContent = $"{usingStatements}\n"; + bool useNamespace = !string.IsNullOrEmpty(namespaceName); + + if (useNamespace) + { + fullContent += $"namespace {namespaceName}\n{{\n"; + // Indent class and body if using namespace + classDeclaration = " " + classDeclaration; + body = string.Join("\n", body.Split('\n').Select(line => " " + line)); + } + + fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; + + if (useNamespace) + { + fullContent += "\n}"; // Close namespace + } + + return fullContent.Trim() + "\n"; // Ensure a trailing newline + } + + /// + /// Gets the validation level from the GUI settings + /// + private static ValidationLevel GetValidationLevelFromGUI() + { + string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); + return savedLevel.ToLower() switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => ValidationLevel.Standard // Default fallback + }; + } + + /// + /// Validates C# script syntax using multiple validation layers. + /// + private static bool ValidateScriptSyntax(string contents) + { + return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); + } + + /// + /// Advanced syntax validation with detailed diagnostics and configurable strictness. + /// + private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) + { + var errorList = new System.Collections.Generic.List(); + errors = null; + + if (string.IsNullOrEmpty(contents)) + { + return true; // Empty content is valid + } + + // Basic structural validation + if (!ValidateBasicStructure(contents, errorList)) + { + errors = errorList.ToArray(); + return false; + } + +#if USE_ROSLYN + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) + { + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } + } +#endif + + // Unity-specific validation + if (level >= ValidationLevel.Standard) + { + ValidateScriptSyntaxUnity(contents, errorList); + } + + // Semantic analysis for common issues + if (level >= ValidationLevel.Comprehensive) + { + ValidateSemanticRules(contents, errorList); + } + +#if USE_ROSLYN + // Full semantic compilation validation for Strict level + if (level == ValidationLevel.Strict) + { + if (!ValidateScriptSemantics(contents, errorList)) + { + errors = errorList.ToArray(); + return false; // Strict level fails on any semantic errors + } + } +#endif + + errors = errorList.ToArray(); + return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); + } + + /// + /// Validation strictness levels + /// + private enum ValidationLevel + { + Basic, // Only syntax errors + Standard, // Syntax + Unity best practices + Comprehensive, // All checks + semantic analysis + Strict // Treat all issues as errors + } + + /// + /// Validates basic code structure (braces, quotes, comments) + /// + private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors) + { + bool isValid = true; + int braceBalance = 0; + int parenBalance = 0; + int bracketBalance = 0; + bool inStringLiteral = false; + bool inCharLiteral = false; + bool inSingleLineComment = false; + bool inMultiLineComment = false; + bool escaped = false; + + for (int i = 0; i < contents.Length; i++) + { + char c = contents[i]; + char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; + + // Handle escape sequences + if (escaped) + { + escaped = false; + continue; + } + + if (c == '\\' && (inStringLiteral || inCharLiteral)) + { + escaped = true; + continue; + } + + // Handle comments + if (!inStringLiteral && !inCharLiteral) + { + if (c == '/' && next == '/' && !inMultiLineComment) + { + inSingleLineComment = true; + continue; + } + if (c == '/' && next == '*' && !inSingleLineComment) + { + inMultiLineComment = true; + i++; // Skip next character + continue; + } + if (c == '*' && next == '/' && inMultiLineComment) + { + inMultiLineComment = false; + i++; // Skip next character + continue; + } + } + + if (c == '\n') + { + inSingleLineComment = false; + continue; + } + + if (inSingleLineComment || inMultiLineComment) + continue; + + // Handle string and character literals + if (c == '"' && !inCharLiteral) + { + inStringLiteral = !inStringLiteral; + continue; + } + if (c == '\'' && !inStringLiteral) + { + inCharLiteral = !inCharLiteral; + continue; + } + + if (inStringLiteral || inCharLiteral) + continue; + + // Count brackets and braces + switch (c) + { + case '{': braceBalance++; break; + case '}': braceBalance--; break; + case '(': parenBalance++; break; + case ')': parenBalance--; break; + case '[': bracketBalance++; break; + case ']': bracketBalance--; break; + } + + // Check for negative balances (closing without opening) + if (braceBalance < 0) + { + errors.Add("ERROR: Unmatched closing brace '}'"); + isValid = false; + } + if (parenBalance < 0) + { + errors.Add("ERROR: Unmatched closing parenthesis ')'"); + isValid = false; + } + if (bracketBalance < 0) + { + errors.Add("ERROR: Unmatched closing bracket ']'"); + isValid = false; + } + } + + // Check final balances + if (braceBalance != 0) + { + errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); + isValid = false; + } + if (parenBalance != 0) + { + errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); + isValid = false; + } + if (bracketBalance != 0) + { + errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); + isValid = false; + } + if (inStringLiteral) + { + errors.Add("ERROR: Unterminated string literal"); + isValid = false; + } + if (inCharLiteral) + { + errors.Add("ERROR: Unterminated character literal"); + isValid = false; + } + if (inMultiLineComment) + { + errors.Add("WARNING: Unterminated multi-line comment"); + } + + return isValid; + } + +#if USE_ROSLYN + /// + /// Cached compilation references for performance + /// + private static System.Collections.Generic.List _cachedReferences = null; + private static DateTime _cacheTime = DateTime.MinValue; + private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); + + /// + /// Validates syntax using Roslyn compiler services + /// + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + var diagnostics = syntaxTree.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + string severity = diagnostic.Severity.ToString().ToUpper(); + string message = $"{severity}: {diagnostic.GetMessage()}"; + + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + } + + // Include warnings in comprehensive mode + if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now + { + var location = diagnostic.Location.GetLineSpan(); + if (location.IsValid) + { + message += $" (Line {location.StartLinePosition.Line + 1})"; + } + errors.Add(message); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors + /// + private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List errors) + { + try + { + // Get compilation references with caching + var references = GetCompilationReferences(); + if (references == null || references.Count == 0) + { + errors.Add("WARNING: Could not load compilation references for semantic validation"); + return true; // Don't fail if we can't get references + } + + // Create syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + + // Create compilation with full context + var compilation = CSharpCompilation.Create( + "TempValidation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Get semantic diagnostics - this catches all the issues you mentioned! + var diagnostics = compilation.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + // Include diagnostic ID for better error identification + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + else if (diagnostic.Severity == DiagnosticSeverity.Warning) + { + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Gets compilation references with caching for performance + /// + private static System.Collections.Generic.List GetCompilationReferences() + { + // Check cache validity + if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) + { + return _cachedReferences; + } + + try + { + var references = new System.Collections.Generic.List(); + + // Core .NET assemblies + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib + references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq + references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections + + // Unity assemblies + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); + } + +#if UNITY_EDITOR + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); + } + + // Get Unity project assemblies + try + { + var assemblies = CompilationPipeline.GetAssemblies(); + foreach (var assembly in assemblies) + { + if (File.Exists(assembly.outputPath)) + { + references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); + } +#endif + + // Cache the results + _cachedReferences = references; + _cacheTime = DateTime.Now; + + return references; + } + catch (Exception ex) + { + Debug.LogError($"Failed to get compilation references: {ex.Message}"); + return new System.Collections.Generic.List(); + } + } +#else + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + // Fallback when Roslyn is not available + return true; + } +#endif + + /// + /// Validates Unity-specific coding rules and best practices + /// //TODO: Naive Unity Checks and not really yield any results, need to be improved + /// + private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List errors) + { + // Check for common Unity anti-patterns + if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) + { + errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); + } + + if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) + { + errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); + } + + // Check for proper MonoBehaviour usage + if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); + } + + // Check for SerializeField usage + if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); + } + + // Check for proper coroutine usage + if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) + { + errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); + } + + // Check for Update without FixedUpdate for physics + if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) + { + errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); + } + + // Check for missing null checks on Unity objects + if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) + { + errors.Add("WARNING: Consider null checking GetComponent results"); + } + + // Check for proper event function signatures + if (contents.Contains("void Start(") && !contents.Contains("void Start()")) + { + errors.Add("WARNING: Start() should not have parameters"); + } + + if (contents.Contains("void Update(") && !contents.Contains("void Update()")) + { + errors.Add("WARNING: Update() should not have parameters"); + } + + // Check for inefficient string operations + if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) + { + errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); + } + } + + /// + /// Validates semantic rules and common coding issues + /// + private static void ValidateSemanticRules(string contents, System.Collections.Generic.List errors) + { + // Check for potential memory leaks + if (contents.Contains("new ") && contents.Contains("Update()")) + { + errors.Add("WARNING: Creating objects in Update() may cause memory issues"); + } + + // Check for magic numbers + var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var matches = magicNumberPattern.Matches(contents); + if (matches.Count > 5) + { + errors.Add("WARNING: Consider using named constants instead of magic numbers"); + } + + // Check for long methods (simple line count check) + var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var methodMatches = methodPattern.Matches(contents); + foreach (Match match in methodMatches) + { + int startIndex = match.Index; + int braceCount = 0; + int lineCount = 0; + bool inMethod = false; + + for (int i = startIndex; i < contents.Length; i++) + { + if (contents[i] == '{') + { + braceCount++; + inMethod = true; + } + else if (contents[i] == '}') + { + braceCount--; + if (braceCount == 0 && inMethod) + break; + } + else if (contents[i] == '\n' && inMethod) + { + lineCount++; + } + } + + if (lineCount > 50) + { + errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); + break; // Only report once + } + } + + // Check for proper exception handling + if (contents.Contains("catch") && contents.Contains("catch()")) + { + errors.Add("WARNING: Empty catch blocks should be avoided"); + } + + // Check for proper async/await usage + if (contents.Contains("async ") && !contents.Contains("await")) + { + errors.Add("WARNING: Async method should contain await or return Task"); + } + + // Check for hardcoded tags and layers + if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) + { + errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); + } + } + + //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) + /// + /// Public method to validate script syntax with configurable validation level + /// Returns detailed validation results including errors and warnings + /// + // public static object ValidateScript(JObject @params) + // { + // string contents = @params["contents"]?.ToString(); + // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; + + // if (string.IsNullOrEmpty(contents)) + // { + // return Response.Error("Contents parameter is required for validation."); + // } + + // // Parse validation level + // ValidationLevel level = ValidationLevel.Standard; + // switch (validationLevel.ToLower()) + // { + // case "basic": level = ValidationLevel.Basic; break; + // case "standard": level = ValidationLevel.Standard; break; + // case "comprehensive": level = ValidationLevel.Comprehensive; break; + // case "strict": level = ValidationLevel.Strict; break; + // default: + // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // } + + // // Perform validation + // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); + + // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; + // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; + + // var result = new + // { + // isValid = isValid, + // validationLevel = validationLevel, + // errorCount = errors.Length, + // warningCount = warnings.Length, + // errors = errors, + // warnings = warnings, + // summary = isValid + // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") + // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" + // }; + + // if (isValid) + // { + // return Response.Success("Script validation completed successfully.", result); + // } + // else + // { + // return Response.Error("Script validation failed.", result); + // } + // } + } +} + +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // The timestamp of the most recent schedule request. + private static DateTime _lastRequest; + + // Guard to ensure we only have a single ticking callback running. + private static bool _scheduled; + + public static void Schedule(string relPath, TimeSpan window) + { + // Record that work is pending and track the path in a threadsafe way. + Interlocked.Exchange(ref _pending, 1); + lock (_lock) + { + _paths.Add(relPath); + _lastRequest = DateTime.UtcNow; + + // If a debounce timer is already scheduled it will pick up the new request. + if (_scheduled) + return; + + _scheduled = true; + } + + // Kick off a ticking callback that waits until the window has elapsed + // from the last request before performing the refresh. + EditorApplication.delayCall += () => Tick(window); + // Nudge the editor loop so ticks run even if the window is unfocused + EditorApplication.QueuePlayerLoopUpdate(); + } + + private static void Tick(TimeSpan window) + { + bool ready; + lock (_lock) + { + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) + { + _scheduled = false; + } + } + + if (!ready) + { + // Window has not yet elapsed; check again on the next editor tick. + EditorApplication.delayCall += () => Tick(window); + return; + } + + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + { + var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); + AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + } +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + } +} + +static class ManageScriptRefreshHelpers +{ + public static string SanitizeAssetsPath(string p) + { + if (string.IsNullOrEmpty(p)) return p; + p = p.Replace('\\', '/').Trim(); + if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("unity://path/".Length); + while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("Assets/".Length); + if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + p = "Assets/" + p.TrimStart('/'); + return p; + } + + public static void ScheduleScriptRefresh(string relPath) + { + var sp = SanitizeAssetsPath(relPath); + RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); + } + + public static void ImportAndRequestCompile(string relPath, bool synchronous = true) + { + var sp = SanitizeAssetsPath(relPath); + var opts = ImportAssetOptions.ForceUpdate; + if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; + AssetDatabase.ImportAsset(sp, opts); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } +} diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs.meta b/MCPForUnity/Editor/Tools/ManageScript.cs.meta new file mode 100644 index 00000000..091cfe1c --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 626d2d44668019a45ae52e9ee066b7ec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs new file mode 100644 index 00000000..2d7f4d0a --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -0,0 +1,343 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles CRUD operations for shader files within the Unity project. + /// + [McpForUnityTool("manage_shader")] + public static class ManageShader + { + /// + /// Main handler for shader management actions. + /// + public static object HandleCommand(JObject @params) + { + // Extract parameters + string action = @params["action"]?.ToString().ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode shader contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + return Response.Error("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + return Response.Error( + $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Ensure path is relative to Assets/, removing any leading "Assets/" + // Set default directory to "Shaders" if path is not provided + string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + // Handle empty string case explicitly after processing + if (string.IsNullOrEmpty(relativeDir)) + { + relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" + } + + // Construct paths + string shaderFileName = $"{name}.shader"; + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); + string fullPath = Path.Combine(fullPathDir, shaderFileName); + string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) + .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + if (!Directory.Exists(fullPathDir)) + { + Directory.CreateDirectory(fullPathDir); + // Refresh AssetDatabase to recognize new folders + AssetDatabase.Refresh(); + } + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateShader(fullPath, relativePath, name, contents); + case "read": + return ReadShader(fullPath, relativePath); + case "update": + return UpdateShader(fullPath, relativePath, name, contents); + case "delete": + return DeleteShader(fullPath, relativePath); + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: create, read, update, delete." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + // Check if shader already exists + if (File.Exists(fullPath)) + { + return Response.Error( + $"Shader already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Add validation for shader name conflicts in Unity + if (Shader.Find(name) != null) + { + return Response.Error( + $"A shader with name '{name}' already exists in the project. Choose a different name." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultShaderContent(name); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader + return Response.Success( + $"Shader '{name}.shader' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); + } + } + + private static object ReadShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Shader not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + //TODO: Consider a threshold for large files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var responseData = new + { + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return Response.Success( + $"Shader '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); + } + } + + private static object UpdateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return Response.Error( + $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(); + return Response.Success( + $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); + } + } + + private static object DeleteShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Shader not found at '{relativePath}'."); + } + + try + { + // Delete the asset through Unity's AssetDatabase first + bool success = AssetDatabase.DeleteAsset(relativePath); + if (!success) + { + return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); + } + + // If the file still exists (rare case), try direct deletion + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); + } + catch (Exception e) + { + return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); + } + } + + //This is a CGProgram template + //TODO: making a HLSL template as well? + private static string GenerateDefaultShaderContent(string name) + { + return @"Shader """ + name + @""" + { + Properties + { + _MainTex (""Texture"", 2D) = ""white"" {} + } + SubShader + { + Tags { ""RenderType""=""Opaque"" } + LOD 100 + + Pass + { + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include ""UnityCG.cginc"" + + struct appdata + { + float4 vertex : POSITION; + float2 uv : TEXCOORD0; + }; + + struct v2f + { + float2 uv : TEXCOORD0; + float4 vertex : SV_POSITION; + }; + + sampler2D _MainTex; + float4 _MainTex_ST; + + v2f vert (appdata v) + { + v2f o; + o.vertex = UnityObjectToClipPos(v.vertex); + o.uv = TRANSFORM_TEX(v.uv, _MainTex); + return o; + } + + fixed4 frag (v2f i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + return col; + } + ENDCG + } + } + }"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs.meta b/MCPForUnity/Editor/Tools/ManageShader.cs.meta new file mode 100644 index 00000000..89d10cdd --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageShader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bcf4f1f3110494344b2af9324cf5c571 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs new file mode 100644 index 00000000..bb4e0431 --- /dev/null +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -0,0 +1,37 @@ +using System; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Marks a class as an MCP tool handler for auto-discovery. + /// The class must have a public static HandleCommand(JObject) method. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class McpForUnityToolAttribute : Attribute + { + /// + /// The command name used to route requests to this tool. + /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// + public string CommandName { get; } + + /// + /// Create an MCP tool attribute with auto-generated command name. + /// The command name will be derived from the class name (PascalCase → snake_case). + /// Example: ManageAsset → manage_asset + /// + public McpForUnityToolAttribute() + { + CommandName = null; // Will be auto-generated + } + + /// + /// Create an MCP tool attribute with explicit command name. + /// + /// The command name (e.g., "manage_asset") + public McpForUnityToolAttribute(string commandName) + { + CommandName = commandName; + } + } +} diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta new file mode 100644 index 00000000..57242c17 --- /dev/null +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 804d07b886f4e4eb39316bbef34687c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/MenuItems.meta b/MCPForUnity/Editor/Tools/MenuItems.meta new file mode 100644 index 00000000..ffbda8e7 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2df8f144c6e684ec3bfd53e4a48f06ee +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs b/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs new file mode 100644 index 00000000..e4b7eaf7 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -0,0 +1,42 @@ +using System; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.MenuItems +{ + [McpForUnityTool("manage_menu_item")] + public static class ManageMenuItem + { + /// + /// Routes actions: execute, list, exists, refresh + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required. Valid actions are: execute, list, exists, refresh."); + } + + try + { + switch (action) + { + case "execute": + return MenuItemExecutor.Execute(@params); + case "list": + return MenuItemsReader.List(@params); + case "exists": + return MenuItemsReader.Exists(@params); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: execute, list, exists, refresh."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageMenuItem] Action '{action}' failed: {e}"); + return Response.Error($"Internal error: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs.meta b/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs.meta new file mode 100644 index 00000000..aba1f496 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/ManageMenuItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77808278b21a6474a90f3abb91483f71 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs b/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs new file mode 100644 index 00000000..193a80f6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEditor; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.MenuItems +{ + /// + /// Executes Unity Editor menu items by path with safety checks. + /// + public static class MenuItemExecutor + { + // Basic blacklist to prevent execution of disruptive menu items. + private static readonly HashSet _menuPathBlacklist = new HashSet( + StringComparer.OrdinalIgnoreCase) + { + "File/Quit", + }; + + /// + /// Execute a specific menu item. Expects 'menu_path' or 'menuPath' in params. + /// + public static object Execute(JObject @params) + { + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + if (_menuPathBlacklist.Contains(menuPath)) + { + return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons."); + } + + try + { + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (!executed) + { + McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent."); + } + return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); + } + catch (Exception e) + { + McpLog.Error($"[MenuItemExecutor] Failed to setup execution for '{menuPath}': {e}"); + return Response.Error($"Error setting up execution for menu item '{menuPath}': {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta b/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta new file mode 100644 index 00000000..2e9f4223 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/MenuItemExecutor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ccc7c6ff549542e1ae4ba3463ae79d2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs b/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs new file mode 100644 index 00000000..60c94125 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEditor; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.MenuItems +{ + /// + /// Provides read/list/exists capabilities for Unity menu items with caching. + /// + public static class MenuItemsReader + { + private static List _cached; + + [InitializeOnLoadMethod] + private static void Build() => Refresh(); + + /// + /// Returns the cached list, refreshing if necessary. + /// + public static IReadOnlyList AllMenuItems() => _cached ??= Refresh(); + + /// + /// Rebuilds the cached list from reflection. + /// + private static List Refresh() + { + try + { + var methods = TypeCache.GetMethodsWithAttribute(); + _cached = methods + // Methods can have multiple [MenuItem] attributes; collect them all + .SelectMany(m => m + .GetCustomAttributes(typeof(MenuItem), false) + .OfType() + .Select(attr => attr.menuItem)) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.Ordinal) // Ensure no duplicates + .OrderBy(s => s, StringComparer.Ordinal) // Ensure consistent ordering + .ToList(); + return _cached; + } + catch (Exception e) + { + McpLog.Error($"[MenuItemsReader] Failed to scan menu items: {e}"); + _cached = _cached ?? new List(); + return _cached; + } + } + + /// + /// Returns a list of menu items. Optional 'search' param filters results. + /// + public static object List(JObject @params) + { + string search = @params["search"]?.ToString(); + bool doRefresh = @params["refresh"]?.ToObject() ?? false; + if (doRefresh || _cached == null) + { + Refresh(); + } + + IEnumerable result = _cached ?? Enumerable.Empty(); + if (!string.IsNullOrEmpty(search)) + { + result = result.Where(s => s.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0); + } + + return Response.Success("Menu items retrieved.", result.ToList()); + } + + /// + /// Checks if a given menu path exists in the cache. + /// + public static object Exists(JObject @params) + { + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + bool doRefresh = @params["refresh"]?.ToObject() ?? false; + if (doRefresh || _cached == null) + { + Refresh(); + } + + bool exists = (_cached ?? new List()).Contains(menuPath); + return Response.Success($"Exists check completed for '{menuPath}'.", new { exists }); + } + } +} diff --git a/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs.meta b/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs.meta new file mode 100644 index 00000000..78fd7ab4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/MenuItems/MenuItemsReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37f212f83e8854ed7b5454d3733e4bfa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Prefabs.meta b/MCPForUnity/Editor/Tools/Prefabs.meta new file mode 100644 index 00000000..4fb95c50 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bd48a1b7555c46bba168078ce0291cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs new file mode 100644 index 00000000..9e68d20e --- /dev/null +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.Prefabs +{ + [McpForUnityTool("manage_prefabs")] + public static class ManagePrefabs + { + private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return Response.Error("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString()?.ToLowerInvariant(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}."); + } + + try + { + switch (action) + { + case "open_stage": + return OpenStage(@params); + case "close_stage": + return CloseStage(@params); + case "save_open_stage": + return SaveOpenStage(); + case "create_from_gameobject": + return CreatePrefabFromGameObject(@params); + default: + return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}"); + return Response.Error($"Internal error: {e.Message}"); + } + } + + private static object OpenStage(JObject @params) + { + string prefabPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrEmpty(prefabPath)) + { + return Response.Error("'prefabPath' parameter is required for open_stage."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath); + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedPath); + if (prefabAsset == null) + { + return Response.Error($"No prefab asset found at path '{sanitizedPath}'."); + } + + string modeValue = @params["mode"]?.ToString(); + if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase)) + { + return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time."); + } + + PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath); + if (stage == null) + { + return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'."); + } + + return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage)); + } + + private static object CloseStage(JObject @params) + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Success("No prefab stage was open."); + } + + bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject() ?? false; + if (saveBeforeClose && stage.scene.isDirty) + { + SaveStagePrefab(stage); + AssetDatabase.SaveAssets(); + } + + StageUtility.GoToMainStage(); + return Response.Success($"Closed prefab stage for '{stage.assetPath}'."); + } + + private static object SaveOpenStage() + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Error("No prefab stage is currently open."); + } + + SaveStagePrefab(stage); + AssetDatabase.SaveAssets(); + return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage)); + } + + private static void SaveStagePrefab(PrefabStage stage) + { + if (stage?.prefabContentsRoot == null) + { + throw new InvalidOperationException("Cannot save prefab stage without a prefab root."); + } + + bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath); + if (!saved) + { + throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'."); + } + } + + private static object CreatePrefabFromGameObject(JObject @params) + { + string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString(); + if (string.IsNullOrEmpty(targetName)) + { + return Response.Error("'target' parameter is required for create_from_gameobject."); + } + + bool includeInactive = @params["searchInactive"]?.ToObject() ?? false; + GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive); + if (sourceObject == null) + { + return Response.Error($"GameObject '{targetName}' not found in the active scene."); + } + + if (PrefabUtility.IsPartOfPrefabAsset(sourceObject)) + { + return Response.Error( + $"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead." + ); + } + + PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject); + if (status != PrefabInstanceStatus.NotAPrefab) + { + return Response.Error( + $"GameObject '{sourceObject.name}' is already linked to an existing prefab instance." + ); + } + + string requestedPath = @params["prefabPath"]?.ToString(); + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return Response.Error("'prefabPath' parameter is required for create_from_gameobject."); + } + + string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath); + if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + sanitizedPath += ".prefab"; + } + + bool allowOverwrite = @params["allowOverwrite"]?.ToObject() ?? false; + string finalPath = sanitizedPath; + + if (!allowOverwrite && AssetDatabase.LoadAssetAtPath(finalPath) != null) + { + finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath); + } + + EnsureAssetDirectoryExists(finalPath); + + try + { + GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + sourceObject, + finalPath, + InteractionMode.AutomatedAction + ); + + if (connectedInstance == null) + { + return Response.Error($"Failed to save prefab asset at '{finalPath}'."); + } + + Selection.activeGameObject = connectedInstance; + + return Response.Success( + $"Prefab created at '{finalPath}' and instance linked.", + new + { + prefabPath = finalPath, + instanceId = connectedInstance.GetInstanceID() + } + ); + } + catch (Exception e) + { + return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}"); + } + } + + private static void EnsureAssetDirectoryExists(string assetPath) + { + string directory = Path.GetDirectoryName(assetPath); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory); + if (!Directory.Exists(fullDirectory)) + { + Directory.CreateDirectory(fullDirectory); + AssetDatabase.Refresh(); + } + } + + private static GameObject FindSceneObjectByName(string name, bool includeInactive) + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage?.prefabContentsRoot != null) + { + foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren(includeInactive)) + { + if (transform.name == name) + { + return transform.gameObject; + } + } + } + + Scene activeScene = SceneManager.GetActiveScene(); + foreach (GameObject root in activeScene.GetRootGameObjects()) + { + foreach (Transform transform in root.GetComponentsInChildren(includeInactive)) + { + GameObject candidate = transform.gameObject; + if (candidate.name == name) + { + return candidate; + } + } + } + + return null; + } + + private static object SerializeStage(PrefabStage stage) + { + if (stage == null) + { + return new { isOpen = false }; + } + + return new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + }; + } + + } +} diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta new file mode 100644 index 00000000..27182e77 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c14e76b2aa7bb4570a88903b061e946e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs new file mode 100644 index 00000000..e94e5d51 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; +using MCPForUnity.Editor.Helpers; // For Response class + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Handles reading and clearing Unity Editor console log entries. + /// Uses reflection to access internal LogEntry methods/properties. + /// + [McpForUnityTool("read_console")] + public static class ReadConsole + { + // (Calibration removed) + + // Reflection members for accessing internal LogEntry data + // private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection + private static MethodInfo _startGettingEntriesMethod; + private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End... + private static MethodInfo _clearMethod; + private static MethodInfo _getCountMethod; + private static MethodInfo _getEntryMethod; + private static FieldInfo _modeField; + private static FieldInfo _messageField; + private static FieldInfo _fileField; + private static FieldInfo _lineField; + private static FieldInfo _instanceIdField; + + // Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative? + + // Static constructor for reflection setup + static ReadConsole() + { + try + { + Type logEntriesType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntries" + ); + if (logEntriesType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntries"); + + + + // Include NonPublic binding flags as internal APIs might change accessibility + BindingFlags staticFlags = + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + BindingFlags instanceFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + _startGettingEntriesMethod = logEntriesType.GetMethod( + "StartGettingEntries", + staticFlags + ); + if (_startGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.StartGettingEntries"); + + // Try reflecting EndGettingEntries based on warning message + _endGettingEntriesMethod = logEntriesType.GetMethod( + "EndGettingEntries", + staticFlags + ); + if (_endGettingEntriesMethod == null) + throw new Exception("Failed to reflect LogEntries.EndGettingEntries"); + + _clearMethod = logEntriesType.GetMethod("Clear", staticFlags); + if (_clearMethod == null) + throw new Exception("Failed to reflect LogEntries.Clear"); + + _getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags); + if (_getCountMethod == null) + throw new Exception("Failed to reflect LogEntries.GetCount"); + + _getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags); + if (_getEntryMethod == null) + throw new Exception("Failed to reflect LogEntries.GetEntryInternal"); + + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception("Could not find internal type UnityEditor.LogEntry"); + + _modeField = logEntryType.GetField("mode", instanceFlags); + if (_modeField == null) + throw new Exception("Failed to reflect LogEntry.mode"); + + _messageField = logEntryType.GetField("message", instanceFlags); + if (_messageField == null) + throw new Exception("Failed to reflect LogEntry.message"); + + _fileField = logEntryType.GetField("file", instanceFlags); + if (_fileField == null) + throw new Exception("Failed to reflect LogEntry.file"); + + _lineField = logEntryType.GetField("line", instanceFlags); + if (_lineField == null) + throw new Exception("Failed to reflect LogEntry.line"); + + _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); + if (_instanceIdField == null) + throw new Exception("Failed to reflect LogEntry.instanceID"); + + // (Calibration removed) + + } + catch (Exception e) + { + Debug.LogError( + $"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}" + ); + // Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this. + _startGettingEntriesMethod = + _endGettingEntriesMethod = + _clearMethod = + _getCountMethod = + _getEntryMethod = + null; + _modeField = _messageField = _fileField = _lineField = _instanceIdField = null; + } + } + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + // Check if ALL required reflection members were successfully initialized. + if ( + _startGettingEntriesMethod == null + || _endGettingEntriesMethod == null + || _clearMethod == null + || _getCountMethod == null + || _getEntryMethod == null + || _modeField == null + || _messageField == null + || _fileField == null + || _lineField == null + || _instanceIdField == null + ) + { + // Log the error here as well for easier debugging in Unity Console + Debug.LogError( + "[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue." + ); + return Response.Error( + "ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs." + ); + } + + string action = @params["action"]?.ToString().ToLower() ?? "get"; + + try + { + if (action == "clear") + { + return ClearConsole(); + } + else if (action == "get") + { + // Extract parameters for 'get' + var types = + (@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList() + ?? new List { "error", "warning", "log" }; + int? count = @params["count"]?.ToObject(); + string filterText = @params["filterText"]?.ToString(); + string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // TODO: Implement timestamp filtering + string format = (@params["format"]?.ToString() ?? "detailed").ToLower(); + bool includeStacktrace = + @params["includeStacktrace"]?.ToObject() ?? true; + + if (types.Contains("all")) + { + types = new List { "error", "warning", "log" }; // Expand 'all' + } + + if (!string.IsNullOrEmpty(sinceTimestampStr)) + { + Debug.LogWarning( + "[ReadConsole] Filtering by 'since_timestamp' is not currently implemented." + ); + // Need a way to get timestamp per log entry. + } + + return GetConsoleEntries(types, count, filterText, format, includeStacktrace); + } + else + { + return Response.Error( + $"Unknown action: '{action}'. Valid actions are 'get' or 'clear'." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object ClearConsole() + { + try + { + _clearMethod.Invoke(null, null); // Static method, no instance, no parameters + return Response.Success("Console cleared successfully."); + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Failed to clear console: {e}"); + return Response.Error($"Failed to clear console: {e.Message}"); + } + } + + private static object GetConsoleEntries( + List types, + int? count, + string filterText, + string format, + bool includeStacktrace + ) + { + List formattedEntries = new List(); + int retrievedCount = 0; + + try + { + // LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal + _startGettingEntriesMethod.Invoke(null, null); + + int totalEntries = (int)_getCountMethod.Invoke(null, null); + // Create instance to pass to GetEntryInternal - Ensure the type is correct + Type logEntryType = typeof(EditorApplication).Assembly.GetType( + "UnityEditor.LogEntry" + ); + if (logEntryType == null) + throw new Exception( + "Could not find internal type UnityEditor.LogEntry during GetConsoleEntries." + ); + object logEntryInstance = Activator.CreateInstance(logEntryType); + + for (int i = 0; i < totalEntries; i++) + { + // Get the entry data into our instance using reflection + _getEntryMethod.Invoke(null, new object[] { i, logEntryInstance }); + + // Extract data using reflection + int mode = (int)_modeField.GetValue(logEntryInstance); + string message = (string)_messageField.GetValue(logEntryInstance); + string file = (string)_fileField.GetValue(logEntryInstance); + + int line = (int)_lineField.GetValue(logEntryInstance); + // int instanceId = (int)_instanceIdField.GetValue(logEntryInstance); + + if (string.IsNullOrEmpty(message)) + { + continue; // Skip empty messages + } + + // (Calibration removed) + + // --- Filtering --- + // Prefer classifying severity from message/stacktrace; fallback to mode bits if needed + LogType unityType = InferTypeFromMessage(message); + bool isExplicitDebug = IsExplicitDebugLog(message); + if (!isExplicitDebug && unityType == LogType.Log) + { + unityType = GetLogTypeFromMode(mode); + } + + bool want; + // Treat Exception/Assert as errors for filtering convenience + if (unityType == LogType.Exception) + { + want = types.Contains("error") || types.Contains("exception"); + } + else if (unityType == LogType.Assert) + { + want = types.Contains("error") || types.Contains("assert"); + } + else + { + want = types.Contains(unityType.ToString().ToLowerInvariant()); + } + + if (!want) continue; + + // Filter by text (case-insensitive) + if ( + !string.IsNullOrEmpty(filterText) + && message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0 + ) + { + continue; + } + + // TODO: Filter by timestamp (requires timestamp data) + + // --- Formatting --- + string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; + // Always get first line for the message, use full message only if no stack trace exists + string[] messageLines = message.Split( + new[] { '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries + ); + string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; + + // If not including stacktrace, ensure we only show the first line + if (!includeStacktrace) + { + stackTrace = null; + } + + object formattedEntry = null; + switch (format) + { + case "plain": + formattedEntry = messageOnly; + break; + case "json": + case "detailed": // Treat detailed as json for structured return + default: + formattedEntry = new + { + type = unityType.ToString(), + message = messageOnly, + file = file, + line = line, + // timestamp = "", // TODO + stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found + }; + break; + } + + formattedEntries.Add(formattedEntry); + retrievedCount++; + + // Apply count limit (after filtering) + if (count.HasValue && retrievedCount >= count.Value) + { + break; + } + } + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}"); + // Ensure EndGettingEntries is called even if there's an error during iteration + try + { + _endGettingEntriesMethod.Invoke(null, null); + } + catch + { /* Ignore nested exception */ + } + return Response.Error($"Error retrieving log entries: {e.Message}"); + } + finally + { + // Ensure we always call EndGettingEntries + try + { + _endGettingEntriesMethod.Invoke(null, null); + } + catch (Exception e) + { + Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}"); + // Don't return error here as we might have valid data, but log it. + } + } + + // Return the filtered and formatted list (might be empty) + return Response.Success( + $"Retrieved {formattedEntries.Count} log entries.", + formattedEntries + ); + } + + // --- Internal Helpers --- + + // Mapping bits from LogEntry.mode. These may vary by Unity version. + private const int ModeBitError = 1 << 0; + private const int ModeBitAssert = 1 << 1; + private const int ModeBitWarning = 1 << 2; + private const int ModeBitLog = 1 << 3; + private const int ModeBitException = 1 << 4; // often combined with Error bits + private const int ModeBitScriptingError = 1 << 9; + private const int ModeBitScriptingWarning = 1 << 10; + private const int ModeBitScriptingLog = 1 << 11; + private const int ModeBitScriptingException = 1 << 18; + private const int ModeBitScriptingAssertion = 1 << 22; + + private static LogType GetLogTypeFromMode(int mode) + { + // Preserve Unity's real type (no remapping); bits may vary by version + if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception; + if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error; + if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert; + if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning; + return LogType.Log; + } + + // (Calibration helpers removed) + + /// + /// Classifies severity using message/stacktrace content. Works across Unity versions. + /// + private static LogType InferTypeFromMessage(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return LogType.Log; + + // Fast path: look for explicit Debug API names in the appended stack trace + // e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning" + if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + + // Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx" + if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Warning; + if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0 + || fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Error; + + // Exceptions (avoid misclassifying compiler diagnostics) + if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Exception; + + // Unity assertions + if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0) + return LogType.Assert; + + return LogType.Log; + } + + private static bool IsExplicitDebugLog(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) return false; + if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; + } + + /// + /// Applies the "one level lower" remapping for filtering, like the old version. + /// This ensures compatibility with the filtering logic that expects remapped types. + /// + private static LogType GetRemappedTypeForFiltering(LogType unityType) + { + switch (unityType) + { + case LogType.Error: + return LogType.Warning; // Error becomes Warning + case LogType.Warning: + return LogType.Log; // Warning becomes Log + case LogType.Assert: + return LogType.Assert; // Assert remains Assert + case LogType.Log: + return LogType.Log; // Log remains Log + case LogType.Exception: + return LogType.Warning; // Exception becomes Warning + default: + return LogType.Log; // Default fallback + } + } + + /// + /// Attempts to extract the stack trace part from a log message. + /// Unity log messages often have the stack trace appended after the main message, + /// starting on a new line and typically indented or beginning with "at ". + /// + /// The complete log message including potential stack trace. + /// The extracted stack trace string, or null if none is found. + private static string ExtractStackTrace(string fullMessage) + { + if (string.IsNullOrEmpty(fullMessage)) + return null; + + // Split into lines, removing empty ones to handle different line endings gracefully. + // Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here. + string[] lines = fullMessage.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries + ); + + // If there's only one line or less, there's no separate stack trace. + if (lines.Length <= 1) + return null; + + int stackStartIndex = -1; + + // Start checking from the second line onwards. + for (int i = 1; i < lines.Length; ++i) + { + // Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical. + string trimmedLine = lines[i].TrimStart(); + + // Check for common stack trace patterns. + if ( + trimmedLine.StartsWith("at ") + || trimmedLine.StartsWith("UnityEngine.") + || trimmedLine.StartsWith("UnityEditor.") + || trimmedLine.Contains("(at ") + || // Covers "(at Assets/..." pattern + // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) + ( + trimmedLine.Length > 0 + && char.IsUpper(trimmedLine[0]) + && trimmedLine.Contains('.') + ) + ) + { + stackStartIndex = i; + break; // Found the likely start of the stack trace + } + } + + // If a potential start index was found... + if (stackStartIndex > 0) + { + // Join the lines from the stack start index onwards using standard newline characters. + // This reconstructs the stack trace part of the message. + return string.Join("\n", lines.Skip(stackStartIndex)); + } + + // No clear stack trace found based on the patterns. + return null; + } + + /* LogEntry.mode bits exploration (based on Unity decompilation/observation): + May change between versions. + + Basic Types: + kError = 1 << 0 (1) + kAssert = 1 << 1 (2) + kWarning = 1 << 2 (4) + kLog = 1 << 3 (8) + kFatal = 1 << 4 (16) - Often treated as Exception/Error + + Modifiers/Context: + kAssetImportError = 1 << 7 (128) + kAssetImportWarning = 1 << 8 (256) + kScriptingError = 1 << 9 (512) + kScriptingWarning = 1 << 10 (1024) + kScriptingLog = 1 << 11 (2048) + kScriptCompileError = 1 << 12 (4096) + kScriptCompileWarning = 1 << 13 (8192) + kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play + kMayIgnoreLineNumber = 1 << 15 (32768) + kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button + kDisplayPreviousErrorInStatusBar = 1 << 17 (131072) + kScriptingException = 1 << 18 (262144) + kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI + kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior + kGraphCompileError = 1 << 21 (2097152) + kScriptingAssertion = 1 << 22 (4194304) + kVisualScriptingError = 1 << 23 (8388608) + + Example observed values: + Log: 2048 (ScriptingLog) or 8 (Log) + Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning) + Error: 513 (ScriptingError | Error) or 1 (Error) + Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination + Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert) + */ + } +} diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs.meta b/MCPForUnity/Editor/Tools/ReadConsole.cs.meta new file mode 100644 index 00000000..039895f8 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46c4f3614ed61f547ba823f0b2790267 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows.meta b/MCPForUnity/Editor/Windows.meta new file mode 100644 index 00000000..eda016e5 --- /dev/null +++ b/MCPForUnity/Editor/Windows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d2ee39f5d4171184eb208e865c1ef4c1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs new file mode 100644 index 00000000..fdbfcbb5 --- /dev/null +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -0,0 +1,1669 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Net.Sockets; +using System.Net; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Windows +{ + public class MCPForUnityEditorWindow : EditorWindow + { + private bool isUnityBridgeRunning = false; + private Vector2 scrollPosition; + private string pythonServerInstallationStatus = "Not Installed"; + private Color pythonServerInstallationStatusColor = Color.red; + private const int mcpPort = 6500; // MCP port (still hardcoded for MCP server) + private readonly McpClients mcpClients = new(); + private bool autoRegisterEnabled; + private bool lastClientRegisteredOk; + 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", + "Comprehensive - All checks + semantic analysis", + "Strict - Full semantic validation (requires Roslyn)" + }; + + // UI state + private int selectedClientIndex = 0; + + public static void ShowWindow() + { + GetWindow("MCP For Unity"); + } + + private void OnEnable() + { + UpdatePythonServerInstallationStatus(); + + // Refresh bridge status + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + autoRegisterEnabled = EditorPrefs.GetBool("MCPForUnity.AutoRegisterEnabled", true); + debugLogsEnabled = EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } + foreach (McpClient mcpClient in mcpClients.clients) + { + CheckMcpConfiguration(mcpClient); + } + + // Load validation level setting + LoadValidationLevelSetting(); + + // First-run auto-setup only if Claude CLI is available + if (autoRegisterEnabled && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) + { + AutoFirstRunSetup(); + } + } + + private void OnFocus() + { + // Refresh bridge running state on focus in case initialization completed after domain reload + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + if (mcpClients.clients.Count > 0 && selectedClientIndex < mcpClients.clients.Count) + { + McpClient selectedClient = mcpClients.clients[selectedClientIndex]; + CheckMcpConfiguration(selectedClient); + } + Repaint(); + } + + private Color GetStatusColor(McpStatus status) + { + // Return appropriate color based on the status enum + return status switch + { + McpStatus.Configured => Color.green, + McpStatus.Running => Color.green, + McpStatus.Connected => Color.green, + McpStatus.IncorrectPath => Color.yellow, + McpStatus.CommunicationError => Color.yellow, + McpStatus.NoResponse => Color.yellow, + _ => Color.red, // Default to red for error states or not configured + }; + } + + private void UpdatePythonServerInstallationStatus() + { + try + { + string installedPath = ServerInstaller.GetServerPath(); + bool installedOk = !string.IsNullOrEmpty(installedPath) && File.Exists(Path.Combine(installedPath, "server.py")); + if (installedOk) + { + pythonServerInstallationStatus = "Installed"; + pythonServerInstallationStatusColor = Color.green; + return; + } + + // Fall back to embedded/dev source via our existing resolution logic + string embeddedPath = FindPackagePythonDirectory(); + bool embeddedOk = !string.IsNullOrEmpty(embeddedPath) && File.Exists(Path.Combine(embeddedPath, "server.py")); + if (embeddedOk) + { + pythonServerInstallationStatus = "Installed (Embedded)"; + pythonServerInstallationStatusColor = Color.green; + } + else + { + pythonServerInstallationStatus = "Not Installed"; + pythonServerInstallationStatusColor = Color.red; + } + } + catch + { + pythonServerInstallationStatus = "Not Installed"; + pythonServerInstallationStatusColor = Color.red; + } + } + + + private void DrawStatusDot(Rect statusRect, Color statusColor, float size = 12) + { + float offsetX = (statusRect.width - size) / 2; + float offsetY = (statusRect.height - size) / 2; + Rect dotRect = new(statusRect.x + offsetX, statusRect.y + offsetY, size, size); + Vector3 center = new( + dotRect.x + (dotRect.width / 2), + dotRect.y + (dotRect.height / 2), + 0 + ); + float radius = size / 2; + + // Draw the main dot + Handles.color = statusColor; + Handles.DrawSolidDisc(center, Vector3.forward, radius); + + // Draw the border + Color borderColor = new( + statusColor.r * 0.7f, + statusColor.g * 0.7f, + statusColor.b * 0.7f + ); + Handles.color = borderColor; + Handles.DrawWireDisc(center, Vector3.forward, radius); + } + + private void OnGUI() + { + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + // Header + DrawHeader(); + + // Compute equal column widths for uniform layout + float horizontalSpacing = 2f; + float outerPadding = 20f; // approximate padding + // Make columns a bit less wide for a tighter layout + float computed = (position.width - outerPadding - horizontalSpacing) / 2f; + float colWidth = Mathf.Clamp(computed, 220f, 340f); + // Use fixed heights per row so paired panels match exactly + float topPanelHeight = 190f; + float bottomPanelHeight = 230f; + + // Top row: Server Status (left) and Unity Bridge (right) + EditorGUILayout.BeginHorizontal(); + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawServerStatusSection(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(topPanelHeight)); + DrawBridgeSection(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // Second row: MCP Client Configuration (left) and Script Validation (right) + EditorGUILayout.BeginHorizontal(); + { + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawUnifiedClientConfiguration(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(horizontalSpacing); + + EditorGUILayout.BeginVertical(GUILayout.Width(colWidth), GUILayout.Height(bottomPanelHeight)); + DrawValidationSection(); + EditorGUILayout.EndVertical(); + } + EditorGUILayout.EndHorizontal(); + + // Minimal bottom padding + EditorGUILayout.Space(2); + + EditorGUILayout.EndScrollView(); + } + + 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", + titleStyle + ); + + // Place the Show Debug Logs toggle on the same header row, right-aligned + float toggleWidth = 160f; + Rect toggleRect = new Rect(titleRect.xMax - toggleWidth - 12f, titleRect.y + 10f, toggleWidth, 20f); + bool newDebug = GUI.Toggle(toggleRect, debugLogsEnabled, "Show Debug Logs"); + if (newDebug != debugLogsEnabled) + { + debugLogsEnabled = newDebug; + EditorPrefs.SetBool("MCPForUnity.DebugLogs", debugLogsEnabled); + if (debugLogsEnabled) + { + LogDebugPrefsState(); + } + } + EditorGUILayout.Space(15); + } + + private void LogDebugPrefsState() + { + try + { + string pythonDirOverridePref = SafeGetPrefString("MCPForUnity.PythonDirOverride"); + string uvPathPref = SafeGetPrefString("MCPForUnity.UvPath"); + string serverSrcPref = SafeGetPrefString("MCPForUnity.ServerSrc"); + bool useEmbedded = SafeGetPrefBool("MCPForUnity.UseEmbeddedServer"); + + // Version-scoped detection key + string embeddedVer = ReadEmbeddedVersionOrFallback(); + string detectKey = $"MCPForUnity.LegacyDetectLogged:{embeddedVer}"; + bool detectLogged = SafeGetPrefBool(detectKey); + + // Project-scoped auto-register key + string projectPath = Application.dataPath ?? string.Empty; + string autoKey = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; + bool autoRegistered = SafeGetPrefBool(autoKey); + + MCPForUnity.Editor.Helpers.McpLog.Info( + "MCP Debug Prefs:\n" + + $" DebugLogs: {debugLogsEnabled}\n" + + $" PythonDirOverride: '{pythonDirOverridePref}'\n" + + $" UvPath: '{uvPathPref}'\n" + + $" ServerSrc: '{serverSrcPref}'\n" + + $" UseEmbeddedServer: {useEmbedded}\n" + + $" DetectOnceKey: '{detectKey}' => {detectLogged}\n" + + $" AutoRegisteredKey: '{autoKey}' => {autoRegistered}", + always: false + ); + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP Debug Prefs logging failed: {ex.Message}"); + } + } + + private static string SafeGetPrefString(string key) + { + try { return EditorPrefs.GetString(key, string.Empty) ?? string.Empty; } catch { return string.Empty; } + } + + private static bool SafeGetPrefBool(string key) + { + try { return EditorPrefs.GetBool(key, false); } catch { return false; } + } + + private static string ReadEmbeddedVersionOrFallback() + { + try + { + if (ServerPathResolver.TryFindEmbeddedServerSource(out var embeddedSrc)) + { + var p = Path.Combine(embeddedSrc, "server_version.txt"); + if (File.Exists(p)) + { + var s = File.ReadAllText(p)?.Trim(); + if (!string.IsNullOrEmpty(s)) return s; + } + } + } + catch { } + return "unknown"; + } + + private void DrawServerStatusSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14 + }; + EditorGUILayout.LabelField("Server Status", sectionTitleStyle); + EditorGUILayout.Space(8); + + EditorGUILayout.BeginHorizontal(); + Rect statusRect = GUILayoutUtility.GetRect(0, 28, GUILayout.Width(24)); + DrawStatusDot(statusRect, pythonServerInstallationStatusColor, 16); + + GUIStyle statusStyle = new GUIStyle(EditorStyles.label) + { + fontSize = 12, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(pythonServerInstallationStatus, statusStyle, GUILayout.Height(28)); + 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) + { + fontSize = 11 + }; + EditorGUILayout.LabelField($"Ports: Unity {currentUnityPort}, MCP {mcpPort}", portStyle); + EditorGUILayout.Space(5); + + /// Auto-Setup button below ports + string setupButtonText = (lastClientRegisteredOk && lastBridgeVerifiedOk) ? "Connected ✓" : "Auto-Setup"; + if (GUILayout.Button(setupButtonText, GUILayout.Height(24))) + { + RunSetupNow(); + } + EditorGUILayout.Space(4); + + // Rebuild MCP Server button with tooltip tag + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.FlexibleSpace(); + GUIContent repairLabel = new GUIContent( + "Rebuild MCP Server", + "Deletes the installed server and re-copies it from the package. Use this to update the server after making source code changes or if the installation is corrupted." + ); + if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) + { + bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RebuildMcpServer(); + if (ok) + { + EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); + UpdatePythonServerInstallationStatus(); + } + else + { + EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); + } + } + } + // (Removed descriptive tool tag under the Repair button) + + // (Show Debug Logs toggle moved to header) + EditorGUILayout.Space(2); + + // Python detection warning with link + if (!IsPythonDetected()) + { + GUIStyle warnStyle = new GUIStyle(EditorStyles.label) { richText = true, wordWrap = true }; + EditorGUILayout.LabelField("Warning: No Python installation found.", warnStyle); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open Install Instructions", GUILayout.Width(200))) + { + Application.OpenURL("https://www.python.org/downloads/"); + } + } + EditorGUILayout.Space(4); + } + + // Troubleshooting helpers + if (pythonServerInstallationStatusColor != Color.green) + { + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Select server folder…", GUILayout.Width(160))) + { + string picked = EditorUtility.OpenFolderPanel("Select UnityMcpServer/src", Application.dataPath, ""); + if (!string.IsNullOrEmpty(picked) && File.Exists(Path.Combine(picked, "server.py"))) + { + pythonDirOverride = picked; + EditorPrefs.SetString("MCPForUnity.PythonDirOverride", pythonDirOverride); + UpdatePythonServerInstallationStatus(); + } + else if (!string.IsNullOrEmpty(picked)) + { + EditorUtility.DisplayDialog("Invalid Selection", "The selected folder does not contain server.py", "OK"); + } + } + if (GUILayout.Button("Verify again", GUILayout.Width(120))) + { + UpdatePythonServerInstallationStatus(); + } + } + } + EditorGUILayout.EndVertical(); + } + + private void DrawBridgeSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Always reflect the live state each repaint to avoid stale UI after recompiles + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + + GUIStyle sectionTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14 + }; + 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, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(isUnityBridgeRunning ? "Running" : "Stopped", bridgeStatusStyle, GUILayout.Height(28)); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8); + if (GUILayout.Button(isUnityBridgeRunning ? "Stop Bridge" : "Start Bridge", GUILayout.Height(32))) + { + ToggleUnityBridge(); + } + EditorGUILayout.Space(5); + EditorGUILayout.EndVertical(); + } + + 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); + EditorGUILayout.Space(4); + // (Show Debug Logs toggle moved to header) + EditorGUILayout.Space(2); + EditorGUILayout.EndVertical(); + } + + 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) + + // Client selector + string[] clientNames = mcpClients.clients.Select(c => c.name).ToArray(); + EditorGUI.BeginChangeCheck(); + selectedClientIndex = EditorGUILayout.Popup("Select Client", selectedClientIndex, clientNames, GUILayout.Height(20)); + if (EditorGUI.EndChangeCheck()) + { + 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(); + } + + private void AutoFirstRunSetup() + { + try + { + // Project-scoped one-time flag + string projectPath = Application.dataPath ?? string.Empty; + string key = $"MCPForUnity.AutoRegistered.{ComputeSha1(projectPath)}"; + if (EditorPrefs.GetBool(key, false)) + { + return; + } + + // Attempt client registration using discovered Python server dir + pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (!string.IsNullOrEmpty(pythonDir) && File.Exists(Path.Combine(pythonDir, "server.py"))) + { + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + // Only attempt if Claude CLI is present + if (!IsClaudeConfigured() && !string.IsNullOrEmpty(ExecPath.ResolveClaude())) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; + if (!alreadyConfigured) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); + } + + // Ensure the bridge is listening and has a fresh saved port + if (!MCPForUnityBridge.IsRunning) + { + try + { + MCPForUnityBridge.StartAutoConnect(); + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + Repaint(); + } + catch (Exception ex) + { + MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup StartAutoConnect failed: {ex.Message}"); + } + } + + // Verify bridge with a quick ping + lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); + + EditorPrefs.SetBool(key, true); + } + catch (Exception e) + { + MCPForUnity.Editor.Helpers.McpLog.Warn($"MCP for Unity auto-setup skipped: {e.Message}"); + } + } + + private static string ComputeSha1(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hash = sha1.ComputeHash(bytes); + StringBuilder sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); + } + catch + { + return ""; + } + } + + private void RunSetupNow() + { + // Force a one-shot setup regardless of first-run flag + try + { + pythonDirOverride ??= EditorPrefs.GetString("MCPForUnity.PythonDirOverride", null); + string pythonDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (string.IsNullOrEmpty(pythonDir) || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + EditorUtility.DisplayDialog("Setup", "Python server not found. Please select UnityMcpServer/src.", "OK"); + return; + } + + bool anyRegistered = false; + foreach (McpClient client in mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!IsClaudeConfigured()) + { + RegisterWithClaudeCode(pythonDir); + anyRegistered = true; + } + } + else + { + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; + if (!alreadyConfigured) + { + ConfigureMcpClient(client); + anyRegistered = true; + } + } + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); + } + } + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); + + // Restart/ensure bridge + MCPForUnityBridge.StartAutoConnect(); + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + + // Verify + lastBridgeVerifiedOk = VerifyBridgePing(MCPForUnityBridge.GetCurrentPort()); + Repaint(); + } + catch (Exception e) + { + EditorUtility.DisplayDialog("Setup Failed", e.Message, "OK"); + } + } + + 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), + ".cursor", "mcp.json"); + if (!File.Exists(configPath)) return false; + string json = File.ReadAllText(configPath); + dynamic cfg = JsonConvert.DeserializeObject(json); + var servers = cfg?.mcpServers; + if (servers == null) return false; + var unity = servers.unityMCP ?? servers.UnityMCP; + if (unity == null) return false; + var args = unity.args; + if (args == null) return false; + // Prefer exact extraction of the --directory value and compare normalized paths + string[] strArgs = ((System.Collections.Generic.IEnumerable)args) + .Select(x => x?.ToString() ?? string.Empty) + .ToArray(); + string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); + if (string.IsNullOrEmpty(dir)) return false; + return McpConfigFileHelper.PathsEqual(dir, pythonDir); + } + catch { return false; } + } + + private static bool IsClaudeConfigured() + { + try + { + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) return false; + + // Only prepend PATH on Unix + string pathPrepend = null; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + pathPrepend = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; + } + + if (!ExecPath.TryRun(claudePath, "mcp list", workingDir: null, out var stdout, out var stderr, 5000, pathPrepend)) + { + return false; + } + return (stdout ?? string.Empty).IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0; + } + catch { return false; } + } + + private static bool VerifyBridgePing(int port) + { + // Use strict framed protocol to match bridge (FRAMING=1) + const int ConnectTimeoutMs = 1000; + const int FrameTimeoutMs = 30000; // match bridge frame I/O timeout + + try + { + using TcpClient client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (!connectTask.Wait(ConnectTimeoutMs)) return false; + + using NetworkStream stream = client.GetStream(); + try { client.NoDelay = true; } catch { } + + // 1) Read handshake line (ASCII, newline-terminated) + string handshake = ReadLineAscii(stream, 2000); + if (string.IsNullOrEmpty(handshake) || handshake.IndexOf("FRAMING=1", StringComparison.OrdinalIgnoreCase) < 0) + { + UnityEngine.Debug.LogWarning("MCP for Unity: Bridge handshake missing FRAMING=1"); + return false; + } + + // 2) Send framed "ping" + byte[] payload = Encoding.UTF8.GetBytes("ping"); + WriteFrame(stream, payload, FrameTimeoutMs); + + // 3) Read framed response and check for pong + string response = ReadFrameUtf8(stream, FrameTimeoutMs); + bool ok = !string.IsNullOrEmpty(response) && response.IndexOf("pong", StringComparison.OrdinalIgnoreCase) >= 0; + if (!ok) + { + UnityEngine.Debug.LogWarning($"MCP for Unity: Framed ping failed; response='{response}'"); + } + return ok; + } + catch (Exception ex) + { + UnityEngine.Debug.LogWarning($"MCP for Unity: VerifyBridgePing error: {ex.Message}"); + return false; + } + } + + // Minimal framing helpers (8-byte big-endian length prefix), blocking with timeouts + private static void WriteFrame(NetworkStream stream, byte[] payload, int timeoutMs) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + if (payload.LongLength < 1) throw new IOException("Zero-length frames are not allowed"); + byte[] header = new byte[8]; + ulong len = (ulong)payload.LongLength; + header[0] = (byte)(len >> 56); + header[1] = (byte)(len >> 48); + header[2] = (byte)(len >> 40); + header[3] = (byte)(len >> 32); + header[4] = (byte)(len >> 24); + header[5] = (byte)(len >> 16); + header[6] = (byte)(len >> 8); + header[7] = (byte)(len); + + stream.WriteTimeout = timeoutMs; + stream.Write(header, 0, header.Length); + stream.Write(payload, 0, payload.Length); + } + + private static string ReadFrameUtf8(NetworkStream stream, int timeoutMs) + { + byte[] header = ReadExact(stream, 8, timeoutMs); + ulong len = ((ulong)header[0] << 56) + | ((ulong)header[1] << 48) + | ((ulong)header[2] << 40) + | ((ulong)header[3] << 32) + | ((ulong)header[4] << 24) + | ((ulong)header[5] << 16) + | ((ulong)header[6] << 8) + | header[7]; + if (len == 0UL) throw new IOException("Zero-length frames are not allowed"); + if (len > int.MaxValue) throw new IOException("Frame too large"); + byte[] payload = ReadExact(stream, (int)len, timeoutMs); + return Encoding.UTF8.GetString(payload); + } + + private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) + { + byte[] buffer = new byte[count]; + int offset = 0; + stream.ReadTimeout = timeoutMs; + while (offset < count) + { + int read = stream.Read(buffer, offset, count - offset); + if (read <= 0) throw new IOException("Connection closed before reading expected bytes"); + offset += read; + } + return buffer; + } + + private static string ReadLineAscii(NetworkStream stream, int timeoutMs, int maxLen = 512) + { + stream.ReadTimeout = timeoutMs; + using var ms = new MemoryStream(); + byte[] one = new byte[1]; + while (ms.Length < maxLen) + { + int n = stream.Read(one, 0, 1); + if (n <= 0) break; + if (one[0] == (byte)'\n') break; + ms.WriteByte(one[0]); + } + return Encoding.ASCII.GetString(ms.ToArray()); + } + + private void DrawClientConfigurationCompact(McpClient mcpClient) + { + // 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, + fontStyle = FontStyle.Bold + }; + 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; + } + + // Action buttons in horizontal layout + EditorGUILayout.BeginHorizontal(); + + if (mcpClient.mcpType == McpTypes.VSCode) + { + if (GUILayout.Button("Auto Configure", GUILayout.Height(32))) + { + 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 (GUILayout.Button($"Auto Configure", GUILayout.Height(32))) + { + ConfigureMcpClient(mcpClient); + } + } + + if (mcpClient.mcpType != McpTypes.ClaudeCode) + { + if (GUILayout.Button("Manual Setup", GUILayout.Height(32))) + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? mcpClient.windowsConfigPath + : mcpClient.linuxConfigPath; + + if (mcpClient.mcpType == McpTypes.VSCode) + { + string pythonDir = FindPackagePythonDirectory(); + string uvPath = FindUvPath(); + if (uvPath == null) + { + UnityEngine.Debug.LogError("UV package manager not found. Cannot configure VSCode."); + return; + } + // VSCode now reads from mcp.json with a top-level "servers" block + var vscodeConfig = new + { + servers = new + { + unityMCP = new + { + command = uvPath, + args = new[] { "run", "--directory", pythonDir, "server.py" } + } + } + }; + JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; + string manualConfigJson = JsonConvert.SerializeObject(vscodeConfig, jsonSettings); + VSCodeManualSetupWindow.ShowWindow(configPath, manualConfigJson); + } + else + { + ShowManualInstructionsWindow(configPath, 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); + } + } + + private void ToggleUnityBridge() + { + if (isUnityBridgeRunning) + { + MCPForUnityBridge.Stop(); + } + else + { + MCPForUnityBridge.Start(); + } + // Reflect the actual state post-operation (avoid optimistic toggle) + isUnityBridgeRunning = MCPForUnityBridge.IsRunning; + Repaint(); + } + + // 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 string FindPackagePythonDirectory() + { + // Use shared helper for consistent path resolution across both windows + return McpPathResolver.FindPackagePythonDirectory(debugLogsEnabled); + } + + private string ConfigureMcpClient(McpClient mcpClient) + { + try + { + // Use shared helper for consistent config path resolution + string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); + + // Create directory if it doesn't exist + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + + // Find the server.py file location using shared helper + string pythonDir = FindPackagePythonDirectory(); + + if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + ShowManualInstructionsWindow(configPath, mcpClient); + return "Manual Configuration Required"; + } + + string result = mcpClient.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) + : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); + + // Update the client status after successful configuration + if (result == "Configured successfully") + { + mcpClient.SetStatus(McpStatus.Configured); + } + + return result; + } + catch (Exception e) + { + // Determine the config file path based on OS for error message + string configPath = ""; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + configPath = mcpClient.windowsConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ) + { + configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if ( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + ) + { + configPath = mcpClient.linuxConfigPath; + } + + ShowManualInstructionsWindow(configPath, mcpClient); + UnityEngine.Debug.LogError( + $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" + ); + return $"Failed to configure {mcpClient.name}"; + } + } + + private void LoadValidationLevelSetting() + { + string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); + validationLevelIndex = savedLevel.ToLower() switch + { + "basic" => 0, + "standard" => 1, + "comprehensive" => 2, + "strict" => 3, + _ => 1 // Default to Standard + }; + } + + private void SaveValidationLevelSetting() + { + string levelString = validationLevelIndex switch + { + 0 => "basic", + 1 => "standard", + 2 => "comprehensive", + 3 => "strict", + _ => "standard" + }; + EditorPrefs.SetString("MCPForUnity_ScriptValidationLevel", levelString); + } + + private string GetValidationLevelDescription(int index) + { + return index switch + { + 0 => "Only basic syntax checks (braces, quotes, comments)", + 1 => "Syntax checks + Unity best practices and warnings", + 2 => "All checks + semantic analysis and performance warnings", + 3 => "Full semantic validation with namespace/type resolution (requires Roslyn)", + _ => "Standard validation" + }; + } + + private void CheckMcpConfiguration(McpClient mcpClient) + { + try + { + // Special handling for Claude Code + if (mcpClient.mcpType == McpTypes.ClaudeCode) + { + CheckClaudeCodeConfiguration(mcpClient); + return; + } + + // Use shared helper for consistent config path resolution + string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); + + if (!File.Exists(configPath)) + { + mcpClient.SetStatus(McpStatus.NotConfigured); + return; + } + + 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); + + if (standardConfig?.mcpServers?.unityMCP != null) + { + args = standardConfig.mcpServers.unityMCP.args; + configExists = true; + } + break; + } + + // Common logic for checking configuration status + if (configExists) + { + string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); + bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); + if (matches) + { + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + // Attempt auto-rewrite once if the package path changed + try + { + string rewriteResult = mcpClient.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) + : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); + if (rewriteResult == "Configured successfully") + { + if (debugLogsEnabled) + { + MCPForUnity.Editor.Helpers.McpLog.Info($"Auto-updated MCP config for '{mcpClient.name}' to new path: {pythonDir}", always: false); + } + mcpClient.SetStatus(McpStatus.Configured); + } + else + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + } + } + catch (Exception ex) + { + mcpClient.SetStatus(McpStatus.IncorrectPath); + if (debugLogsEnabled) + { + UnityEngine.Debug.LogWarning($"MCP for Unity: Auto-config rewrite failed for '{mcpClient.name}': {ex.Message}"); + } + } + } + } + else + { + mcpClient.SetStatus(McpStatus.MissingConfig); + } + } + catch (Exception e) + { + mcpClient.SetStatus(McpStatus.Error, e.Message); + } + } + + private void RegisterWithClaudeCode(string pythonDir) + { + // Resolve claude and uv; then run register command + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) + { + UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); + return; + } + string uvPath = ExecPath.ResolveUv() ?? "uv"; + + // Prefer embedded/dev path when available + string srcDir = !string.IsNullOrEmpty(pythonDirOverride) ? pythonDirOverride : FindPackagePythonDirectory(); + if (string.IsNullOrEmpty(srcDir)) srcDir = pythonDir; + + string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{srcDir}\" server.py"; + + string projectDir = Path.GetDirectoryName(Application.dataPath); + // Ensure PATH includes common locations on Unix; on Windows leave PATH as-is + string pathPrepend = null; + if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.LinuxEditor) + { + pathPrepend = Application.platform == RuntimePlatform.OSXEditor + ? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + : "/usr/local/bin:/usr/bin:/bin"; + } + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + { + string combined = ($"{stdout}\n{stderr}") ?? string.Empty; + if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Treat as success if Claude reports existing registration + var existingClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (existingClient != null) CheckClaudeCodeConfiguration(existingClient); + Repaint(); + UnityEngine.Debug.Log("MCP-FOR-UNITY: MCP for Unity already registered with Claude Code."); + } + else + { + UnityEngine.Debug.LogError($"MCP for Unity: Failed to start Claude CLI.\n{stderr}\n{stdout}"); + } + return; + } + + // Update status + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) CheckClaudeCodeConfiguration(claudeClient); + Repaint(); + UnityEngine.Debug.Log("MCP-FOR-UNITY: Registered with Claude Code."); + } + + private void UnregisterWithClaudeCode() + { + string claudePath = ExecPath.ResolveClaude(); + if (string.IsNullOrEmpty(claudePath)) + { + UnityEngine.Debug.LogError("MCP for Unity: Claude CLI not found. Set a path in this window or install the CLI, then try again."); + return; + } + + string projectDir = Path.GetDirectoryName(Application.dataPath); + string pathPrepend = Application.platform == RuntimePlatform.OSXEditor + ? "/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; + } + + // 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)) + { + success = true; + UnityEngine.Debug.Log($"MCP for Unity: Successfully removed MCP server: {serverName}"); + break; + } + else if (!string.IsNullOrEmpty(stderr) && + !stderr.Contains("No MCP server found", StringComparison.OrdinalIgnoreCase)) + { + // If it's not a "not found" error, log it and stop trying + UnityEngine.Debug.LogWarning($"Error removing {serverName}: {stderr}"); + break; + } + } + + if (success) + { + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + // Optimistically flip to NotConfigured; then verify + claudeClient.SetStatus(McpStatus.NotConfigured); + CheckClaudeCodeConfiguration(claudeClient); + } + Repaint(); + UnityEngine.Debug.Log("MCP for Unity: MCP server successfully unregistered from Claude Code."); + } + else + { + // If no servers were found to remove, they're already unregistered + // Force status to NotConfigured and update the UI + UnityEngine.Debug.Log("No MCP servers found to unregister - already unregistered."); + var claudeClient = mcpClients.clients.FirstOrDefault(c => c.mcpType == McpTypes.ClaudeCode); + if (claudeClient != null) + { + claudeClient.SetStatus(McpStatus.NotConfigured); + CheckClaudeCodeConfiguration(claudeClient); + } + Repaint(); + } + } + + // Removed unused ParseTextOutput + + private string FindUvPath() + { + try { return MCPForUnity.Editor.Helpers.ServerInstaller.FindUvPath(); } catch { return null; } + } + + // Validation and platform-specific scanning are handled by ServerInstaller.FindUvPath() + + // Windows-specific discovery removed; use ServerInstaller.FindUvPath() instead + + // Removed unused FindClaudeCommand + + private void CheckClaudeCodeConfiguration(McpClient mcpClient) + { + try + { + // 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)) + configPath = mcpClient.windowsConfigPath; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + 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) + { + var servers = claudeConfig.mcpServers; + if (servers.UnityMCP != null || servers.unityMCP != null) + { + // Found MCP for Unity configured + mcpClient.SetStatus(McpStatus.Configured); + return; + } + } + + // Also check if there's a project-specific configuration for this Unity project (legacy format) + if (claudeConfig?.projects != null) + { + // Look for the project path in the config + 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) + var servers = project.Value.mcpServers; + if (servers.UnityMCP != null || servers.unityMCP != null) + { + // Found MCP for Unity configured for this project + mcpClient.SetStatus(McpStatus.Configured); + return; + } + } + } + } + + // No configuration found for this project + mcpClient.SetStatus(McpStatus.NotConfigured); + } + catch (Exception e) + { + UnityEngine.Debug.LogWarning($"Error checking Claude Code config: {e.Message}"); + mcpClient.SetStatus(McpStatus.Error, e.Message); + } + } + + private bool IsPythonDetected() + { + try + { + // Windows-specific Python detection + if (Application.platform == RuntimePlatform.WindowsEditor) + { + // Common Windows Python installation paths + string[] windowsCandidates = + { + @"C:\Python313\python.exe", + @"C:\Python312\python.exe", + @"C:\Python311\python.exe", + @"C:\Python310\python.exe", + @"C:\Python39\python.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python311\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python310\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\Python\Python39\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python313\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python312\python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), @"Python311\python.exe"), + 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; + } + + // Try 'where python' command (Windows equivalent of 'which') + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = "python", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp)) + { + string[] lines = outp.Split('\n'); + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (File.Exists(trimmed)) return true; + } + } + } + else + { + // macOS/Linux detection (existing code) + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/python3", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/local/bin/python3", + Path.Combine(home, ".local", "bin", "python3"), + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + }; + foreach (string c in candidates) + { + if (File.Exists(c)) return true; + } + + // Try 'which python3' + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = "python3", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + string outp = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(2000); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(outp) && File.Exists(outp)) return true; + } + } + catch { } + return false; + } + } +} diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs.meta b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs.meta new file mode 100644 index 00000000..94b00cc5 --- /dev/null +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f740bec3a8d04716adeab35c412a15f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs b/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs new file mode 100644 index 00000000..ecccbef1 --- /dev/null +++ b/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs @@ -0,0 +1,296 @@ +using System.Runtime.InteropServices; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Windows +{ + // Editor window to display manual configuration instructions + public class ManualConfigEditorWindow : EditorWindow + { + protected string configPath; + protected string configJson; + protected Vector2 scrollPos; + protected bool pathCopied = false; + protected bool jsonCopied = false; + protected float copyFeedbackTimer = 0; + protected McpClient mcpClient; + + public static void ShowWindow(string configPath, string configJson, McpClient mcpClient) + { + var window = GetWindow("Manual Configuration"); + window.configPath = configPath; + window.configJson = configJson; + window.mcpClient = mcpClient; + window.minSize = new Vector2(500, 400); + window.Show(); + } + + protected virtual void OnGUI() + { + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + + // Header with improved styling + EditorGUILayout.Space(10); + Rect titleRect = EditorGUILayout.GetControlRect(false, 30); + EditorGUI.DrawRect( + new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), + new Color(0.2f, 0.2f, 0.2f, 0.1f) + ); + GUI.Label( + new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), + (mcpClient?.name ?? "Unknown") + " Manual Configuration", + EditorStyles.boldLabel + ); + EditorGUILayout.Space(10); + + // Instructions with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + Rect headerRect = EditorGUILayout.GetControlRect(false, 24); + EditorGUI.DrawRect( + new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), + new Color(0.1f, 0.1f, 0.1f, 0.2f) + ); + GUI.Label( + new Rect( + headerRect.x + 8, + headerRect.y + 4, + headerRect.width - 16, + headerRect.height + ), + "The automatic configuration failed. Please follow these steps:", + EditorStyles.boldLabel + ); + EditorGUILayout.Space(10); + + GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) + { + margin = new RectOffset(10, 10, 5, 5), + }; + + EditorGUILayout.LabelField( + "1. Open " + (mcpClient?.name ?? "Unknown") + " config file by either:", + instructionStyle + ); + if (mcpClient?.mcpType == McpTypes.ClaudeDesktop) + { + EditorGUILayout.LabelField( + " a) Going to Settings > Developer > Edit Config", + instructionStyle + ); + } + else if (mcpClient?.mcpType == McpTypes.Cursor) + { + EditorGUILayout.LabelField( + " a) Going to File > Preferences > Cursor Settings > MCP > Add new global MCP server", + instructionStyle + ); + } + else if (mcpClient?.mcpType == McpTypes.Windsurf) + { + EditorGUILayout.LabelField( + " a) Going to File > Preferences > Windsurf Settings > MCP > Manage MCPs -> View raw config", + instructionStyle + ); + } + else if (mcpClient?.mcpType == McpTypes.Kiro) + { + EditorGUILayout.LabelField( + " a) Going to File > Settings > Settings > Search for \"MCP\" > Open Workspace MCP Config", + instructionStyle + ); + } + else if (mcpClient?.mcpType == McpTypes.Codex) + { + EditorGUILayout.LabelField( + " a) Running `codex config edit` in a terminal", + instructionStyle + ); + } + EditorGUILayout.LabelField(" OR", instructionStyle); + EditorGUILayout.LabelField( + " b) Opening the configuration file at:", + instructionStyle + ); + + // Path section with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + string displayPath; + if (mcpClient != null) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + displayPath = mcpClient.windowsConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + displayPath = string.IsNullOrEmpty(mcpClient.macConfigPath) + + ? configPath + + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + displayPath = mcpClient.linuxConfigPath; + } + else + { + displayPath = configPath; + } + } + else + { + displayPath = configPath; + } + + // Prevent text overflow by allowing the text field to wrap + GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; + + EditorGUILayout.TextField( + displayPath, + pathStyle, + GUILayout.Height(EditorGUIUtility.singleLineHeight) + ); + + // Copy button with improved styling + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + GUIStyle copyButtonStyle = new(GUI.skin.button) + { + padding = new RectOffset(15, 15, 5, 5), + margin = new RectOffset(10, 10, 5, 5), + }; + + if ( + GUILayout.Button( + "Copy Path", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + EditorGUIUtility.systemCopyBuffer = displayPath; + pathCopied = true; + copyFeedbackTimer = 2f; + } + + if ( + GUILayout.Button( + "Open File", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + // Open the file using the system's default application + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = displayPath, + UseShellExecute = true, + } + ); + } + + if (pathCopied) + { + GUIStyle feedbackStyle = new(EditorStyles.label); + feedbackStyle.normal.textColor = Color.green; + EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + + string configLabel = mcpClient?.mcpType == McpTypes.Codex + ? "2. Paste the following TOML configuration:" + : "2. Paste the following JSON configuration:"; + EditorGUILayout.LabelField(configLabel, instructionStyle); + + // JSON section with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Improved text area for JSON with syntax highlighting colors + GUIStyle jsonStyle = new(EditorStyles.textArea) + { + font = EditorStyles.boldFont, + wordWrap = true, + }; + jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue + + // Draw the JSON in a text area with a taller height for better readability + EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); + + // Copy JSON button with improved styling + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + if ( + GUILayout.Button( + "Copy JSON", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + EditorGUIUtility.systemCopyBuffer = configJson; + jsonCopied = true; + copyFeedbackTimer = 2f; + } + + if (jsonCopied) + { + GUIStyle feedbackStyle = new(EditorStyles.label); + feedbackStyle.normal.textColor = Color.green; + EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + EditorGUILayout.LabelField( + "3. Save the file and restart " + (mcpClient?.name ?? "Unknown"), + instructionStyle + ); + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + + // Close button at the bottom + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) + { + Close(); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndScrollView(); + } + + protected virtual void Update() + { + // Handle the feedback message timer + if (copyFeedbackTimer > 0) + { + copyFeedbackTimer -= Time.deltaTime; + if (copyFeedbackTimer <= 0) + { + pathCopied = false; + jsonCopied = false; + Repaint(); + } + } + } + } +} diff --git a/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs.meta b/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs.meta new file mode 100644 index 00000000..41646e62 --- /dev/null +++ b/MCPForUnity/Editor/Windows/ManualConfigEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36798bd7b867b8e43ac86885e94f928f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs b/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs new file mode 100644 index 00000000..10e066d2 --- /dev/null +++ b/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs @@ -0,0 +1,291 @@ +using System.Runtime.InteropServices; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Windows +{ + public class VSCodeManualSetupWindow : ManualConfigEditorWindow + { + public static void ShowWindow(string configPath, string configJson) + { + var window = GetWindow("VSCode GitHub Copilot Setup"); + 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(); + } + + protected override void OnGUI() + { + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + + // Header with improved styling + EditorGUILayout.Space(10); + Rect titleRect = EditorGUILayout.GetControlRect(false, 30); + EditorGUI.DrawRect( + new Rect(titleRect.x, titleRect.y, titleRect.width, titleRect.height), + new Color(0.2f, 0.2f, 0.2f, 0.1f) + ); + GUI.Label( + new Rect(titleRect.x + 10, titleRect.y + 6, titleRect.width - 20, titleRect.height), + "VSCode GitHub Copilot MCP Setup", + EditorStyles.boldLabel + ); + EditorGUILayout.Space(10); + + // Instructions with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + Rect headerRect = EditorGUILayout.GetControlRect(false, 24); + EditorGUI.DrawRect( + new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height), + new Color(0.1f, 0.1f, 0.1f, 0.2f) + ); + GUI.Label( + new Rect( + headerRect.x + 8, + headerRect.y + 4, + headerRect.width - 16, + headerRect.height + ), + "Setting up GitHub Copilot in VSCode with MCP for Unity", + EditorStyles.boldLabel + ); + EditorGUILayout.Space(10); + + GUIStyle instructionStyle = new(EditorStyles.wordWrappedLabel) + { + margin = new RectOffset(10, 10, 5, 5), + }; + + EditorGUILayout.LabelField( + "1. Prerequisites", + EditorStyles.boldLabel + ); + EditorGUILayout.LabelField( + "• Ensure you have VSCode installed", + instructionStyle + ); + EditorGUILayout.LabelField( + "• Ensure you have GitHub Copilot extension installed in VSCode", + instructionStyle + ); + EditorGUILayout.LabelField( + "• Ensure you have a valid GitHub Copilot subscription", + instructionStyle + ); + EditorGUILayout.Space(5); + + EditorGUILayout.LabelField( + "2. Steps to Configure", + EditorStyles.boldLabel + ); + EditorGUILayout.LabelField( + "a) Open or create your VSCode MCP config file (mcp.json) at the path below", + instructionStyle + ); + EditorGUILayout.LabelField( + "b) Paste the JSON shown below into mcp.json", + instructionStyle + ); + EditorGUILayout.LabelField( + "c) Save the file and restart VSCode", + instructionStyle + ); + EditorGUILayout.Space(5); + + EditorGUILayout.LabelField( + "3. VSCode mcp.json location:", + EditorStyles.boldLabel + ); + + // Path section with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + string displayPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + displayPath = System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "Code", + "User", + "mcp.json" + ); + } + else + { + displayPath = System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Code", + "User", + "mcp.json" + ); + } + + // Store the path in the base class config path + if (string.IsNullOrEmpty(configPath)) + { + configPath = displayPath; + } + + // Prevent text overflow by allowing the text field to wrap + GUIStyle pathStyle = new(EditorStyles.textField) { wordWrap = true }; + + EditorGUILayout.TextField( + displayPath, + pathStyle, + GUILayout.Height(EditorGUIUtility.singleLineHeight) + ); + + // Copy button with improved styling + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + GUIStyle copyButtonStyle = new(GUI.skin.button) + { + padding = new RectOffset(15, 15, 5, 5), + margin = new RectOffset(10, 10, 5, 5), + }; + + if ( + GUILayout.Button( + "Copy Path", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + EditorGUIUtility.systemCopyBuffer = displayPath; + pathCopied = true; + copyFeedbackTimer = 2f; + } + + if ( + GUILayout.Button( + "Open File", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + // Open the file using the system's default application + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = displayPath, + UseShellExecute = true, + } + ); + } + + if (pathCopied) + { + GUIStyle feedbackStyle = new(EditorStyles.label); + feedbackStyle.normal.textColor = Color.green; + EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(10); + + EditorGUILayout.LabelField( + "4. Add this configuration to your mcp.json:", + EditorStyles.boldLabel + ); + + // JSON section with improved styling + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Improved text area for JSON with syntax highlighting colors + GUIStyle jsonStyle = new(EditorStyles.textArea) + { + font = EditorStyles.boldFont, + wordWrap = true, + }; + jsonStyle.normal.textColor = new Color(0.3f, 0.6f, 0.9f); // Syntax highlighting blue + + // Draw the JSON in a text area with a taller height for better readability + EditorGUILayout.TextArea(configJson, jsonStyle, GUILayout.Height(200)); + + // Copy JSON button with improved styling + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + if ( + GUILayout.Button( + "Copy JSON", + copyButtonStyle, + GUILayout.Height(25), + GUILayout.Width(100) + ) + ) + { + EditorGUIUtility.systemCopyBuffer = configJson; + jsonCopied = true; + copyFeedbackTimer = 2f; + } + + if (jsonCopied) + { + GUIStyle feedbackStyle = new(EditorStyles.label); + feedbackStyle.normal.textColor = Color.green; + EditorGUILayout.LabelField("Copied!", feedbackStyle, GUILayout.Width(60)); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + EditorGUILayout.LabelField( + "5. After configuration:", + EditorStyles.boldLabel + ); + EditorGUILayout.LabelField( + "• Restart VSCode", + instructionStyle + ); + EditorGUILayout.LabelField( + "• GitHub Copilot will now be able to interact with your Unity project through the MCP protocol", + instructionStyle + ); + EditorGUILayout.LabelField( + "• Remember to have the MCP for Unity Bridge running in Unity Editor", + instructionStyle + ); + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + + // Close button at the bottom + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Close", GUILayout.Height(30), GUILayout.Width(100))) + { + Close(); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndScrollView(); + } + + protected override void Update() + { + // Call the base implementation which handles the copy feedback timer + base.Update(); + } + } +} diff --git a/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs.meta b/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs.meta new file mode 100644 index 00000000..fb13126b --- /dev/null +++ b/MCPForUnity/Editor/Windows/VSCodeManualSetupWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 377fe73d52cf0435fabead5f50a0d204 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/README.md b/MCPForUnity/README.md new file mode 100644 index 00000000..b26b9f19 --- /dev/null +++ b/MCPForUnity/README.md @@ -0,0 +1,88 @@ +# MCP for Unity — Editor Plugin Guide + +Use this guide to configure and run MCP for Unity inside the Unity Editor. Installation is covered elsewhere; this document focuses on the Editor window, client configuration, and troubleshooting. + +## Open the window +- Unity menu: Window > MCP for Unity + +The window has four areas: Server Status, Unity Bridge, MCP Client Configuration, and Script Validation. + +--- + +## Quick start +1. Open Window > MCP for Unity. +2. Click “Auto-Setup”. +3. If prompted: + - Select the server folder that contains `server.py` (UnityMcpServer~/src). + - Install Python and/or uv if missing. + - For Claude Code, ensure the `claude` CLI is installed. +4. Click “Start Bridge” if the Unity Bridge shows “Stopped”. +5. Use your MCP client (Cursor, VS Code, Windsurf, Claude Code) to connect. + +--- + +## Server Status +- Status dot and label: + - Installed / Installed (Embedded) / Not Installed. +- Mode and ports: + - Mode: Auto or Standard. + - Ports: Unity (varies; shown in UI), MCP 6500. +- Actions: + - Auto-Setup: Registers/updates your selected MCP client(s), ensures bridge connectivity. Shows “Connected ✓” after success. + - Rebuild MCP Server: Rebuilds the Python based MCP server + - Select server folder…: Choose the folder containing `server.py`. + - Verify again: Re-checks server presence. + - If Python isn’t detected, use “Open Install Instructions”. + +--- + +## Unity Bridge +- Shows Running or Stopped with a status dot. +- Start/Stop Bridge button toggles the Unity bridge process used by MCP clients to talk to Unity. +- Tip: After Auto-Setup, the bridge may auto-start in Auto mode. + +--- + +## MCP Client Configuration +- Select Client: Choose your target MCP client (e.g., Cursor, VS Code, Windsurf, Claude Code). +- Per-client actions: + - Cursor / VS Code / Windsurf: + - Auto Configure: Writes/updates your config to launch the server via uv: + - Command: uv + - Args: run --directory server.py + - Manual Setup: Opens a window with a pre-filled JSON snippet to copy/paste into your client config. + - Choose `uv` Install Location: If uv isn’t on PATH, select the uv binary. + - A compact “Config:” line shows the resolved config file name once uv/server are detected. + - Claude Code: + - Register with Claude Code / Unregister MCP for Unity with Claude Code. + - If the CLI isn’t found, click “Choose Claude Install Location”. + - The window displays the resolved Claude CLI path when detected. + +Notes: +- The UI shows a status dot and a short status text (e.g., “Configured”, “uv Not Found”, “Claude Not Found”). +- Use “Auto Configure” for one-click setup; use “Manual Setup” when you prefer to review/copy config. + +--- + +## Script Validation +- Validation Level options: + - Basic — Only syntax checks + - Standard — Syntax + Unity practices + - Comprehensive — All checks + semantic analysis + - Strict — Full semantic validation (requires Roslyn) +- Pick a level based on your project’s needs. A description is shown under the dropdown. + +--- + +## Troubleshooting +- Python or `uv` not found: + - Help: [Fix MCP for Unity with Cursor, VS Code & Windsurf](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf) +- Claude CLI not found: + - Help: [Fix MCP for Unity with Claude Code](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code) + +--- + +## Tips +- Enable “Show Debug Logs” in the header for more details in the Console when diagnosing issues. + +--- \ No newline at end of file diff --git a/MCPForUnity/README.md.meta b/MCPForUnity/README.md.meta new file mode 100644 index 00000000..6ef03ff5 --- /dev/null +++ b/MCPForUnity/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c3d9e362fb93e46f59ce7213fbe4f2b1 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Runtime.meta b/MCPForUnity/Runtime.meta new file mode 100644 index 00000000..ae1e4dfa --- /dev/null +++ b/MCPForUnity/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b5cc10fd969474b3680332e542416860 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef b/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef new file mode 100644 index 00000000..52b509f8 --- /dev/null +++ b/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef @@ -0,0 +1,16 @@ +{ + "name": "MCPForUnity.Runtime", + "rootNamespace": "MCPForUnity.Runtime", + "references": [ + "GUID:560b04d1a97f54a46a2660c3cc343a6f" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef.meta b/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef.meta new file mode 100644 index 00000000..74c20289 --- /dev/null +++ b/MCPForUnity/Runtime/MCPForUnity.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 562a750ff18ee4193928e885c708fee1 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Runtime/Serialization.meta b/MCPForUnity/Runtime/Serialization.meta new file mode 100644 index 00000000..89cd67ad --- /dev/null +++ b/MCPForUnity/Runtime/Serialization.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c7e33d6224fe6473f9bc69fe6d40e508 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs new file mode 100644 index 00000000..c76b280d --- /dev/null +++ b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs @@ -0,0 +1,266 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; // Required for AssetDatabase and EditorUtility +#endif + +namespace MCPForUnity.Runtime.Serialization +{ + public class Vector3Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("z"); + writer.WriteValue(value.z); + writer.WriteEndObject(); + } + + public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Vector3( + (float)jo["x"], + (float)jo["y"], + (float)jo["z"] + ); + } + } + + public class Vector2Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WriteEndObject(); + } + + public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Vector2( + (float)jo["x"], + (float)jo["y"] + ); + } + } + + public class QuaternionConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Quaternion value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("z"); + writer.WriteValue(value.z); + writer.WritePropertyName("w"); + writer.WriteValue(value.w); + writer.WriteEndObject(); + } + + public override Quaternion ReadJson(JsonReader reader, Type objectType, Quaternion existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Quaternion( + (float)jo["x"], + (float)jo["y"], + (float)jo["z"], + (float)jo["w"] + ); + } + } + + public class ColorConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Color value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("r"); + writer.WriteValue(value.r); + writer.WritePropertyName("g"); + writer.WriteValue(value.g); + writer.WritePropertyName("b"); + writer.WriteValue(value.b); + writer.WritePropertyName("a"); + writer.WriteValue(value.a); + writer.WriteEndObject(); + } + + public override Color ReadJson(JsonReader reader, Type objectType, Color existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Color( + (float)jo["r"], + (float)jo["g"], + (float)jo["b"], + (float)jo["a"] + ); + } + } + + public class RectConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("width"); + writer.WriteValue(value.width); + writer.WritePropertyName("height"); + writer.WriteValue(value.height); + writer.WriteEndObject(); + } + + public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Rect( + (float)jo["x"], + (float)jo["y"], + (float)jo["width"], + (float)jo["height"] + ); + } + } + + public class BoundsConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("center"); + serializer.Serialize(writer, value.center); // Use serializer to handle nested Vector3 + writer.WritePropertyName("size"); + serializer.Serialize(writer, value.size); // Use serializer to handle nested Vector3 + writer.WriteEndObject(); + } + + public override Bounds ReadJson(JsonReader reader, Type objectType, Bounds existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + Vector3 center = jo["center"].ToObject(serializer); // Use serializer to handle nested Vector3 + Vector3 size = jo["size"].ToObject(serializer); // Use serializer to handle nested Vector3 + return new Bounds(center, size); + } + } + + // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) + public class UnityEngineObjectConverter : JsonConverter + { + public override bool CanRead => true; // We need to implement ReadJson + public override bool CanWrite => true; + + public override void WriteJson(JsonWriter writer, UnityEngine.Object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + +#if UNITY_EDITOR // AssetDatabase and EditorUtility are Editor-only + if (UnityEditor.AssetDatabase.Contains(value)) + { + // It's an asset (Material, Texture, Prefab, etc.) + string path = UnityEditor.AssetDatabase.GetAssetPath(value); + if (!string.IsNullOrEmpty(path)) + { + writer.WriteValue(path); + } + else + { + // Asset exists but path couldn't be found? Write minimal info. + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WritePropertyName("isAssetWithoutPath"); + writer.WriteValue(true); + writer.WriteEndObject(); + } + } + else + { + // It's a scene object (GameObject, Component, etc.) + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WriteEndObject(); + } +#else + // Runtime fallback: Write basic info without AssetDatabase + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WritePropertyName("warning"); + writer.WriteValue("UnityEngineObjectConverter running in non-Editor mode, asset path unavailable."); + writer.WriteEndObject(); +#endif + } + + public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, UnityEngine.Object existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + +#if UNITY_EDITOR + if (reader.TokenType == JsonToken.String) + { + // Assume it's an asset path + string path = reader.Value.ToString(); + return UnityEditor.AssetDatabase.LoadAssetAtPath(path, objectType); + } + + if (reader.TokenType == JsonToken.StartObject) + { + JObject jo = JObject.Load(reader); + if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) + { + int instanceId = idToken.ToObject(); + UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); + if (obj != null && objectType.IsAssignableFrom(obj.GetType())) + { + return obj; + } + } + // Could potentially try finding by name as a fallback if ID lookup fails/isn't present + // but that's less reliable. + } +#else + // Runtime deserialization is tricky without AssetDatabase/EditorUtility + // Maybe log a warning and return null or existingValue? + Debug.LogWarning("UnityEngineObjectConverter cannot deserialize complex objects in non-Editor mode."); + // Skip the token to avoid breaking the reader + if (reader.TokenType == JsonToken.StartObject) JObject.Load(reader); + else if (reader.TokenType == JsonToken.String) reader.ReadAsString(); + // Return null or existing value, depending on desired behavior + return existingValue; +#endif + + throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); + } + } +} \ No newline at end of file diff --git a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs.meta b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs.meta new file mode 100644 index 00000000..caaf2859 --- /dev/null +++ b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e65311c160f0d41d4a1b45a3dba8dd5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/UnityMcpServer~/src/Dockerfile b/MCPForUnity/UnityMcpServer~/src/Dockerfile new file mode 100644 index 00000000..5fcbc4eb --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +# Install required system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Install uv package manager +RUN pip install uv + +# Copy required files +COPY config.py /app/ +COPY server.py /app/ +COPY unity_connection.py /app/ +COPY pyproject.toml /app/ +COPY __init__.py /app/ +COPY tools/ /app/tools/ + +# Install dependencies using uv +RUN uv pip install --system -e . + + +# Command to run the server +CMD ["uv", "run", "server.py"] diff --git a/MCPForUnity/UnityMcpServer~/src/__init__.py b/MCPForUnity/UnityMcpServer~/src/__init__.py new file mode 100644 index 00000000..ad59ec7c --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/__init__.py @@ -0,0 +1,3 @@ +""" +MCP for Unity Server package. +""" diff --git a/MCPForUnity/UnityMcpServer~/src/config.py b/MCPForUnity/UnityMcpServer~/src/config.py new file mode 100644 index 00000000..526522da --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/config.py @@ -0,0 +1,48 @@ +""" +Configuration settings for the MCP for Unity Server. +This file contains all configurable parameters for the server. +""" + +from dataclasses import dataclass + + +@dataclass +class ServerConfig: + """Main configuration class for the MCP server.""" + + # Network settings + unity_host: str = "localhost" + unity_port: int = 6400 + mcp_port: int = 6500 + + # Connection settings + # short initial timeout; retries use shorter timeouts + connection_timeout: float = 1.0 + buffer_size: int = 16 * 1024 * 1024 # 16MB buffer + # Framed receive behavior + # max seconds to wait while consuming heartbeats only + framed_receive_timeout: float = 2.0 + # cap heartbeat frames consumed before giving up + max_heartbeat_frames: int = 16 + + # Logging settings + log_level: str = "INFO" + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Server settings + max_retries: int = 10 + retry_delay: float = 0.25 + # Backoff hint returned to clients when Unity is reloading (milliseconds) + reload_retry_ms: int = 250 + # Number of polite retries when Unity reports reloading + # 40 × 250ms ≈ 10s default window + reload_max_retries: int = 40 + + # Telemetry settings + telemetry_enabled: bool = True + # Align with telemetry.py default Cloud Run endpoint + telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" + + +# Create a global config instance +config = ServerConfig() diff --git a/MCPForUnity/UnityMcpServer~/src/port_discovery.py b/MCPForUnity/UnityMcpServer~/src/port_discovery.py new file mode 100644 index 00000000..b936f967 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/port_discovery.py @@ -0,0 +1,160 @@ +""" +Port discovery utility for MCP for Unity Server. + +What changed and why: +- Unity now writes a per-project port file named like + `~/.unity-mcp/unity-mcp-port-.json` to avoid projects overwriting + each other's saved port. The legacy file `unity-mcp-port.json` may still + exist. +- This module now scans for both patterns, prefers the most recently + modified file, and verifies that the port is actually a MCP for Unity listener + (quick socket connect + ping) before choosing it. +""" + +import glob +import json +import logging +from pathlib import Path +import socket +from typing import Optional, List + +logger = logging.getLogger("mcp-for-unity-server") + + +class PortDiscovery: + """Handles port discovery from Unity Bridge registry""" + REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file + DEFAULT_PORT = 6400 + CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery + + @staticmethod + def get_registry_path() -> Path: + """Get the path to the port registry file""" + return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE + + @staticmethod + def get_registry_dir() -> Path: + return Path.home() / ".unity-mcp" + + @staticmethod + def list_candidate_files() -> List[Path]: + """Return candidate registry files, newest first. + Includes hashed per-project files and the legacy file (if present). + """ + base = PortDiscovery.get_registry_dir() + hashed = sorted( + (Path(p) for p in glob.glob(str(base / "unity-mcp-port-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + legacy = PortDiscovery.get_registry_path() + if legacy.exists(): + # Put legacy at the end so hashed, per-project files win + hashed.append(legacy) + return hashed + + @staticmethod + def _try_probe_unity_mcp(port: int) -> bool: + """Quickly check if a MCP for Unity listener is on this port. + Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + """ + try: + with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: + s.settimeout(PortDiscovery.CONNECT_TIMEOUT) + try: + s.sendall(b"ping") + data = s.recv(512) + # Minimal validation: look for a success pong response + if data and b'"message":"pong"' in data: + return True + except Exception: + return False + except Exception: + return False + return False + + @staticmethod + def _read_latest_status() -> Optional[dict]: + try: + base = PortDiscovery.get_registry_dir() + status_files = sorted( + (Path(p) + for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not status_files: + return None + with status_files[0].open('r') as f: + return json.load(f) + except Exception: + return None + + @staticmethod + def discover_unity_port() -> int: + """ + Discover Unity port by scanning per-project and legacy registry files. + Prefer the newest file whose port responds; fall back to first parsed + value; finally default to 6400. + + Returns: + Port number to connect to + """ + # Prefer the latest heartbeat status if it points to a responsive port + status = PortDiscovery._read_latest_status() + if status: + port = status.get('unity_port') + if isinstance(port, int) and PortDiscovery._try_probe_unity_mcp(port): + logger.info(f"Using Unity port from status: {port}") + return port + + candidates = PortDiscovery.list_candidate_files() + + first_seen_port: Optional[int] = None + + for path in candidates: + try: + with open(path, 'r') as f: + cfg = json.load(f) + unity_port = cfg.get('unity_port') + if isinstance(unity_port, int): + if first_seen_port is None: + first_seen_port = unity_port + if PortDiscovery._try_probe_unity_mcp(unity_port): + logger.info( + f"Using Unity port from {path.name}: {unity_port}") + return unity_port + except Exception as e: + logger.warning(f"Could not read port registry {path}: {e}") + + if first_seen_port is not None: + logger.info( + f"No responsive port found; using first seen value {first_seen_port}") + return first_seen_port + + # Fallback to default port + logger.info( + f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + return PortDiscovery.DEFAULT_PORT + + @staticmethod + def get_port_config() -> Optional[dict]: + """ + Get the most relevant port configuration from registry. + Returns the most recent hashed file's config if present, + otherwise the legacy file's config. Returns None if nothing exists. + + Returns: + Port configuration dict or None if not found + """ + candidates = PortDiscovery.list_candidate_files() + if not candidates: + return None + for path in candidates: + try: + with open(path, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning( + f"Could not read port configuration {path}: {e}") + return None diff --git a/MCPForUnity/UnityMcpServer~/src/pyproject.toml b/MCPForUnity/UnityMcpServer~/src/pyproject.toml new file mode 100644 index 00000000..0d624bf8 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "MCPForUnityServer" +version = "5.0.0" +description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.15.0"] + +[build-system] +requires = ["setuptools>=64.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["config", "server", "unity_connection"] +packages = ["tools"] diff --git a/MCPForUnity/UnityMcpServer~/src/pyrightconfig.json b/MCPForUnity/UnityMcpServer~/src/pyrightconfig.json new file mode 100644 index 00000000..4fdeb465 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "typeCheckingMode": "basic", + "reportMissingImports": "none", + "pythonVersion": "3.11", + "executionEnvironments": [ + { + "root": ".", + "pythonVersion": "3.11" + } + ] +} diff --git a/MCPForUnity/UnityMcpServer~/src/registry/__init__.py b/MCPForUnity/UnityMcpServer~/src/registry/__init__.py new file mode 100644 index 00000000..5beb708b --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/registry/__init__.py @@ -0,0 +1,14 @@ +""" +Registry package for MCP tool auto-discovery. +""" +from .tool_registry import ( + mcp_for_unity_tool, + get_registered_tools, + clear_registry +) + +__all__ = [ + 'mcp_for_unity_tool', + 'get_registered_tools', + 'clear_registry' +] diff --git a/MCPForUnity/UnityMcpServer~/src/registry/tool_registry.py b/MCPForUnity/UnityMcpServer~/src/registry/tool_registry.py new file mode 100644 index 00000000..bbe36439 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/registry/tool_registry.py @@ -0,0 +1,51 @@ +""" +Tool registry for auto-discovery of MCP tools. +""" +from typing import Callable, Any + +# Global registry to collect decorated tools +_tool_registry: list[dict[str, Any]] = [] + + +def mcp_for_unity_tool( + name: str | None = None, + description: str | None = None, + **kwargs +) -> Callable: + """ + Decorator for registering MCP tools in the server's tools directory. + + Tools are registered in the global tool registry. + + Args: + name: Tool name (defaults to function name) + description: Tool description + **kwargs: Additional arguments passed to @mcp.tool() + + Example: + @mcp_for_unity_tool(description="Does something cool") + async def my_custom_tool(ctx: Context, ...): + pass + """ + def decorator(func: Callable) -> Callable: + tool_name = name if name is not None else func.__name__ + _tool_registry.append({ + 'func': func, + 'name': tool_name, + 'description': description, + 'kwargs': kwargs + }) + + return func + + return decorator + + +def get_registered_tools() -> list[dict[str, Any]]: + """Get all registered tools""" + return _tool_registry.copy() + + +def clear_registry(): + """Clear the tool registry (useful for testing)""" + _tool_registry.clear() diff --git a/MCPForUnity/UnityMcpServer~/src/reload_sentinel.py b/MCPForUnity/UnityMcpServer~/src/reload_sentinel.py new file mode 100644 index 00000000..71e5f623 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/reload_sentinel.py @@ -0,0 +1,9 @@ +""" +Deprecated: Sentinel flipping is handled inside Unity via the MCP menu +'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim. +All functions are no-ops to prevent accidental external writes. +""" + + +def flip_reload_sentinel(*args, **kwargs) -> str: + return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py new file mode 100644 index 00000000..af6fe036 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -0,0 +1,194 @@ +from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType +from mcp.server.fastmcp import FastMCP +import logging +from logging.handlers import RotatingFileHandler +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator, Dict, Any +from config import config +from tools import register_all_tools +from unity_connection import get_unity_connection, UnityConnection +import time + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format, + stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio + force=True # Ensure our handler replaces any prior stdout handlers +) +logger = logging.getLogger("mcp-for-unity-server") + +# 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") + _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.setFormatter(logging.Formatter(config.log_format)) + _fh.setLevel(getattr(logging, config.log_level)) + logger.addHandler(_fh) + # Also route telemetry logger to the same rotating file and normal level + try: + tlog = logging.getLogger("unity-mcp-telemetry") + tlog.setLevel(getattr(logging, config.log_level)) + tlog.addHandler(_fh) + except Exception: + # Never let logging setup break startup + pass +except Exception: + # Never let logging setup break startup + pass +# 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))) + except Exception: + pass + +# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels +# 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 + +# Global connection state +_unity_connection: UnityConnection = None + + +@asynccontextmanager +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() + try: + from pathlib import Path + ver_path = Path(__file__).parent / "server_version.txt" + server_version = ver_path.read_text(encoding="utf-8").strip() + except Exception: + server_version = "unknown" + # Defer initial telemetry by 1s to avoid stdio handshake interference + import threading + + def _emit_startup(): + try: + record_telemetry(RecordType.STARTUP, { + "server_version": server_version, + "startup_time": start_time, + }) + record_milestone(MilestoneType.FIRST_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") + if skip_connect: + 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( + RecordType.UNITY_CONNECTION, + { + "status": "connected", + "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] + _t.Timer(1.0, lambda: record_telemetry( + RecordType.UNITY_CONNECTION, + { + "status": "failed", + "error": _err_msg, + "connection_time_ms": (time.perf_counter() - start_clk) * 1000, + } + )).start() + except Exception as e: + logger.warning( + "Unexpected error connecting to Unity on startup: %s", e) + _unity_connection = None + import threading as _t + _err_msg = str(e)[:200] + _t.Timer(1.0, lambda: record_telemetry( + RecordType.UNITY_CONNECTION, + { + "status": "failed", + "error": _err_msg, + "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) + yield {"bridge": _unity_connection} + finally: + if _unity_connection: + _unity_connection.disconnect() + _unity_connection = None + logger.info("MCP for Unity Server shut down") + +# Initialize MCP server +mcp = FastMCP( + name="mcp-for-unity-server", + lifespan=server_lifespan +) + +# Register all tools +register_all_tools(mcp) + +# Asset Creation Strategy + + +@mcp.prompt() +def asset_creation_strategy() -> str: + """Guide for discovering and using MCP for Unity tools effectively.""" + return ( + "Available MCP for Unity Server Tools:\n\n" + "- `manage_editor`: Controls editor state and queries info.\n" + "- `manage_menu_item`: Executes, lists and checks for the existence of Unity Editor menu items.\n" + "- `read_console`: Reads or clears Unity console messages, with filtering options.\n" + "- `manage_scene`: Manages scenes.\n" + "- `manage_gameobject`: Manages GameObjects in the scene.\n" + "- `manage_script`: Manages C# script files.\n" + "- `manage_asset`: Manages prefabs and assets.\n" + "- `manage_shader`: Manages shaders.\n\n" + "Tips:\n" + "- Create prefabs for reusable GameObjects.\n" + "- Always include a camera and main light in your scenes.\n" + "- Unless specified otherwise, paths are relative to the project's `Assets/` folder.\n" + "- After creating or modifying scripts with `manage_script`, allow Unity to recompile; use `read_console` to check for compile errors.\n" + "- Use `manage_menu_item` for interacting with Unity systems and third party tools like a user would.\n" + "- List menu items before using them if you are unsure of the menu path.\n" + "- If a menu item seems missing, refresh the cache: use manage_menu_item with action='list' and refresh=true, or action='refresh'. Avoid refreshing every time; prefer refresh only when the menu set likely changed.\n" + ) + + +# Run the server +if __name__ == "__main__": + mcp.run(transport='stdio') diff --git a/MCPForUnity/UnityMcpServer~/src/server_version.txt b/MCPForUnity/UnityMcpServer~/src/server_version.txt new file mode 100644 index 00000000..0062ac97 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/server_version.txt @@ -0,0 +1 @@ +5.0.0 diff --git a/MCPForUnity/UnityMcpServer~/src/telemetry.py b/MCPForUnity/UnityMcpServer~/src/telemetry.py new file mode 100644 index 00000000..bfcfe76a --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/telemetry.py @@ -0,0 +1,460 @@ +""" +Privacy-focused, anonymous telemetry system for Unity MCP +Inspired by Onyx's telemetry implementation with Unity-specific adaptations + +Fire-and-forget telemetry sender with a single background worker. +- No context/thread-local propagation to avoid re-entrancy into tool resolution. +- Small network timeouts to prevent stalls. +""" + +import contextlib +from dataclasses import dataclass +from enum import Enum +import importlib +import json +import logging +import os +from pathlib import Path +import platform +import queue +import sys +import threading +import time +from typing import Optional, Dict, Any +from urllib.parse import urlparse +import uuid + +try: + import httpx + HAS_HTTPX = True +except ImportError: + httpx = None # type: ignore + HAS_HTTPX = False + +logger = logging.getLogger("unity-mcp-telemetry") + + +class RecordType(str, Enum): + """Types of telemetry records we collect""" + VERSION = "version" + STARTUP = "startup" + USAGE = "usage" + LATENCY = "latency" + FAILURE = "failure" + TOOL_EXECUTION = "tool_execution" + UNITY_CONNECTION = "unity_connection" + CLIENT_CONNECTION = "client_connection" + + +class MilestoneType(str, Enum): + """Major user journey milestones""" + FIRST_STARTUP = "first_startup" + FIRST_TOOL_USAGE = "first_tool_usage" + FIRST_SCRIPT_CREATION = "first_script_creation" + FIRST_SCENE_MODIFICATION = "first_scene_modification" + MULTIPLE_SESSIONS = "multiple_sessions" + DAILY_ACTIVE_USER = "daily_active_user" + WEEKLY_ACTIVE_USER = "weekly_active_user" + + +@dataclass +class TelemetryRecord: + """Structure for telemetry data""" + record_type: RecordType + timestamp: float + customer_uuid: str + session_id: str + data: Dict[str, Any] + milestone: Optional[MilestoneType] = None + + +class TelemetryConfig: + """Telemetry configuration""" + + def __init__(self): + # Prefer config file, then allow env overrides + server_config = None + for modname in ( + "MCPForUnity.UnityMcpServer~.src.config", + "MCPForUnity.UnityMcpServer.src.config", + "src.config", + "config", + ): + try: + mod = importlib.import_module(modname) + server_config = getattr(mod, "config", None) + if server_config is not None: + break + except Exception: + continue + + # Determine enabled flag: config -> env DISABLE_* opt-out + cfg_enabled = True if server_config is None else bool( + getattr(server_config, "telemetry_enabled", True)) + self.enabled = cfg_enabled and not self._is_disabled() + + # Telemetry endpoint (Cloud Run default; override via env) + cfg_default = None if server_config is None else getattr( + server_config, "telemetry_endpoint", None) + default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" + self.default_endpoint = default_ep + self.endpoint = self._validated_endpoint( + os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), + default_ep, + ) + try: + logger.info( + "Telemetry configured: endpoint=%s (default=%s), timeout_env=%s", + self.endpoint, + default_ep, + os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT") or "" + ) + except Exception: + pass + + # Local storage for UUID and milestones + self.data_dir = self._get_data_directory() + self.uuid_file = self.data_dir / "customer_uuid.txt" + self.milestones_file = self.data_dir / "milestones.json" + + # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT + try: + self.timeout = float(os.environ.get( + "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) + except Exception: + self.timeout = 1.5 + try: + logger.info("Telemetry timeout=%.2fs", self.timeout) + except Exception: + pass + + # Session tracking + self.session_id = str(uuid.uuid4()) + + def _is_disabled(self) -> bool: + """Check if telemetry is disabled via environment variables""" + disable_vars = [ + "DISABLE_TELEMETRY", + "UNITY_MCP_DISABLE_TELEMETRY", + "MCP_DISABLE_TELEMETRY" + ] + + for var in disable_vars: + if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): + return True + return False + + def _get_data_directory(self) -> Path: + """Get directory for storing telemetry data""" + if os.name == 'nt': # Windows + base_dir = Path(os.environ.get( + 'APPDATA', Path.home() / 'AppData' / 'Roaming')) + elif os.name == 'posix': # macOS/Linux + if 'darwin' in os.uname().sysname.lower(): # macOS + base_dir = Path.home() / 'Library' / 'Application Support' + else: # Linux + base_dir = Path(os.environ.get('XDG_DATA_HOME', + Path.home() / '.local' / 'share')) + else: + base_dir = Path.home() / '.unity-mcp' + + data_dir = base_dir / 'UnityMCP' + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + def _validated_endpoint(self, candidate: str, fallback: str) -> str: + """Validate telemetry endpoint URL scheme; allow only http/https. + Falls back to the provided default on error. + """ + try: + parsed = urlparse(candidate) + if parsed.scheme not in ("https", "http"): + raise ValueError(f"Unsupported scheme: {parsed.scheme}") + # Basic sanity: require network location and path + if not parsed.netloc: + raise ValueError("Missing netloc in endpoint") + # Reject localhost/loopback endpoints in production to avoid accidental local overrides + host = parsed.hostname or "" + if host in ("localhost", "127.0.0.1", "::1"): + raise ValueError( + "Localhost endpoints are not allowed for telemetry") + return candidate + except Exception as e: + logger.debug( + f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}", + exc_info=True, + ) + return fallback + + +class TelemetryCollector: + """Main telemetry collection class""" + + def __init__(self): + self.config = TelemetryConfig() + self._customer_uuid: Optional[str] = None + self._milestones: Dict[str, Dict[str, Any]] = {} + self._lock: threading.Lock = threading.Lock() + # Bounded queue with single background worker (records only; no context propagation) + self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) + # Load persistent data before starting worker so first events have UUID + self._load_persistent_data() + self._worker: threading.Thread = threading.Thread( + target=self._worker_loop, daemon=True) + self._worker.start() + + def _load_persistent_data(self): + """Load UUID and milestones from disk""" + # Load customer UUID + try: + if self.config.uuid_file.exists(): + self._customer_uuid = self.config.uuid_file.read_text( + encoding="utf-8").strip() or str(uuid.uuid4()) + else: + self._customer_uuid = str(uuid.uuid4()) + try: + self.config.uuid_file.write_text( + self._customer_uuid, encoding="utf-8") + if os.name == "posix": + os.chmod(self.config.uuid_file, 0o600) + except OSError as e: + logger.debug( + f"Failed to persist customer UUID: {e}", exc_info=True) + except OSError as e: + logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) + self._customer_uuid = str(uuid.uuid4()) + + # Load milestones (failure here must not affect UUID) + try: + if self.config.milestones_file.exists(): + content = self.config.milestones_file.read_text( + encoding="utf-8") + self._milestones = json.loads(content) or {} + if not isinstance(self._milestones, dict): + self._milestones = {} + except (OSError, json.JSONDecodeError, ValueError) as e: + logger.debug(f"Failed to load milestones: {e}", exc_info=True) + self._milestones = {} + + def _save_milestones(self): + """Save milestones to disk. Caller must hold self._lock.""" + try: + self.config.milestones_file.write_text( + json.dumps(self._milestones, indent=2), + encoding="utf-8", + ) + except OSError as e: + logger.warning(f"Failed to save milestones: {e}", exc_info=True) + + def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: + """Record a milestone event, returns True if this is the first occurrence""" + if not self.config.enabled: + return False + milestone_key = milestone.value + with self._lock: + if milestone_key in self._milestones: + return False # Already recorded + milestone_data = { + "timestamp": time.time(), + "data": data or {}, + } + self._milestones[milestone_key] = milestone_data + self._save_milestones() + + # Also send as telemetry record + self.record( + record_type=RecordType.USAGE, + data={"milestone": milestone_key, **(data or {})}, + milestone=milestone + ) + + return True + + def record(self, + record_type: RecordType, + data: Dict[str, Any], + milestone: Optional[MilestoneType] = None): + """Record a telemetry event (async, non-blocking)""" + if not self.config.enabled: + return + + # Allow fallback sender when httpx is unavailable (no early return) + + record = TelemetryRecord( + record_type=record_type, + timestamp=time.time(), + customer_uuid=self._customer_uuid or "unknown", + session_id=self.config.session_id, + data=data, + milestone=milestone + ) + # Enqueue for background worker (non-blocking). Drop on backpressure. + try: + self._queue.put_nowait(record) + except queue.Full: + logger.debug("Telemetry queue full; dropping %s", + record.record_type) + + def _worker_loop(self): + """Background worker that serializes telemetry sends.""" + while True: + rec = self._queue.get() + try: + # Run sender directly; do not reuse caller context/thread-locals + self._send_telemetry(rec) + except Exception: + logger.debug("Telemetry worker send failed", exc_info=True) + finally: + with contextlib.suppress(Exception): + self._queue.task_done() + + def _send_telemetry(self, record: TelemetryRecord): + """Send telemetry data to endpoint""" + try: + # System fingerprint (top-level remains concise; details stored in data JSON) + _platform = platform.system() # 'Darwin' | 'Linux' | 'Windows' + _source = sys.platform # 'darwin' | 'linux' | 'win32' + _platform_detail = f"{_platform} {platform.release()} ({platform.machine()})" + _python_version = platform.python_version() + + # Enrich data JSON so BigQuery stores detailed fields without schema change + enriched_data = dict(record.data or {}) + enriched_data.setdefault("platform_detail", _platform_detail) + enriched_data.setdefault("python_version", _python_version) + + payload = { + "record": record.record_type.value, + "timestamp": record.timestamp, + "customer_uuid": record.customer_uuid, + "session_id": record.session_id, + "data": enriched_data, + "version": "3.0.2", # Unity MCP version + "platform": _platform, + "source": _source, + } + + if record.milestone: + payload["milestone"] = record.milestone.value + + # Prefer httpx when available; otherwise fall back to urllib + if httpx: + with httpx.Client(timeout=self.config.timeout) as client: + # Re-validate endpoint at send time to handle dynamic changes + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) + response = client.post(endpoint, json=payload) + if 200 <= response.status_code < 300: + logger.debug(f"Telemetry sent: {record.record_type}") + else: + logger.warning( + f"Telemetry failed: HTTP {response.status_code}") + else: + import urllib.request + import urllib.error + data_bytes = json.dumps(payload).encode("utf-8") + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) + req = urllib.request.Request( + endpoint, + data=data_bytes, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: + if 200 <= resp.getcode() < 300: + logger.debug( + f"Telemetry sent (urllib): {record.record_type}") + else: + logger.warning( + f"Telemetry failed (urllib): HTTP {resp.getcode()}") + except urllib.error.URLError as ue: + logger.warning(f"Telemetry send failed (urllib): {ue}") + + except Exception as e: + # Never let telemetry errors interfere with app functionality + logger.debug(f"Telemetry send failed: {e}") + + +# Global telemetry instance +_telemetry_collector: Optional[TelemetryCollector] = None + + +def get_telemetry() -> TelemetryCollector: + """Get the global telemetry collector instance""" + global _telemetry_collector + if _telemetry_collector is None: + _telemetry_collector = TelemetryCollector() + return _telemetry_collector + + +def record_telemetry(record_type: RecordType, + data: Dict[str, Any], + milestone: Optional[MilestoneType] = None): + """Convenience function to record telemetry""" + get_telemetry().record(record_type, data, milestone) + + +def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: + """Convenience function to record a milestone""" + return get_telemetry().record_milestone(milestone, data) + + +def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None): + """Record tool usage telemetry + + Args: + tool_name: Name of the tool invoked (e.g., 'manage_scene'). + success: Whether the tool completed successfully. + duration_ms: Execution duration in milliseconds. + error: Optional error message (truncated if present). + sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy'). + """ + data = { + "tool_name": tool_name, + "success": success, + "duration_ms": round(duration_ms, 2) + } + + if sub_action is not None: + try: + data["sub_action"] = str(sub_action) + except Exception: + # Ensure telemetry is never disruptive + data["sub_action"] = "unknown" + + if error: + data["error"] = str(error)[:200] # Limit error message length + + record_telemetry(RecordType.TOOL_EXECUTION, data) + + +def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): + """Record latency telemetry""" + data = { + "operation": operation, + "duration_ms": round(duration_ms, 2) + } + + if metadata: + data.update(metadata) + + record_telemetry(RecordType.LATENCY, data) + + +def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): + """Record failure telemetry""" + data = { + "component": component, + "error": str(error)[:500] # Limit error message length + } + + if metadata: + data.update(metadata) + + record_telemetry(RecordType.FAILURE, data) + + +def is_telemetry_enabled() -> bool: + """Check if telemetry is enabled""" + return get_telemetry().config.enabled diff --git a/MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py b/MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py new file mode 100644 index 00000000..7e892809 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/telemetry_decorator.py @@ -0,0 +1,107 @@ +""" +Telemetry decorator for Unity MCP tools +""" + +import functools +import inspect +import logging +import time +from typing import Callable, Any + +from telemetry import record_tool_usage, record_milestone, MilestoneType + +_log = logging.getLogger("unity-mcp-telemetry") +_decorator_log_count = 0 + + +def telemetry_tool(tool_name: str): + """Decorator to add telemetry tracking to MCP tools""" + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def _sync_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + # Extract sub-action (e.g., 'get_hierarchy') from bound args when available + sub_action = None + try: + sig = inspect.signature(func) + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + sub_action = bound.arguments.get("action") + except Exception: + sub_action = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info(f"telemetry_decorator sync: tool={tool_name}") + _decorator_log_count += 1 + result = func(*args, **kwargs) + success = True + action_val = sub_action or kwargs.get("action") + try: + if tool_name == "manage_script" and action_val == "create": + record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) + elif tool_name.startswith("manage_scene"): + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone(MilestoneType.FIRST_TOOL_USAGE) + except Exception: + _log.debug("milestone emit failed", exc_info=True) + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) + except Exception: + _log.debug("record_tool_usage failed", exc_info=True) + + @functools.wraps(func) + async def _async_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + success = False + error = None + # Extract sub-action (e.g., 'get_hierarchy') from bound args when available + sub_action = None + try: + sig = inspect.signature(func) + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + sub_action = bound.arguments.get("action") + except Exception: + sub_action = None + try: + global _decorator_log_count + if _decorator_log_count < 10: + _log.info(f"telemetry_decorator async: tool={tool_name}") + _decorator_log_count += 1 + result = await func(*args, **kwargs) + success = True + action_val = sub_action or kwargs.get("action") + try: + if tool_name == "manage_script" and action_val == "create": + record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) + elif tool_name.startswith("manage_scene"): + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone(MilestoneType.FIRST_TOOL_USAGE) + except Exception: + _log.debug("milestone emit failed", exc_info=True) + return result + except Exception as e: + error = str(e) + raise + finally: + duration_ms = (time.time() - start_time) * 1000 + try: + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) + except Exception: + _log.debug("record_tool_usage failed", exc_info=True) + + return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper + return decorator diff --git a/MCPForUnity/UnityMcpServer~/src/test_telemetry.py b/MCPForUnity/UnityMcpServer~/src/test_telemetry.py new file mode 100644 index 00000000..145f14e1 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/test_telemetry.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Test script for Unity MCP Telemetry System +Run this to verify telemetry is working correctly +""" + +import os +from pathlib import Path +import sys + +# Add src to Python path for imports +sys.path.insert(0, str(Path(__file__).parent)) + + +def test_telemetry_basic(): + """Test basic telemetry functionality""" + # Avoid stdout noise in tests + + try: + from telemetry import ( + get_telemetry, record_telemetry, record_milestone, + RecordType, MilestoneType, is_telemetry_enabled + ) + pass + except ImportError as e: + # Silent failure path for tests + return False + + # Test telemetry enabled status + _ = is_telemetry_enabled() + + # Test basic record + try: + record_telemetry(RecordType.VERSION, { + "version": "3.0.2", + "test_run": True + }) + pass + except Exception as e: + # Silent failure path for tests + return False + + # Test milestone recording + try: + is_first = record_milestone(MilestoneType.FIRST_STARTUP, { + "test_mode": True + }) + _ = is_first + except Exception as e: + # Silent failure path for tests + return False + + # Test telemetry collector + try: + collector = get_telemetry() + _ = collector + except Exception as e: + # Silent failure path for tests + return False + + return True + + +def test_telemetry_disabled(): + """Test telemetry with disabled state""" + # Silent for tests + + # Set environment variable to disable telemetry + os.environ["DISABLE_TELEMETRY"] = "true" + + # Re-import to get fresh config + import importlib + import telemetry + importlib.reload(telemetry) + + from telemetry import is_telemetry_enabled, record_telemetry, RecordType + + _ = is_telemetry_enabled() + + if not is_telemetry_enabled(): + pass + + # Test that records are ignored when disabled + record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) + pass + + return True + else: + pass + return False + + +def test_data_storage(): + """Test data storage functionality""" + # Silent for tests + + try: + from telemetry import get_telemetry + + collector = get_telemetry() + data_dir = collector.config.data_dir + + _ = (data_dir, collector.config.uuid_file, + collector.config.milestones_file) + + # Check if files exist + if collector.config.uuid_file.exists(): + pass + else: + pass + + if collector.config.milestones_file.exists(): + pass + else: + pass + + return True + + except Exception as e: + # Silent failure path for tests + return False + + +def main(): + """Run all telemetry tests""" + # Silent runner for CI + + tests = [ + test_telemetry_basic, + test_data_storage, + test_telemetry_disabled, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + if test(): + passed += 1 + pass + else: + failed += 1 + pass + except Exception as e: + failed += 1 + pass + + _ = (passed, failed) + + if failed == 0: + pass + return True + else: + pass + return False + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py new file mode 100644 index 00000000..6ede53d3 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py @@ -0,0 +1,60 @@ +""" +MCP Tools package - Auto-discovers and registers all tools in this directory. +""" +import importlib +import logging +from pathlib import Path +import pkgutil + +from mcp.server.fastmcp import FastMCP +from telemetry_decorator import telemetry_tool + +from registry import get_registered_tools, mcp_for_unity_tool + +logger = logging.getLogger("mcp-for-unity-server") + +# Export decorator for easy imports within tools +__all__ = ['register_all_tools', 'mcp_for_unity_tool'] + + +def register_all_tools(mcp: FastMCP): + """ + Auto-discover and register all tools in the tools/ directory. + + Any .py file in this directory with @mcp_for_unity_tool decorated + functions will be automatically registered. + """ + logger.info("Auto-discovering MCP for Unity Server tools...") + # Dynamic import of all modules in this directory + tools_dir = Path(__file__).parent + + for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]): + # Skip private modules and __init__ + if module_name.startswith('_'): + continue + + try: + importlib.import_module(f'.{module_name}', __package__) + except Exception as e: + logger.warning(f"Failed to import tool module {module_name}: {e}") + + tools = get_registered_tools() + + if not tools: + logger.warning("No MCP tools registered!") + return + + for tool_info in tools: + func = tool_info['func'] + tool_name = tool_info['name'] + description = tool_info['description'] + kwargs = tool_info['kwargs'] + + # Apply the @mcp.tool decorator and telemetry + wrapped = mcp.tool( + name=tool_name, description=description, **kwargs)(func) + wrapped = telemetry_tool(tool_name)(wrapped) + tool_info['func'] = wrapped + logger.info(f"Registered tool: {tool_name} - {description}") + + logger.info(f"Registered {len(tools)} MCP tools") diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py new file mode 100644 index 00000000..5e21d2ce --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py @@ -0,0 +1,83 @@ +""" +Defines the manage_asset tool for interacting with Unity assets. +""" +import asyncio +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Performs asset operations (import, create, modify, delete, etc.) in Unity." +) +async def manage_asset( + ctx: Context, + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], + path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], + asset_type: Annotated[str, + "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, + properties: Annotated[dict[str, Any], + "Dictionary of properties for 'create'/'modify'."] | None = None, + destination: Annotated[str, + "Target path for 'duplicate'/'move'."] | None = None, + generate_preview: Annotated[bool, + "Generate a preview/thumbnail for the asset when supported."] = False, + search_pattern: Annotated[str, + "Search pattern (e.g., '*.prefab')."] | None = None, + filter_type: Annotated[str, "Filter type for search"] | None = None, + filter_date_after: Annotated[str, + "Date after which to filter"] | None = None, + page_size: Annotated[int, "Page size for pagination"] | None = None, + page_number: Annotated[int, "Page number for pagination"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing manage_asset: {action}") + # Ensure properties is a dict if None + if properties is None: + properties = {} + + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + page_size = _coerce_int(page_size) + page_number = _coerce_int(page_number) + + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } + + # Remove None values to avoid sending unnecessary nulls + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Get the current asyncio event loop + loop = asyncio.get_running_loop() + + # Use centralized async retry helper to avoid blocking the event loop + result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) + # Return the result obtained from Unity + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py new file mode 100644 index 00000000..c0de76c2 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py @@ -0,0 +1,57 @@ +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from telemetry import is_telemetry_enabled, record_tool_usage +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Controls and queries the Unity editor's state and settings" +) +def manage_editor( + ctx: Context, + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], + wait_for_completion: Annotated[bool, + "Optional. If True, waits for certain actions"] | None = None, + tool_name: Annotated[str, + "Tool name when setting active tool"] | None = None, + tag_name: Annotated[str, + "Tag name when adding and removing tags"] | None = None, + layer_name: Annotated[str, + "Layer name when adding and removing layers"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_editor: {action}") + try: + # Diagnostics: quick telemetry checks + if action == "telemetry_status": + return {"success": True, "telemetry_enabled": is_telemetry_enabled()} + + if action == "telemetry_ping": + record_tool_usage("diagnostic_ping", True, 1.0, None) + return {"success": True, "message": "telemetry ping queued"} + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command using centralized retry helper + response = send_command_with_retry("manage_editor", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py new file mode 100644 index 00000000..a8ca1609 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py @@ -0,0 +1,145 @@ +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." +) +def manage_gameobject( + ctx: Context, + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], + target: Annotated[str, + "GameObject identifier by name or path for modify/delete/component actions"] | None = None, + search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], + "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, + name: Annotated[str, + "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, + tag: Annotated[str, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, + parent: Annotated[str, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, + position: Annotated[list[float], + "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, + rotation: Annotated[list[float], + "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, + scale: Annotated[list[float], + "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, + components_to_add: Annotated[list[str], + "List of component names to add"] | None = None, + primitive_type: Annotated[str, + "Primitive type for 'create' action"] | None = None, + save_as_prefab: Annotated[bool, + "If True, saves the created GameObject as a prefab"] | None = None, + prefab_path: Annotated[str, "Path for prefab creation"] | None = None, + prefab_folder: Annotated[str, + "Folder for prefab creation"] | None = None, + # --- Parameters for 'modify' --- + set_active: Annotated[bool, + "If True, sets the GameObject active"] | None = None, + layer: Annotated[str, "Layer name"] | None = None, + components_to_remove: Annotated[list[str], + "List of component names to remove"] | None = None, + component_properties: Annotated[dict[str, dict[str, Any]], + """Dictionary of component names to their properties to set. For example: + `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject + `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component + Example set nested property: + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, + # --- Parameters for 'find' --- + search_term: Annotated[str, + "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, + find_all: Annotated[bool, + "If True, finds all GameObjects matching the search term"] | None = None, + search_in_children: Annotated[bool, + "If True, searches in children of the GameObject"] | None = None, + search_inactive: Annotated[bool, + "If True, searches inactive GameObjects"] | None = None, + # -- Component Management Arguments -- + component_name: Annotated[str, + "Component name for 'add_component' and 'remove_component' actions"] | None = None, + # Controls whether serialization of private [SerializeField] fields is included + includeNonPublicSerialized: Annotated[bool, + "Controls whether serialization of private [SerializeField] fields is included"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_gameobject: {action}") + try: + # Validate parameter usage to prevent silent failures + if action == "find": + if name is not None: + return { + "success": False, + "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" + } + if search_term is None: + return { + "success": False, + "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." + } + + if action in ["create", "modify"]: + if search_term is not None: + return { + "success": False, + "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." + } + + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "setActive": set_active, + "layer": layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized + } + params = {k: v for k, v in params.items() if v is not None} + + # --- Handle Prefab Path Logic --- + # Check if 'saveAsPrefab' is explicitly True in params + if action == "create" and params.get("saveAsPrefab"): + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefabFolder", None) + # -------------------------------- + + # Use centralized retry helper + response = send_command_with_retry("manage_gameobject", params) + + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_menu_item.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_menu_item.py new file mode 100644 index 00000000..5463614d --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_menu_item.py @@ -0,0 +1,41 @@ +""" +Defines the manage_menu_item tool for executing and reading Unity Editor menu items. +""" +import asyncio +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import async_send_command_with_retry + + +@mcp_for_unity_tool( + description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'." +) +async def manage_menu_item( + ctx: Context, + action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."], + menu_path: Annotated[str, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, + search: Annotated[str, + "Optional filter string for 'list' (e.g., 'Save')"] | None = None, + refresh: Annotated[bool, + "Optional flag to force refresh of the menu cache when listing"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_menu_item: {action}") + # Prepare parameters for the C# handler + params_dict: dict[str, Any] = { + "action": action, + "menuPath": menu_path, + "search": search, + "refresh": refresh, + } + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} + + # Get the current asyncio event loop + loop = asyncio.get_running_loop() + + # Use centralized async retry helper + result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py new file mode 100644 index 00000000..ea89201c --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py @@ -0,0 +1,58 @@ +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Bridge for prefab management commands (stage control and creation)." +) +def manage_prefabs( + ctx: Context, + action: Annotated[Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "create_from_gameobject", + ], "Manage prefabs (stage control and creation)."], + prefab_path: Annotated[str, + "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, + mode: Annotated[str, + "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, + save_before_close: Annotated[bool, + "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, + target: Annotated[str, + "Scene GameObject name required for create_from_gameobject"] | None = None, + allow_overwrite: Annotated[bool, + "Allow replacing an existing prefab at the same path"] | None = None, + search_inactive: Annotated[bool, + "Include inactive objects when resolving the target name"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_prefabs: {action}") + try: + params: dict[str, Any] = {"action": action} + + if prefab_path: + params["prefabPath"] = prefab_path + if mode: + params["mode"] = mode + if save_before_close is not None: + params["saveBeforeClose"] = bool(save_before_close) + if target: + params["target"] = target + if allow_overwrite is not None: + params["allowOverwrite"] = bool(allow_overwrite) + if search_inactive is not None: + params["searchInactive"] = bool(search_inactive) + response = send_command_with_retry("manage_prefabs", params) + + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Prefab operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + except Exception as exc: + return {"success": False, "message": f"Python error managing prefabs: {exc}"} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py new file mode 100644 index 00000000..09494e4a --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py @@ -0,0 +1,56 @@ +from typing import Annotated, Literal, Any + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool(description="Manage Unity scenes") +def manage_scene( + ctx: Context, + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], + name: Annotated[str, + "Scene name. Not required get_active/get_build_settings"] | None = None, + path: Annotated[str, + "Asset path for scene operations (default: 'Assets/')"] | None = None, + build_index: Annotated[int, + "Build index for load/build settings actions"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_scene: {action}") + try: + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + coerced_build_index = _coerce_int(build_index, default=None) + + params = {"action": action} + if name: + params["name"] = name + if path: + params["path"] = path + if coerced_build_index is not None: + params["buildIndex"] = coerced_build_index + + # Use centralized retry helper + response = send_command_with_retry("manage_scene", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py new file mode 100644 index 00000000..cad6a88c --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py @@ -0,0 +1,552 @@ +import base64 +import os +from typing import Annotated, Any, Literal +from urllib.parse import urlparse, unquote + +from mcp.server.fastmcp import FastMCP, Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str + if uri.startswith("unity://path/"): + raw_path = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) + else: + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) + parts = [p for p in norm.split("/") if p not in ("", ".")] + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None + + effective_path = assets_rel if assets_rel else norm + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) + return name, directory + + +@mcp_for_unity_tool(description=( + """Apply small text edits to a C# script identified by URI. + IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! + RECOMMENDED WORKFLOW: + 1. First call resources/read with start_line/line_count to verify exact content + 2. Count columns carefully (or use find_in_file to locate patterns) + 3. Apply your edit with precise coordinates + 4. Consider script_apply_edits with anchors for safer pattern-based replacements + Notes: + - For method/class operations, use script_apply_edits (safer, structured edits) + - For pattern-based replacements, consider anchor operations in script_apply_edits + - Lines, columns are 1-indexed + - Tabs count as 1 column""" +)) +def apply_text_edits( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], + precondition_sha256: Annotated[str, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, + strict: Annotated[bool, + "Optional strict flag, used to enforce strict mode"] | None = None, + options: Annotated[dict[str, Any], + "Optional options, used to pass additional options to the script editor"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing apply_text_edits: {uri}") + name, directory = _split_uri(uri) + + # Normalize common aliases/misuses for resilience: + # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} + # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} + # If normalization is required, read current contents to map indices -> 1-based line/col. + def _needs_normalization(arr: list[dict[str, Any]]) -> bool: + for e in arr or []: + if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): + return True + return False + + normalized_edits: list[dict[str, Any]] = [] + warnings: list[str] = [] + if _needs_normalization(edits): + # Read file to support index->line/col conversion when needed + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": directory, + }) + if not (isinstance(read_resp, dict) and read_resp.get("success")): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") + except Exception: + contents = contents or "" + + # Helper to map 0-based character index to 1-based line/col + def line_col_from_index(idx: int) -> tuple[int, int]: + if idx <= 0: + return 1, 1 + # Count lines up to idx and position within line + nl_count = contents.count("\n", 0, idx) + line = nl_count + 1 + last_nl = contents.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + for e in edits or []: + e2 = dict(e) + # Map text->newText if needed + if "newText" not in e2 and "text" in e2: + e2["newText"] = e2.pop("text") + + if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: + # Guard: explicit fields must be 1-based. + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} + # Normalize by clamping to 1 and warn + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + continue + + rng = e2.get("range") + if isinstance(rng, dict): + # LSP style: 0-based + s = rng.get("start", {}) + t = rng.get("end", {}) + e2["startLine"] = int(s.get("line", 0)) + 1 + e2["startCol"] = int(s.get("character", 0)) + 1 + e2["endLine"] = int(t.get("line", 0)) + 1 + e2["endCol"] = int(t.get("character", 0)) + 1 + e2.pop("range", None) + normalized_edits.append(e2) + continue + if isinstance(rng, (list, tuple)) and len(rng) == 2: + try: + a = int(rng[0]) + b = int(rng[1]) + if b < a: + a, b = b, a + sl, sc = line_col_from_index(a) + el, ec = line_col_from_index(b) + e2["startLine"] = sl + e2["startCol"] = sc + e2["endLine"] = el + e2["endCol"] = ec + e2.pop("range", None) + normalized_edits.append(e2) + continue + except Exception: + pass + # Could not normalize this edit + return { + "success": False, + "code": "missing_field", + "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", + "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} + } + else: + # Even when edits appear already in explicit form, validate 1-based coordinates. + normalized_edits = [] + for e in edits or []: + e2 = dict(e) + has_all = all(k in e2 for k in ( + "startLine", "startCol", "endLine", "endCol")) + if has_all: + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + e2[k] = 1 + except Exception: + pass + if "zero_based_explicit_fields_normalized" not in warnings: + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + + # Preflight: detect overlapping ranges among normalized line/col spans + def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: + return ( + int(e.get("startLine", 1)) if key_start else int( + e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int( + e.get("endCol", 1)), + ) + + def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: + return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) + + # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. + spans = [] + for e in normalized_edits or []: + try: + s = _pos_tuple(e, True) + t = _pos_tuple(e, False) + if s != t: + spans.append((s, t)) + except Exception: + # If coordinates missing or invalid, let the server validate later + pass + + if spans: + spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) + for i in range(1, len(spans_sorted)): + prev_end = spans_sorted[i-1][1] + curr_start = spans_sorted[i][0] + # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start + if not _le(prev_end, curr_start): + conflicts = [{ + "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, + "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, + "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, + "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, + }] + return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} + + # Note: Do not auto-compute precondition if missing; callers should supply it + # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and + # preserves existing call-count expectations in clients/tests. + + # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance + opts: dict[str, Any] = dict(options or {}) + try: + if len(normalized_edits) > 1 and "applyMode" not in opts: + opts["applyMode"] = "atomic" + except Exception: + pass + # Support optional debug preview for span-by-span simulation without write + if opts.get("debug_preview"): + try: + import difflib + # Apply locally to preview final result + lines = [] + # Build an indexable original from a read if we normalized from read; otherwise skip + prev = "" + # We cannot guarantee file contents here without a read; return normalized spans only + return { + "success": True, + "message": "Preview only (no write)", + "data": { + "normalizedEdits": normalized_edits, + "preview": True + } + } + except Exception as e: + return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} + + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": normalized_edits, + "precondition_sha256": precondition_sha256, + "options": opts, + } + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict): + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", normalized_edits) + if warnings: + data.setdefault("warnings", warnings) + if resp.get("success") and (options or {}).get("force_sentinel_reload"): + # Optional: flip sentinel via menu if explicitly requested + try: + import threading + import time + import json + import glob + import os + + def _latest_status() -> dict | None: + try: + files = sorted(glob.glob(os.path.expanduser( + "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if not files: + return None + with open(files[0], "r") as f: + return json.loads(f.read()) + except Exception: + return None + + def _flip_async(): + try: + time.sleep(0.1) + st = _latest_status() + if st and st.get("reloading"): + return + send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + threading.Thread(target=_flip_async, daemon=True).start() + except Exception: + pass + return resp + return resp + return {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) +def create_script( + ctx: Context, + path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing create_script: {path}") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + # Local validation to avoid round-trips on obviously bad input + norm_path = os.path.normpath( + (path or "").replace("\\", "/")).replace("\\", "/") + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} + if ".." in norm_path.split("/") or norm_path.startswith("/"): + return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} + if not name: + return {"success": False, "code": "bad_path", "message": "path must include a script file name."} + if not norm_path.lower().endswith(".cs"): + return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} + params: dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents: + params["encodedContents"] = base64.b64encode( + contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) +def delete_script( + ctx: Context, + uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + """Delete a C# script by URI.""" + ctx.info(f"Processing delete_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + params = {"action": "delete", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) +def validate_script( + ctx: Context, + uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + level: Annotated[Literal['basic', 'standard'], + "Validation level"] = "basic", + include_diagnostics: Annotated[bool, + "Include full diagnostics and summary"] = False +) -> dict[str, Any]: + ctx.info(f"Processing validate_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + if level not in ("basic", "standard"): + return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + diags = resp.get("data", {}).get("diagnostics", []) or [] + warnings = sum(1 for d in diags if str( + d.get("severity", "")).lower() == "warning") + errors = sum(1 for d in diags if str( + d.get("severity", "")).lower() in ("error", "fatal")) + if include_diagnostics: + return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} + return {"success": True, "data": {"warnings": warnings, "errors": errors}} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) +def manage_script( + ctx: Context, + action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], + name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], + path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create", + "C# code for 'create'/'update'"] | None = None, + script_type: Annotated[str, "Script type (e.g., 'C#')", + "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_script: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents: + if action == 'create': + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + params = {k: v for k, v in params.items() if v is not None} + + response = send_command_with_retry("manage_script", params) + + if isinstance(response, dict): + if response.get("success"): + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response + + return {"success": False, "message": str(response)} + + except Exception as e: + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } + + +@mcp_for_unity_tool(description=( + """Get manage_script capabilities (supported ops, limits, and guards). + Returns: + - ops: list of supported structured ops + - text_ops: list of supported text ops + - max_edit_payload_bytes: server edit payload cap + - guards: header/using guard enabled flag""" +)) +def manage_script_capabilities(ctx: Context) -> dict[str, Any]: + ctx.info("Processing manage_script_capabilities") + try: + # Keep in sync with server/Editor ManageScript implementation + ops = [ + "replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" + ] + text_ops = ["replace_range", "regex_replace", "prepend", "append"] + # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback + max_edit_payload_bytes = 256 * 1024 + guards = {"using_guard": True} + extras = {"get_sha": True} + return {"success": True, "data": { + "ops": ops, + "text_ops": text_ops, + "max_edit_payload_bytes": max_edit_payload_bytes, + "guards": guards, + "extras": extras, + }} + except Exception as e: + return {"success": False, "error": f"capabilities error: {e}"} + + +@mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") +def get_sha( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + ctx.info(f"Processing get_sha: {uri}") + try: + name, directory = _split_uri(uri) + params = {"action": "get_sha", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + data = resp.get("data", {}) + minimal = {"sha256": data.get( + "sha256"), "lengthBytes": data.get("lengthBytes")} + return {"success": True, "data": minimal} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + except Exception as e: + return {"success": False, "message": f"get_sha error: {e}"} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py new file mode 100644 index 00000000..9c199661 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py @@ -0,0 +1,60 @@ +import base64 +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Manages shader scripts in Unity (create, read, update, delete)." +) +def manage_shader( + ctx: Context, + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], + name: Annotated[str, "Shader name (no .cs extension)"], + path: Annotated[str, "Asset path (default: \"Assets/\")"], + contents: Annotated[str, + "Shader code for 'create'/'update'"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_shader: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command via centralized retry helper + response = send_command_with_retry("manage_shader", params) + + # Process response from Unity + if isinstance(response, dict) and response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing shader: {str(e)}"} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py new file mode 100644 index 00000000..5fc9a096 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py @@ -0,0 +1,87 @@ +""" +Defines the read_console tool for accessing Unity Editor console messages. +""" +from typing import Annotated, Any, Literal + +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Gets messages from or clears the Unity Editor console." +) +def read_console( + ctx: Context, + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], + types: Annotated[list[Literal['error', 'warning', + 'log', 'all']], "Message types to get"] | None = None, + count: Annotated[int, "Max messages to return"] | None = None, + filter_text: Annotated[str, "Text filter for messages"] | None = None, + since_timestamp: Annotated[str, + "Get messages after this timestamp (ISO 8601)"] | None = None, + format: Annotated[Literal['plain', 'detailed', + 'json'], "Output format"] | None = None, + include_stacktrace: Annotated[bool, + "Include stack traces in output"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing read_console: {action}") + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + include_stacktrace = include_stacktrace if include_stacktrace is not None else True + + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() + + # Coerce count defensively (string/float -> int) + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + + count = _coerce_int(count) + + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types, + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if isinstance(format, str) else format, + "includeStacktrace": include_stacktrace + } + + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() + if v is not None or k == 'count'} + + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None + + # Use centralized retry helper + resp = send_command_with_retry("read_console", params_dict) + if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: + # Strip stacktrace fields from returned lines if present + try: + lines = resp.get("data", {}).get("lines", []) + for line in lines: + if isinstance(line, dict) and "stacktrace" in line: + line.pop("stacktrace", None) + except Exception: + pass + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py new file mode 100644 index 00000000..a8398f75 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py @@ -0,0 +1,392 @@ +""" +Resource wrapper tools so clients that do not expose MCP resources primitives +can still list and read files via normal tools. These call into the same +safe path logic (re-implemented here to avoid importing server.py). +""" +import fnmatch +import hashlib +import os +from pathlib import Path +import re +from typing import Annotated, Any +from urllib.parse import urlparse, unquote + +from mcp.server.fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: + """Safely coerce various inputs (str/float/etc.) to an int. + Returns default on failure; clamps to minimum when provided. + """ + if value is None: + return default + try: + # Avoid treating booleans as ints implicitly + if isinstance(value, bool): + return default + if isinstance(value, int): + result = int(value) + else: + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + # Allow "10.0" or similar inputs + result = int(float(s)) + if minimum is not None and result < minimum: + return minimum + return result + except Exception: + return default + + +def _resolve_project_root(override: str | None) -> Path: + # 1) Explicit override + if override: + pr = Path(override).expanduser().resolve() + if (pr / "Assets").exists(): + return pr + # 2) Environment + env = os.environ.get("UNITY_PROJECT_ROOT") + if env: + env_path = Path(env).expanduser() + # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir + pr = (Path.cwd( + ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() + if (pr / "Assets").exists(): + return pr + # 3) Ask Unity via manage_editor.get_project_root + try: + resp = send_command_with_retry( + "manage_editor", {"action": "get_project_root"}) + if isinstance(resp, dict) and resp.get("success"): + pr = Path(resp.get("data", {}).get( + "projectRoot", "")).expanduser().resolve() + if pr and (pr / "Assets").exists(): + return pr + except Exception: + pass + + # 4) Walk up from CWD to find a Unity project (Assets + ProjectSettings) + cur = Path.cwd().resolve() + for _ in range(6): + if (cur / "Assets").exists() and (cur / "ProjectSettings").exists(): + return cur + if cur.parent == cur: + break + cur = cur.parent + # 5) Search downwards (shallow) from repo root for first folder with Assets + ProjectSettings + try: + import os as _os + root = Path.cwd().resolve() + max_depth = 3 + for dirpath, dirnames, _ in _os.walk(root): + rel = Path(dirpath).resolve() + try: + depth = len(rel.relative_to(root).parts) + except Exception: + # Unrelated mount/permission edge; skip deeper traversal + dirnames[:] = [] + continue + if depth > max_depth: + # Prune deeper traversal + dirnames[:] = [] + continue + if (rel / "Assets").exists() and (rel / "ProjectSettings").exists(): + return rel + except Exception: + pass + # 6) Fallback: CWD + return Path.cwd().resolve() + + +def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: + raw: str | None = None + if uri.startswith("unity://path/"): + raw = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + raw = unquote(parsed.path or "") + # On Windows, urlparse('file:///C:/x') -> path='/C:/x'. Strip the leading slash for drive letters. + try: + import os as _os + if _os.name == "nt" and raw.startswith("/") and re.match(r"^/[A-Za-z]:/", raw): + raw = raw[1:] + # UNC paths: file://server/share -> netloc='server', path='/share'. Treat as \\\\server/share + if _os.name == "nt" and parsed.netloc: + raw = f"//{parsed.netloc}{raw}" + except Exception: + pass + elif uri.startswith("Assets/"): + raw = uri + if raw is None: + return None + # Normalize separators early + raw = raw.replace("\\", "/") + p = (project / raw).resolve() + try: + p.relative_to(project) + except ValueError: + return None + return p + + +@mcp_for_unity_tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) +async def list_resources( + ctx: Context, + pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", + under: Annotated[str, + "Folder under project root, default is Assets"] = "Assets", + limit: Annotated[int, "Page limit"] = 200, + project_root: Annotated[str, "Project path"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing list_resources: {pattern}") + try: + project = _resolve_project_root(project_root) + base = (project / under).resolve() + try: + base.relative_to(project) + except ValueError: + return {"success": False, "error": "Base path must be under project root"} + # Enforce listing only under Assets + try: + base.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Listing is restricted to Assets/"} + + matches: list[str] = [] + limit_int = _coerce_int(limit, default=200, minimum=1) + for p in base.rglob("*"): + if not p.is_file(): + continue + # Resolve symlinks and ensure the real path stays under project/Assets + try: + rp = p.resolve() + rp.relative_to(project / "Assets") + except Exception: + continue + # Enforce .cs extension regardless of provided pattern + if p.suffix.lower() != ".cs": + continue + if pattern and not fnmatch.fnmatch(p.name, pattern): + continue + rel = p.relative_to(project).as_posix() + matches.append(f"unity://path/{rel}") + if len(matches) >= max(1, limit_int): + break + + # Always include the canonical spec resource so NL clients can discover it + if "unity://spec/script-edits" not in matches: + matches.append("unity://spec/script-edits") + + return {"success": True, "data": {"uris": matches, "count": len(matches)}} + except Exception as e: + return {"success": False, "error": str(e)} + + +@mcp_for_unity_tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) +async def read_resource( + ctx: Context, + uri: Annotated[str, "The resource URI to read under Assets/"], + start_line: Annotated[int, + "The starting line number (0-based)"] | None = None, + line_count: Annotated[int, + "The number of lines to read"] | None = None, + head_bytes: Annotated[int, + "The number of bytes to read from the start of the file"] | None = None, + tail_lines: Annotated[int, + "The number of lines to read from the end of the file"] | None = None, + project_root: Annotated[str, + "The project root directory"] | None = None, + request: Annotated[str, "The request ID"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing read_resource: {uri}") + try: + # Serve the canonical spec directly when requested (allow bare or with scheme) + if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): + spec_json = ( + '{\n' + ' "name": "Unity MCP - Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "\u2190 server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} + + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + try: + p.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Read restricted to Assets/"} + # Natural-language convenience: request like "last 120 lines", "first 200 lines", + # "show 40 lines around MethodName", etc. + if request: + req = request.strip().lower() + m = re.search(r"last\s+(\d+)\s+lines", req) + if m: + tail_lines = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s+lines", req) + if m: + start_line = 1 + line_count = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s*bytes", req) + if m: + head_bytes = int(m.group(1)) + m = re.search( + r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + if m: + window = int(m.group(1)) + method = m.group(2) + # naive search for method header to get a line number + text_all = p.read_text(encoding="utf-8") + lines_all = text_all.splitlines() + pat = re.compile( + rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + hit_line = None + for i, line in enumerate(lines_all, start=1): + if pat.search(line): + hit_line = i + break + if hit_line: + half = max(1, window // 2) + start_line = max(1, hit_line - half) + line_count = window + + # Coerce numeric inputs defensively (string/float -> int) + start_line = _coerce_int(start_line) + line_count = _coerce_int(line_count) + head_bytes = _coerce_int(head_bytes, minimum=1) + tail_lines = _coerce_int(tail_lines, minimum=1) + + # Compute SHA over full file contents (metadata-only default) + full_bytes = p.read_bytes() + full_sha = hashlib.sha256(full_bytes).hexdigest() + + # Selection only when explicitly requested via windowing args or request text hints + selection_requested = bool(head_bytes or tail_lines or ( + start_line is not None and line_count is not None) or request) + if selection_requested: + # Mutually exclusive windowing options precedence: + # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text + if head_bytes and head_bytes > 0: + raw = full_bytes[: head_bytes] + text = raw.decode("utf-8", errors="replace") + else: + text = full_bytes.decode("utf-8", errors="replace") + if tail_lines is not None and tail_lines > 0: + lines = text.splitlines() + n = max(0, tail_lines) + text = "\n".join(lines[-n:]) + elif start_line is not None and line_count is not None and line_count >= 0: + lines = text.splitlines() + s = max(0, start_line - 1) + e = min(len(lines), s + line_count) + text = "\n".join(lines[s:e]) + return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + else: + # Default: metadata only + return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + except Exception as e: + return {"success": False, "error": str(e)} + + +@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") +async def find_in_file( + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, + project_root: Annotated[str, + "The project root directory"] | None = None, + max_results: Annotated[int, + "Cap results to avoid huge payloads"] = 200, +) -> dict[str, Any]: + ctx.info(f"Processing find_in_file: {uri}") + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + + text = p.read_text(encoding="utf-8") + flags = re.MULTILINE + if ignore_case: + flags |= re.IGNORECASE + rx = re.compile(pattern, flags) + + results = [] + max_results_int = _coerce_int(max_results, default=200, minimum=1) + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + m = rx.search(line) + if m: + start_col = m.start() + 1 # 1-based + end_col = m.end() + 1 # 1-based, end exclusive + results.append({ + "startLine": i, + "startCol": start_col, + "endLine": i, + "endCol": end_col, + }) + if max_results_int and len(results) >= max_results_int: + break + + return {"success": True, "data": {"matches": results, "count": len(results)}} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py new file mode 100644 index 00000000..59fbbc61 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py @@ -0,0 +1,966 @@ +import base64 +import hashlib +import re +from typing import Annotated, Any + +from mcp.server.fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith( + "\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE | ( + re.IGNORECASE if edit.get("ignore_case") else 0) + + # Find the best match using improved heuristics + match = _find_best_anchor_match( + anchor, text, flags, bool(edit.get("prefer_last", True))) + if not match: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = match.start() if position == "before" else match.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + max_line = len(lines) + 1 # 1-based, exclusive end + if (start_line < 1 or end_line < start_line or end_line > max_line + or start_col < 1 or end_col < 1): + raise RuntimeError("replace_range out of bounds") + + def index_of(line: int, col: int) -> int: + if line <= len(lines): + return sum(len(l) for l in lines[: line - 1]) + (col - 1) + return sum(len(l) for l in lines) + a = index_of(start_line, start_col) + b = index_of(end_line, end_col) + text = text[:a] + replacement + text[b:] + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + # Translate $n backrefs (our input) to Python \g + repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl_py, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): + """ + Find the best anchor match using improved heuristics. + + For patterns like \\s*}\\s*$ that are meant to find class-ending braces, + this function uses heuristics to choose the most semantically appropriate match: + + 1. If prefer_last=True, prefer the last match (common for class-end insertions) + 2. Use indentation levels to distinguish class vs method braces + 3. Consider context to avoid matches inside strings/comments + + Args: + pattern: Regex pattern to search for + text: Text to search in + flags: Regex flags + prefer_last: If True, prefer the last match over the first + + Returns: + Match object of the best match, or None if no match found + """ + + # Find all matches + matches = list(re.finditer(pattern, text, flags)) + if not matches: + return None + + # If only one match, return it + if len(matches) == 1: + return matches[0] + + # For patterns that look like they're trying to match closing braces at end of lines + is_closing_brace_pattern = '}' in pattern and ( + '$' in pattern or pattern.endswith(r'\s*')) + + if is_closing_brace_pattern and prefer_last: + # Use heuristics to find the best closing brace match + return _find_best_closing_brace_match(matches, text) + + # Default behavior: use last match if prefer_last, otherwise first match + return matches[-1] if prefer_last else matches[0] + + +def _find_best_closing_brace_match(matches, text: str): + """ + Find the best closing brace match using C# structure heuristics. + + Enhanced heuristics for scope-aware matching: + 1. Prefer matches with lower indentation (likely class-level) + 2. Prefer matches closer to end of file + 3. Avoid matches that seem to be inside method bodies + 4. For #endregion patterns, ensure class-level context + 5. Validate insertion point is at appropriate scope + + Args: + matches: List of regex match objects + text: The full text being searched + + Returns: + The best match object + """ + if not matches: + return None + + scored_matches = [] + lines = text.splitlines() + + for match in matches: + score = 0 + start_pos = match.start() + + # Find which line this match is on + lines_before = text[:start_pos].count('\n') + line_num = lines_before + + if line_num < len(lines): + line_content = lines[line_num] + + # Calculate indentation level (lower is better for class braces) + indentation = len(line_content) - len(line_content.lstrip()) + + # Prefer lower indentation (class braces are typically less indented than method braces) + # Max 20 points for indentation=0 + score += max(0, 20 - indentation) + + # Prefer matches closer to end of file (class closing braces are typically at the end) + distance_from_end = len(lines) - line_num + # More points for being closer to end + score += max(0, 10 - distance_from_end) + + # Look at surrounding context to avoid method braces + context_start = max(0, line_num - 3) + context_end = min(len(lines), line_num + 2) + context_lines = lines[context_start:context_end] + + # Penalize if this looks like it's inside a method (has method-like patterns above) + for context_line in context_lines: + if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): + score -= 5 # Penalty for being near method signatures + + # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) + if indentation <= 4 and distance_from_end <= 3: + score += 15 # Bonus for likely class-ending brace + + scored_matches.append((score, match)) + + # Return the match with the highest score + scored_matches.sort(key=lambda x: x[0], reverse=True) + best_match = scored_matches[0][1] + + return best_match + + +def _infer_class_name(script_name: str) -> str: + # Default to script name as class name (common Unity pattern) + return (script_name or "").strip() + + +def _extract_code_after(keyword: str, request: str) -> str: + # Deprecated with NL removal; retained as no-op for compatibility + idx = request.lower().find(keyword) + if idx >= 0: + return request[idx + len(keyword):].strip() + return "" +# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services + + +def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: + """Best-effort normalization of script "name" and "path". + + Accepts any of: + - name = "SmartReach", path = "Assets/Scripts/Interaction" + - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" + - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" + - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) + - name or path using uri prefixes: unity://path/..., file://... + - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" + + Returns (name_without_extension, directory_path_under_Assets). + """ + n = (name or "").strip() + p = (path or "").strip() + + def strip_prefix(s: str) -> str: + if s.startswith("unity://path/"): + return s[len("unity://path/"):] + if s.startswith("file://"): + return s[len("file://"):] + return s + + def collapse_duplicate_tail(s: str) -> str: + # Collapse trailing "/X.cs/X.cs" to "/X.cs" + parts = s.split("/") + if len(parts) >= 2 and parts[-1] == parts[-2]: + parts = parts[:-1] + return "/".join(parts) + + # Prefer a full path if provided in either field + candidate = "" + for v in (n, p): + v2 = strip_prefix(v) + if v2.endswith(".cs") or v2.startswith("Assets/"): + candidate = v2 + break + + if candidate: + candidate = collapse_duplicate_tail(candidate) + # If a directory was passed in path and file in name, join them + if not candidate.endswith(".cs") and n.endswith(".cs"): + v2 = strip_prefix(n) + candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) + if candidate.endswith(".cs"): + parts = candidate.split("/") + file_name = parts[-1] + dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" + base = file_name[:- + 3] if file_name.lower().endswith(".cs") else file_name + return base, dir_path + + # Fall back: remove extension from name if present and return given path + base_name = n[:-3] if n.lower().endswith(".cs") else n + return base_name, (p or "Assets") + + +def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: + if not isinstance(resp, dict): + return resp + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", edits) + if routing: + data["routing"] = routing + return resp + + +def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, + normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": False, + "code": code, "message": message} + data: dict[str, Any] = {} + if expected: + data["expected"] = expected + if rewrite: + data["rewrite_suggestion"] = rewrite + if normalized is not None: + data["normalizedEdits"] = normalized + if routing: + data["routing"] = routing + if extra: + data.update(extra) + if data: + payload["data"] = data + return payload + +# Natural-language parsing removed; clients should send structured edits. + + +@mcp_for_unity_tool(name="script_apply_edits", description=( + """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. + Best practices: + - Prefer anchor_* ops for pattern-based insert/replace near stable markers + - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) + - Avoid whole-file regex deletes; validators will guard unbalanced braces + - For tail insertions, prefer anchor/regex_replace on final brace (class closing) + - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits + Canonical fields (use these exact keys): + - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace + - className: string (defaults to 'name' if omitted on method/class ops) + - methodName: string (required for replace_method, delete_method) + - replacement: string (required for replace_method, insert_method) + - position: start | end | after | before (insert_method only) + - afterMethodName / beforeMethodName: string (required when position='after'/'before') + - anchor: regex string (for anchor_* ops) + - text: string (for anchor_insert/anchor_replace) + Examples: + 1) Replace a method: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "replace_method", + "className": "SmartReach", + "methodName": "HasTarget", + "replacement": "public bool HasTarget(){ return currentTarget!=null; }" + } + ], + "options": {"validate": "standard", "refresh": "immediate"} + } + "2) Insert a method after another: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "insert_method", + "className": "SmartReach", + "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", + "position": "after", + "afterMethodName": "GetCurrentTarget" + } + ], + } + ]""" +)) +def script_apply_edits( + ctx: Context, + name: Annotated[str, "Name of the script to edit"], + path: Annotated[str, "Path to the script to edit under Assets/ directory"], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + options: Annotated[dict[str, Any], + "Options for the script edit"] | None = None, + script_type: Annotated[str, + "Type of the script to edit"] = "MonoBehaviour", + namespace: Annotated[str, + "Namespace of the script to edit"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing script_apply_edits: {name}") + # Normalize locator first so downstream calls target the correct script file. + name, path = _normalize_script_locator(name, path) + # Normalize unsupported or aliased ops to known structured/text paths + + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: + # Unwrap single-key wrappers like {"replace_method": {...}} + for wrapper_key in ( + "replace_method", "insert_method", "delete_method", + "replace_class", "delete_class", + "anchor_insert", "anchor_replace", "anchor_delete", + ): + if wrapper_key in edit and isinstance(edit[wrapper_key], dict): + inner = dict(edit[wrapper_key]) + inner["op"] = wrapper_key + edit = inner + break + + e = dict(edit) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + if op: + e["op"] = op + + # Common field aliases + if "class_name" in e and "className" not in e: + e["className"] = e.pop("class_name") + if "class" in e and "className" not in e: + e["className"] = e.pop("class") + if "method_name" in e and "methodName" not in e: + e["methodName"] = e.pop("method_name") + # Some clients use a generic 'target' for method name + if "target" in e and "methodName" not in e: + e["methodName"] = e.pop("target") + if "method" in e and "methodName" not in e: + e["methodName"] = e.pop("method") + if "new_content" in e and "replacement" not in e: + e["replacement"] = e.pop("new_content") + if "newMethod" in e and "replacement" not in e: + e["replacement"] = e.pop("newMethod") + if "new_method" in e and "replacement" not in e: + e["replacement"] = e.pop("new_method") + if "content" in e and "replacement" not in e: + e["replacement"] = e.pop("content") + if "after" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after") + if "after_method" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after_method") + if "before" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before") + if "before_method" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before_method") + # anchor_method → before/after based on position (default after) + if "anchor_method" in e: + anchor = e.pop("anchor_method") + pos = (e.get("position") or "after").strip().lower() + if pos == "before" and "beforeMethodName" not in e: + e["beforeMethodName"] = anchor + elif "afterMethodName" not in e: + e["afterMethodName"] = anchor + if "anchorText" in e and "anchor" not in e: + e["anchor"] = e.pop("anchorText") + if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): + e["anchor"] = e.pop("pattern") + if "newText" in e and "text" not in e: + e["text"] = e.pop("newText") + + # CI compatibility (T‑A/T‑E): + # Accept method-anchored anchor_insert and upgrade to insert_method + # Example incoming shape: + # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} + if ( + e.get("op") == "anchor_insert" + and not e.get("anchor") + and (e.get("afterMethodName") or e.get("beforeMethodName")) + ): + e["op"] = "insert_method" + if "replacement" not in e: + e["replacement"] = e.get("text", "") + + # LSP-like range edit -> replace_range + if "range" in e and isinstance(e["range"], dict): + rng = e.pop("range") + start = rng.get("start", {}) + end = rng.get("end", {}) + # Convert 0-based to 1-based line/col + e["op"] = "replace_range" + e["startLine"] = int(start.get("line", 0)) + 1 + e["startCol"] = int(start.get("character", 0)) + 1 + e["endLine"] = int(end.get("line", 0)) + 1 + e["endCol"] = int(end.get("character", 0)) + 1 + if "newText" in edit and "text" not in e: + e["text"] = edit.get("newText", "") + return e + + normalized_edits: list[dict[str, Any]] = [] + for raw in edits or []: + e = _unwrap_and_alias(raw) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + + # Default className to script name if missing on structured method/class ops + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): + e["className"] = name + + # Map common aliases for text ops + if op in ("text_replace",): + e["op"] = "replace_range" + normalized_edits.append(e) + continue + if op in ("regex_delete",): + e["op"] = "regex_replace" + e.setdefault("text", "") + normalized_edits.append(e) + continue + if op == "regex_replace" and ("replacement" not in e): + if "text" in e: + e["replacement"] = e.get("text", "") + elif "insert" in e or "content" in e: + e["replacement"] = e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): + e["op"] = "anchor_delete" + normalized_edits.append(e) + continue + normalized_edits.append(e) + + edits = normalized_edits + normalized_for_echo = edits + + # Validate required fields and produce machine-parsable hints + def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: + return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) + + for e in edits or []: + op = e.get("op", "") + if op == "replace_method": + if not e.get("methodName"): + return error_with_hint( + "replace_method requires 'methodName'.", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].methodName": "HasTarget"} + ) + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "replace_method requires 'replacement' (inline or base64).", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].replacement": "public bool X(){ return true; }"} + ) + elif op == "insert_method": + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "insert_method requires a non-empty 'replacement'.", + {"op": "insert_method", "required": ["className", "replacement"], "position": { + "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} + ) + pos = (e.get("position") or "").lower() + if pos == "after" and not e.get("afterMethodName"): + return error_with_hint( + "insert_method with position='after' requires 'afterMethodName'.", + {"op": "insert_method", "position": { + "after_requires": "afterMethodName"}}, + {"edits[0].afterMethodName": "GetCurrentTarget"} + ) + if pos == "before" and not e.get("beforeMethodName"): + return error_with_hint( + "insert_method with position='before' requires 'beforeMethodName'.", + {"op": "insert_method", "position": { + "before_requires": "beforeMethodName"}}, + {"edits[0].beforeMethodName": "GetCurrentTarget"} + ) + elif op == "delete_method": + if not e.get("methodName"): + return error_with_hint( + "delete_method requires 'methodName'.", + {"op": "delete_method", "required": [ + "className", "methodName"]}, + {"edits[0].methodName": "PrintSeries"} + ) + elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): + if not e.get("anchor"): + return error_with_hint( + f"{op} requires 'anchor' (regex).", + {"op": op, "required": ["anchor"]}, + {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} + ) + if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): + return error_with_hint( + f"{op} requires 'text'.", + {"op": op, "required": ["anchor", "text"]}, + {"edits[0].text": "/* comment */\n"} + ) + + # Decide routing: structured vs text vs mixed + STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} + TEXT = {"prepend", "append", "replace_range", "regex_replace"} + ops_set = {(e.get("op") or "").lower() for e in edits or []} + all_struct = ops_set.issubset(STRUCT) + all_text = ops_set.issubset(TEXT) + mixed = not (all_struct or all_text) + + # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. + if all_struct: + opts2 = dict(options or {}) + # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused + opts2.setdefault("refresh", "immediate") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": opts2, + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") + + # 1) read from Unity + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data") or read_resp.get( + "result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # Optional preview/dry-run: apply locally and return diff without writing + preview = bool((options or {}).get("preview")) + + # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured + if mixed: + text_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in STRUCT] + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + for e in text_edits: + opx = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get( + "content") or e.get("replacement") or "" + if opx == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + try: + # Use improved anchor matching logic + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") + idx = m.start() if position == "before" else m.end() + # Normalize insertion to avoid jammed methods + text_field_norm = text_field + if not text_field_norm.startswith("\n"): + text_field_norm = "\n" + text_field_norm + if not text_field_norm.endswith("\n"): + text_field_norm = text_field_norm + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + # do not mutate base_text when building atomic spans + elif opx == "replace_range": + if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + elif opx == "regex_replace": + pattern = e.get("pattern") or "" + try: + regex_obj = re.compile(pattern, re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0)) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") + m = regex_obj.search(base_text) + if not m: + continue + # Expand $1, $2... in replacement using this match + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl = _expand_dollars(text_field) + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + # do not mutate base_text when building atomic spans + elif opx in ("prepend", "append"): + if opx == "prepend": + sl, sc = 1, 1 + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + # prepend can be applied atomically without local mutation + else: + # Insert at true EOF position (handles both \n and \r\n correctly) + eof_idx = len(base_text) + sl, sc = line_col_from_index(eof_idx) + new_text = ("\n" if not base_text.endswith( + "\n") else "") + text_field + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + # do not mutate base_text when building atomic spans + else: + return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + if at_edits: + params_text: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} + } + resp_text = send_command_with_retry( + "manage_script", params_text) + if not (isinstance(resp_text, dict) and resp_text.get("success")): + return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + # Optional sentinel reload removed (deprecated) + except Exception as e: + return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") + + if struct_edits: + opts2 = dict(options or {}) + # Prefer debounced background refresh unless explicitly overridden + opts2.setdefault("refresh", "debounced") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": struct_edits, + "options": opts2 + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") + + return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") + + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition + # so header guards and validation run on the C# side. + # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). + text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( + "mode") or "").strip().lower() for e in (edits or [])} + structured_kinds = {"replace_class", "delete_class", + "replace_method", "delete_method", "insert_method", "anchor_insert"} + if not text_ops.issubset(structured_kinds): + # Convert to apply_text_edits payload + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + # 1-based line/col against base buffer + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + import re as _re + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + # aliasing for text field + text_field = e.get("text") or e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + # Use improved anchor matching logic with helpful errors, honoring ignore_case + try: + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") + idx = m.start() if position == "before" else m.end() + # Normalize insertion newlines + if text_field and not text_field.startswith("\n"): + text_field = "\n" + text_field + if text_field and not text_field.endswith("\n"): + text_field = text_field + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": sl, + "endCol": sc, + "newText": text_field or "" + }) + # Do not mutate base buffer when building an atomic batch + elif op == "replace_range": + # Directly forward if already in line/col form + if "startLine" in e: + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + # If only indices provided, skip (we don't support index-based here) + return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") + elif op == "regex_replace": + pattern = e.get("pattern") or "" + repl = text_field + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + # Early compile for clearer error messages + try: + regex_obj = re.compile(pattern, flags) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") + # Use smart anchor matching for consistent behavior with anchor_insert + m = _find_best_anchor_match( + pattern, base_text, flags, prefer_last=True) + if not m: + continue + # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl_expanded = _expand_dollars(repl) + # Let C# side handle validation using Unity's built-in compiler services + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": el, + "endCol": ec, + "newText": repl_expanded + }) + # Do not mutate base buffer when building an atomic batch + else: + return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") + + if not at_edits: + return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + params: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": { + "refresh": (options or {}).get("refresh", "debounced"), + "validate": (options or {}).get("validate", "standard"), + "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) + } + } + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + resp if isinstance(resp, dict) else { + "success": False, "message": str(resp)}, + normalized_for_echo, + routing="text" + ) + except Exception as e: + return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") + + # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. + # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. + if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): + try: + preview_text = _apply_edits_locally(contents, edits) + import difflib + diff = list(difflib.unified_diff(contents.splitlines( + ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + if len(diff) > 800: + diff = diff[:800] + ["... (diff truncated) ..."] + if preview: + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") + except Exception as e: + return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") + # 2) apply edits locally (only if not text-ops) + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + # Short-circuit no-op edits to avoid false "applied" reports downstream + if new_contents == contents: + return _with_norm({ + "success": True, + "message": "No-op: contents unchanged", + "data": {"no_op": True, "evidence": {"reason": "identical_content"}} + }, normalized_for_echo, routing="text") + + if preview: + # Produce a compact unified diff limited to small context + import difflib + a = contents.splitlines() + b = new_contents.splitlines() + diff = list(difflib.unified_diff( + a, b, fromfile="before", tofile="after", n=3)) + # Limit diff size to keep responses small + if len(diff) > 2000: + diff = diff[:2000] + ["... (diff truncated) ..."] + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + + # 3) update to Unity + # Default refresh/validate for natural usage on text path as well + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "debounced") + + # Compute the SHA of the current file contents for the precondition + old_lines = contents.splitlines(keepends=True) + end_line = len(old_lines) + 1 # 1-based exclusive end + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + + # Apply a whole-file text edit rather than the deprecated 'update' action + params = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": [ + { + "startLine": 1, + "startCol": 1, + "endLine": end_line, + "endCol": 1, + "newText": new_contents, + } + ], + "precondition_sha256": sha, + "options": options or {"validate": "standard", "refresh": "debounced"}, + } + + write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict) and write_resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, + normalized_for_echo, + routing="text", + ) diff --git a/MCPForUnity/UnityMcpServer~/src/unity_connection.py b/MCPForUnity/UnityMcpServer~/src/unity_connection.py new file mode 100644 index 00000000..f688da34 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/unity_connection.py @@ -0,0 +1,452 @@ +from config import config +import contextlib +from dataclasses import dataclass +import errno +import json +import logging +from pathlib import Path +from port_discovery import PortDiscovery +import random +import socket +import struct +import threading +import time +from typing import Any, Dict + + +# Configure logging using settings from config +logging.basicConfig( + level=getattr(logging, config.log_level), + format=config.log_format +) +logger = logging.getLogger("mcp-for-unity-server") + +# Module-level lock to guard global connection initialization +_connection_lock = threading.Lock() + +# Maximum allowed framed payload size (64 MiB) +FRAMED_MAX = 64 * 1024 * 1024 + + +@dataclass +class UnityConnection: + """Manages the socket connection to the Unity Editor.""" + host: str = config.unity_host + port: int = None # Will be set dynamically + sock: socket.socket = None # Socket for Unity communication + use_framing: bool = False # Negotiated per-connection + + def __post_init__(self): + """Set port from discovery if not explicitly provided""" + if self.port is None: + self.port = PortDiscovery.discover_unity_port() + self._io_lock = threading.Lock() + self._conn_lock = threading.Lock() + + def connect(self) -> bool: + """Establish a connection to the Unity Editor.""" + if self.sock: + return True + with self._conn_lock: + if self.sock: + return True + try: + # Bounded connect to avoid indefinite blocking + connect_timeout = float( + getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) + self.sock = socket.create_connection( + (self.host, self.port), connect_timeout) + # Disable Nagle's algorithm to reduce small RPC latency + with contextlib.suppress(Exception): + self.sock.setsockopt( + socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + logger.debug(f"Connected to Unity at {self.host}:{self.port}") + + # Strict handshake: require FRAMING=1 + try: + require_framing = getattr(config, "require_framing", True) + timeout = float(getattr(config, "handshake_timeout", 1.0)) + self.sock.settimeout(timeout) + buf = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and len(buf) < 512: + try: + chunk = self.sock.recv(256) + if not chunk: + break + buf.extend(chunk) + if b"\n" in buf: + break + except socket.timeout: + break + text = bytes(buf).decode('ascii', errors='ignore').strip() + + if 'FRAMING=1' in text: + self.use_framing = True + logger.debug( + 'Unity MCP handshake received: FRAMING=1 (strict)') + else: + if require_framing: + # Best-effort plain-text advisory for legacy peers + with contextlib.suppress(Exception): + self.sock.sendall( + b'Unity MCP requires FRAMING=1\n') + raise ConnectionError( + f'Unity MCP requires FRAMING=1, got: {text!r}') + else: + self.use_framing = False + logger.warning( + 'Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') + finally: + self.sock.settimeout(config.connection_timeout) + return True + except Exception as e: + logger.error(f"Failed to connect to Unity: {str(e)}") + try: + if self.sock: + self.sock.close() + except Exception: + pass + self.sock = None + return False + + def disconnect(self): + """Close the connection to the Unity Editor.""" + if self.sock: + try: + self.sock.close() + except Exception as e: + logger.error(f"Error disconnecting from Unity: {str(e)}") + finally: + self.sock = None + + def _read_exact(self, sock: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise ConnectionError( + "Connection closed before reading expected bytes") + data.extend(chunk) + return bytes(data) + + def receive_full_response(self, sock, buffer_size=config.buffer_size) -> bytes: + """Receive a complete response from Unity, handling chunked data.""" + if self.use_framing: + try: + # Consume heartbeats, but do not hang indefinitely if only zero-length frames arrive + heartbeat_count = 0 + deadline = time.monotonic() + getattr(config, 'framed_receive_timeout', 2.0) + while True: + header = self._read_exact(sock, 8) + payload_len = struct.unpack('>Q', header)[0] + if payload_len == 0: + # Heartbeat/no-op frame: consume and continue waiting for a data frame + logger.debug("Received heartbeat frame (length=0)") + heartbeat_count += 1 + if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: + # Treat as empty successful response to match C# server behavior + logger.debug( + "Heartbeat threshold reached; returning empty response") + return b"" + continue + if payload_len > FRAMED_MAX: + raise ValueError( + f"Invalid framed length: {payload_len}") + payload = self._read_exact(sock, payload_len) + logger.debug( + f"Received framed response ({len(payload)} bytes)") + return payload + except socket.timeout as e: + logger.warning("Socket timeout during framed receive") + raise TimeoutError("Timeout receiving Unity response") from e + except Exception as e: + logger.error(f"Error during framed receive: {str(e)}") + raise + + chunks = [] + # Respect the socket's currently configured timeout + try: + while True: + chunk = sock.recv(buffer_size) + if not chunk: + if not chunks: + raise Exception( + "Connection closed before receiving data") + break + chunks.append(chunk) + + # Process the data received so far + data = b''.join(chunks) + decoded_data = data.decode('utf-8') + + # Check if we've received a complete response + try: + # Special case for ping-pong + if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): + logger.debug("Received ping response") + return data + + # Handle escaped quotes in the content + if '"content":' in decoded_data: + # Find the content field and its value + content_start = decoded_data.find('"content":') + 9 + content_end = decoded_data.rfind('"', content_start) + if content_end > content_start: + # Replace escaped quotes in content with regular quotes + content = decoded_data[content_start:content_end] + content = content.replace('\\"', '"') + decoded_data = decoded_data[:content_start] + \ + content + decoded_data[content_end:] + + # Validate JSON format + json.loads(decoded_data) + + # If we get here, we have valid JSON + logger.info( + f"Received complete response ({len(data)} bytes)") + return data + except json.JSONDecodeError: + # We haven't received a complete valid JSON response yet + continue + except Exception as e: + logger.warning( + f"Error processing response chunk: {str(e)}") + # Continue reading more chunks as this might not be the complete response + continue + except socket.timeout: + logger.warning("Socket timeout during receive") + raise Exception("Timeout receiving Unity response") + except Exception as e: + logger.error(f"Error during receive: {str(e)}") + raise + + def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Send a command with retry/backoff and port rediscovery. Pings only when requested.""" + # Defensive guard: catch empty/placeholder invocations early + if not command_type: + raise ValueError("MCP call missing command_type") + if params is None: + # Return a fast, structured error that clients can display without hanging + return {"success": False, "error": "MCP call received with no parameters (client placeholder?)"} + attempts = max(config.max_retries, 5) + base_backoff = max(0.5, config.retry_delay) + + def read_status_file() -> dict | None: + try: + status_files = sorted(Path.home().joinpath( + '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + if not status_files: + return None + latest = status_files[0] + with latest.open('r') as f: + return json.load(f) + except Exception: + return None + + last_short_timeout = None + + # Preflight: if Unity reports reloading, return a structured hint so clients can retry politely + try: + status = read_status_file() + if status and (status.get('reloading') or status.get('reason') == 'reloading'): + return { + "success": False, + "state": "reloading", + "retry_after_ms": int(config.reload_retry_ms), + "error": "Unity domain reload in progress", + "message": "Unity is reloading scripts; please retry shortly" + } + except Exception: + pass + + for attempt in range(attempts + 1): + try: + # Ensure connected (handshake occurs within connect()) + if not self.sock and not self.connect(): + raise Exception("Could not connect to Unity") + + # Build payload + if command_type == 'ping': + payload = b'ping' + else: + command = {"type": command_type, "params": params or {}} + payload = json.dumps( + command, ensure_ascii=False).encode('utf-8') + + # Send/receive are serialized to protect the shared socket + with self._io_lock: + mode = 'framed' if self.use_framing else 'legacy' + with contextlib.suppress(Exception): + logger.debug( + "send %d bytes; mode=%s; head=%s", + len(payload), + mode, + (payload[:32]).decode('utf-8', 'ignore'), + ) + if self.use_framing: + header = struct.pack('>Q', len(payload)) + self.sock.sendall(header) + self.sock.sendall(payload) + else: + self.sock.sendall(payload) + + # During retry bursts use a short receive timeout and ensure restoration + restore_timeout = None + if attempt > 0 and last_short_timeout is None: + restore_timeout = self.sock.gettimeout() + self.sock.settimeout(1.0) + try: + response_data = self.receive_full_response(self.sock) + with contextlib.suppress(Exception): + logger.debug("recv %d bytes; mode=%s", + len(response_data), mode) + finally: + if restore_timeout is not None: + self.sock.settimeout(restore_timeout) + last_short_timeout = None + + # Parse + if command_type == 'ping': + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'success' and resp.get('result', {}).get('message') == 'pong': + return {"message": "pong"} + raise Exception("Ping unsuccessful") + + resp = json.loads(response_data.decode('utf-8')) + if resp.get('status') == 'error': + err = resp.get('error') or resp.get( + 'message', 'Unknown Unity error') + raise Exception(err) + return resp.get('result', {}) + except Exception as e: + logger.warning( + f"Unity communication attempt {attempt+1} failed: {e}") + try: + if self.sock: + self.sock.close() + finally: + self.sock = None + + # Re-discover port each time + try: + new_port = PortDiscovery.discover_unity_port() + if new_port != self.port: + logger.info( + f"Unity port changed {self.port} -> {new_port}") + self.port = new_port + except Exception as de: + logger.debug(f"Port discovery failed: {de}") + + if attempt < attempts: + # Heartbeat-aware, jittered backoff + status = read_status_file() + # Base exponential backoff + backoff = base_backoff * (2 ** attempt) + # Decorrelated jitter multiplier + jitter = random.uniform(0.1, 0.3) + + # Fast‑retry for transient socket failures + fast_error = isinstance( + e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + if not fast_error: + try: + err_no = getattr(e, 'errno', None) + fast_error = err_no in ( + errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + except Exception: + pass + + # Cap backoff depending on state + if status and status.get('reloading'): + cap = 0.8 + elif fast_error: + cap = 0.25 + else: + cap = 3.0 + + sleep_s = min(cap, jitter * (2 ** attempt)) + time.sleep(sleep_s) + continue + raise + + +# Global Unity connection +_unity_connection = None + + +def get_unity_connection() -> UnityConnection: + """Retrieve or establish a persistent Unity connection. + + Note: Do NOT ping on every retrieval to avoid connection storms. Rely on + send_command() exceptions to detect broken sockets and reconnect there. + """ + global _unity_connection + if _unity_connection is not None: + return _unity_connection + + # Double-checked locking to avoid concurrent socket creation + with _connection_lock: + if _unity_connection is not None: + return _unity_connection + logger.info("Creating new Unity connection") + _unity_connection = UnityConnection() + if not _unity_connection.connect(): + _unity_connection = None + raise ConnectionError( + "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + logger.info("Connected to Unity on startup") + return _unity_connection + + +# ----------------------------- +# Centralized retry helpers +# ----------------------------- + +def _is_reloading_response(resp: dict) -> bool: + """Return True if the Unity response indicates the editor is reloading.""" + if not isinstance(resp, dict): + return False + if resp.get("state") == "reloading": + return True + message_text = (resp.get("message") or resp.get("error") or "").lower() + return "reload" in message_text + + +def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Send a command via the shared connection, waiting politely through Unity reloads. + + Uses config.reload_retry_ms and config.reload_max_retries by default. Preserves the + structured failure if retries are exhausted. + """ + conn = get_unity_connection() + if max_retries is None: + max_retries = getattr(config, "reload_max_retries", 40) + if retry_ms is None: + retry_ms = getattr(config, "reload_retry_ms", 250) + + response = conn.send_command(command_type, params) + retries = 0 + while _is_reloading_response(response) and retries < max_retries: + delay_ms = int(response.get("retry_after_ms", retry_ms) + ) if isinstance(response, dict) else retry_ms + time.sleep(max(0.0, delay_ms / 1000.0)) + retries += 1 + response = conn.send_command(command_type, params) + return response + + +async def async_send_command_with_retry(command_type: str, params: Dict[str, Any], *, loop=None, max_retries: int | None = None, retry_ms: int | None = None) -> Dict[str, Any]: + """Async wrapper that runs the blocking retry helper in a thread pool.""" + try: + import asyncio # local import to avoid mandatory asyncio dependency for sync callers + if loop is None: + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + lambda: send_command_with_retry( + command_type, params, max_retries=max_retries, retry_ms=retry_ms), + ) + except Exception as e: + # Return a structured error dict for consistency with other responses + return {"success": False, "error": f"Python async retry helper failed: {str(e)}"} diff --git a/MCPForUnity/UnityMcpServer~/src/uv.lock b/MCPForUnity/UnityMcpServer~/src/uv.lock new file mode 100644 index 00000000..f5cac0f5 --- /dev/null +++ b/MCPForUnity/UnityMcpServer~/src/uv.lock @@ -0,0 +1,400 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mcp" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/cc/5c5bb19f1a0f8f89a95e25cb608b0b07009e81fd4b031e519335404e1422/mcp-1.4.1.tar.gz", hash = "sha256:b9655d2de6313f9d55a7d1df62b3c3fe27a530100cc85bf23729145b0dba4c7a", size = 154942 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/0e/885f156ade60108e67bf044fada5269da68e29d758a10b0c513f4d85dd76/mcp-1.4.1-py3-none-any.whl", hash = "sha256:a7716b1ec1c054e76f49806f7d96113b99fc1166fc9244c2c6f19867cb75b593", size = 72448 }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mcpforunityserver" +version = "3.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.4.1" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + +[[package]] +name = "typer" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json new file mode 100644 index 00000000..20d439e9 --- /dev/null +++ b/MCPForUnity/package.json @@ -0,0 +1,26 @@ +{ + "name": "com.coplaydev.unity-mcp", + "version": "5.0.0", + "displayName": "MCP for Unity", + "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", + "unity": "2021.3", + "documentationUrl": "https://github.com/CoplayDev/unity-mcp", + "licensesUrl": "https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.0.2" + }, + "keywords": [ + "unity", + "ai", + "llm", + "mcp", + "model-context-protocol", + "mcp-server", + "mcp-client" + ], + "author": { + "name": "Coplay", + "email": "support@coplay.dev", + "url": "https://coplay.dev" + } +} diff --git a/MCPForUnity/package.json.meta b/MCPForUnity/package.json.meta new file mode 100644 index 00000000..5a96937c --- /dev/null +++ b/MCPForUnity/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a2f7ae0675bf4fb478a0a1df7a3f6c64 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README-zh.md b/README-zh.md index c5f0860b..f047bef2 100644 --- a/README-zh.md +++ b/README-zh.md @@ -114,7 +114,7 @@ MCP for Unity 使用两个组件连接您的工具: 3. 点击 `+` -> `Add package from git URL...`。 4. 输入: ``` - https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge + https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity ``` 5. 点击 `Add`。 6. MCP 服务器在首次运行时或通过自动设置由包自动安装。如果失败,请使用手动配置(如下)。 @@ -270,7 +270,11 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microsoft/WinGet/Lin ## 开发和贡献 🛠️ -### 开发者 +### 添加自定义工具 + +MCP for Unity 使用与 Unity 的 C# 脚本绑定的 Python MCP 服务器来实现工具功能。如果您想使用自己的工具扩展功能,请参阅 **[CUSTOM_TOOLS.md](docs/CUSTOM_TOOLS.md)** 了解如何操作。 + +### 贡献项目 如果您正在为 MCP for Unity 做贡献或想要测试核心更改,我们有开发工具来简化您的工作流程: @@ -278,7 +282,7 @@ claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microsoft/WinGet/Lin - **自动备份系统**:具有简单回滚功能的安全测试 - **热重载工作流程**:核心开发的快速迭代周期 -📖 **查看 [README-DEV.md](README-DEV.md)** 获取完整的开发设置和工作流程文档。 +📖 **查看 [README-DEV.md](docs/README-DEV.md)** 获取完整的开发设置和工作流程文档。 ### 贡献 🤝 @@ -299,7 +303,7 @@ Unity MCP 包含**注重隐私的匿名遥测**来帮助我们改进产品。我 - **🔒 匿名**:仅随机 UUID,无个人数据 - **🚫 轻松退出**:设置 `DISABLE_TELEMETRY=true` 环境变量 -- **📖 透明**:查看 [TELEMETRY.md](TELEMETRY.md) 获取完整详情 +- **📖 透明**:查看 [TELEMETRY.md](docs/TELEMETRY.md) 获取完整详情 您的隐私对我们很重要。所有遥测都是可选的,旨在尊重您的工作流程。 diff --git a/README.md b/README.md index f06217f4..2093c680 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ MCP for Unity connects your tools using two components: 3. Click `+` -> `Add package from git URL...`. 4. Enter: ``` - https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge + https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity ``` 5. Click `Add`. 6. The MCP server is installed automatically by the package on first run or via Auto-Setup. If that fails, use Manual Configuration (below). @@ -273,7 +273,11 @@ On Windows, set `command` to the absolute shim, e.g. `C:\\Users\\YOU\\AppData\\L ## Development & Contributing 🛠️ -### For Developers +### Adding Custom Tools + +MCP for Unity uses a Python MCP Server tied with Unity's C# scripts for tools. If you'd like to extend the functionality with your own tools, learn how to do so in **[CUSTOM_TOOLS.md](docs/CUSTOM_TOOLS.md)**. + +### Contributing to the Project If you're contributing to MCP for Unity or want to test core changes, we have development tools to streamline your workflow: @@ -281,7 +285,7 @@ If you're contributing to MCP for Unity or want to test core changes, we have de - **Automatic Backup System**: Safe testing with easy rollback capabilities - **Hot Reload Workflow**: Fast iteration cycle for core development -📖 **See [README-DEV.md](README-DEV.md)** for complete development setup and workflow documentation. +📖 **See [README-DEV.md](docs/README-DEV.md)** for complete development setup and workflow documentation. ### Contributing 🤝 @@ -302,7 +306,7 @@ Unity MCP includes **privacy-focused, anonymous telemetry** to help us improve t - **🔒 Anonymous**: Random UUIDs only, no personal data - **🚫 Easy opt-out**: Set `DISABLE_TELEMETRY=true` environment variable -- **📖 Transparent**: See [TELEMETRY.md](TELEMETRY.md) for full details +- **📖 Transparent**: See [TELEMETRY.md](docs/TELEMETRY.md) for full details Your privacy matters to us. All telemetry is optional and designed to respect your workflow. 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/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs similarity index 87% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs index 3fd77088..88f4118d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs @@ -1,17 +1,14 @@ using System; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Runtime.InteropServices; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEditor; -using UnityEngine; -using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; -using MCPForUnity.Editor.Windows; -namespace MCPForUnityTests.Editor.Windows +namespace MCPForUnityTests.Editor.Helpers { public class WriteToConfigTests { @@ -68,7 +65,7 @@ public void TearDown() public void AddsEnvAndDisabledFalse_ForWindsurf() { var configPath = Path.Combine(_tempRoot, "windsurf.json"); - WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); var client = new McpClient { name = "Windsurf", mcpType = McpTypes.Windsurf }; InvokeWriteToConfig(configPath, client); @@ -85,7 +82,7 @@ public void AddsEnvAndDisabledFalse_ForWindsurf() public void AddsEnvAndDisabledFalse_ForKiro() { var configPath = Path.Combine(_tempRoot, "kiro.json"); - WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); var client = new McpClient { name = "Kiro", mcpType = McpTypes.Kiro }; InvokeWriteToConfig(configPath, client); @@ -102,7 +99,7 @@ public void AddsEnvAndDisabledFalse_ForKiro() public void DoesNotAddEnvOrDisabled_ForCursor() { var configPath = Path.Combine(_tempRoot, "cursor.json"); - WriteInitialConfig(configPath, isVSCode:false, command:_fakeUvPath, directory:"/old/path"); + WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); var client = new McpClient { name = "Cursor", mcpType = McpTypes.Cursor }; InvokeWriteToConfig(configPath, client); @@ -118,7 +115,7 @@ public void DoesNotAddEnvOrDisabled_ForCursor() public void DoesNotAddEnvOrDisabled_ForVSCode() { var configPath = Path.Combine(_tempRoot, "vscode.json"); - WriteInitialConfig(configPath, isVSCode:true, command:_fakeUvPath, directory:"/old/path"); + WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path"); var client = new McpClient { name = "VSCode", mcpType = McpTypes.VSCode }; InvokeWriteToConfig(configPath, client); @@ -219,25 +216,15 @@ private static void WriteInitialConfig(string configPath, bool isVSCode, string File.WriteAllText(configPath, root.ToString()); } - private static MCPForUnityEditorWindow CreateWindow() - { - return ScriptableObject.CreateInstance(); - } - private static void InvokeWriteToConfig(string configPath, McpClient client) { - var window = CreateWindow(); - var mi = typeof(MCPForUnityEditorWindow).GetMethod("WriteToConfig", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(mi, "Could not find WriteToConfig via reflection"); - - // pythonDir is unused by WriteToConfig, but pass server src to keep it consistent - var result = (string)mi!.Invoke(window, new object[] { - /* pythonDir */ string.Empty, - /* configPath */ configPath, - /* mcpClient */ client - }); - - Assert.AreEqual("Configured successfully", result, "WriteToConfig should return success"); + var result = McpConfigurationHelper.WriteMcpConfiguration( + pythonDir: string.Empty, + configPath: configPath, + mcpClient: client + ); + + Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success"); } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs.meta similarity index 100% rename from TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs.meta rename to TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs.meta 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/CommandRegistryTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs index 2bbe4616..ab204ef0 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NUnit.Framework; using MCPForUnity.Editor.Tools; @@ -7,35 +10,49 @@ namespace MCPForUnityTests.Editor.Tools { public class CommandRegistryTests { + [OneTimeSetUp] + public void OneTimeSetUp() + { + // Ensure CommandRegistry is initialized before tests run + CommandRegistry.Initialize(); + } + [Test] - public void GetHandler_ThrowException_ForUnknownCommand() + public void GetHandler_ThrowsException_ForUnknownCommand() { - var unknown = "HandleDoesNotExist"; - try - { - var handler = CommandRegistry.GetHandler(unknown); - Assert.Fail("Should throw InvalidOperation for unknown handler."); - } - catch (InvalidOperationException) - { + var unknown = "nonexistent_command_that_should_not_exist"; - } - catch + Assert.Throws(() => { - Assert.Fail("Should throw InvalidOperation for unknown handler."); - } + CommandRegistry.GetHandler(unknown); + }, "Should throw InvalidOperationException for unknown handler"); } [Test] - public void GetHandler_ReturnsManageGameObjectHandler() + public void AutoDiscovery_RegistersAllBuiltInTools() { - var handler = CommandRegistry.GetHandler("manage_gameobject"); - Assert.IsNotNull(handler, "Expected a handler for manage_gameobject."); + // Verify that all expected built-in tools are registered by trying to get their handlers + var expectedTools = new[] + { + "manage_asset", + "manage_editor", + "manage_gameobject", + "manage_scene", + "manage_script", + "manage_shader", + "read_console", + "manage_menu_item", + "manage_prefabs" + }; - var methodInfo = handler.Method; - Assert.AreEqual("HandleCommand", methodInfo.Name, "Handler method name should be HandleCommand."); - Assert.AreEqual(typeof(ManageGameObject), methodInfo.DeclaringType, "Handler should be declared on ManageGameObject."); - Assert.IsNull(handler.Target, "Handler should be a static method (no target instance)."); + foreach (var toolName in expectedTools) + { + Assert.DoesNotThrow(() => + { + var handler = CommandRegistry.GetHandler(toolName); + Assert.IsNotNull(handler, $"Handler for '{toolName}' should not be null"); + }, $"Expected tool '{toolName}' to be auto-registered"); + } } } } 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/TestProjects/UnityMCPTests/Packages/manifest.json b/TestProjects/UnityMCPTests/Packages/manifest.json index d378a0a8..b8bbe318 100644 --- a/TestProjects/UnityMCPTests/Packages/manifest.json +++ b/TestProjects/UnityMCPTests/Packages/manifest.json @@ -1,6 +1,6 @@ { "dependencies": { - "com.coplaydev.unity-mcp": "file:../../../UnityMcpBridge", + "com.coplaydev.unity-mcp": "file:../../../MCPForUnity", "com.unity.collab-proxy": "2.5.2", "com.unity.feature.development": "1.0.1", "com.unity.ide.rider": "3.0.31", 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/Dependencies.meta b/UnityMcpBridge/Editor/Dependencies.meta new file mode 100644 index 00000000..77685d17 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 221a4d6e595be6897a5b17b77aedd4d0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs b/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs new file mode 100644 index 00000000..ce6efef2 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Dependencies.PlatformDetectors; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Main orchestrator for dependency validation and management + /// + public static class DependencyManager + { + private static readonly List _detectors = new List + { + new WindowsPlatformDetector(), + new MacOSPlatformDetector(), + new LinuxPlatformDetector() + }; + + private static IPlatformDetector _currentDetector; + + /// + /// Get the platform detector for the current operating system + /// + public static IPlatformDetector GetCurrentPlatformDetector() + { + if (_currentDetector == null) + { + _currentDetector = _detectors.FirstOrDefault(d => d.CanDetect); + if (_currentDetector == null) + { + throw new PlatformNotSupportedException($"No detector available for current platform: {RuntimeInformation.OSDescription}"); + } + } + return _currentDetector; + } + + /// + /// Perform a comprehensive dependency check + /// + public static DependencyCheckResult CheckAllDependencies() + { + var result = new DependencyCheckResult(); + + try + { + var detector = GetCurrentPlatformDetector(); + McpLog.Info($"Checking dependencies on {detector.PlatformName}...", always: false); + + // Check Python + var pythonStatus = detector.DetectPython(); + result.Dependencies.Add(pythonStatus); + + // Check UV + var uvStatus = detector.DetectUV(); + result.Dependencies.Add(uvStatus); + + // Check MCP Server + var serverStatus = detector.DetectMCPServer(); + result.Dependencies.Add(serverStatus); + + // Generate summary and recommendations + result.GenerateSummary(); + GenerateRecommendations(result, detector); + + McpLog.Info($"Dependency check completed. System ready: {result.IsSystemReady}", always: false); + } + catch (Exception ex) + { + McpLog.Error($"Error during dependency check: {ex.Message}"); + result.Summary = $"Dependency check failed: {ex.Message}"; + result.IsSystemReady = false; + } + + return result; + } + + /// + /// Get installation recommendations for the current platform + /// + public static string GetInstallationRecommendations() + { + try + { + var detector = GetCurrentPlatformDetector(); + return detector.GetInstallationRecommendations(); + } + catch (Exception ex) + { + return $"Error getting installation recommendations: {ex.Message}"; + } + } + + /// + /// Get platform-specific installation URLs + /// + public static (string pythonUrl, string uvUrl) GetInstallationUrls() + { + try + { + var detector = GetCurrentPlatformDetector(); + return (detector.GetPythonInstallUrl(), detector.GetUVInstallUrl()); + } + catch + { + return ("https://python.org/downloads/", "https://docs.astral.sh/uv/getting-started/installation/"); + } + } + + private static void GenerateRecommendations(DependencyCheckResult result, IPlatformDetector detector) + { + var missing = result.GetMissingDependencies(); + + if (missing.Count == 0) + { + result.RecommendedActions.Add("All dependencies are available. You can start using MCP for Unity."); + return; + } + + foreach (var dep in missing) + { + if (dep.Name == "Python") + { + result.RecommendedActions.Add($"Install Python 3.10+ from: {detector.GetPythonInstallUrl()}"); + } + else if (dep.Name == "UV Package Manager") + { + result.RecommendedActions.Add($"Install UV package manager from: {detector.GetUVInstallUrl()}"); + } + else if (dep.Name == "MCP Server") + { + result.RecommendedActions.Add("MCP Server will be installed automatically when needed."); + } + } + + if (result.GetMissingRequired().Count > 0) + { + result.RecommendedActions.Add("Use the Setup Wizard (Window > MCP for Unity > Setup Wizard) for guided installation."); + } + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta b/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta new file mode 100644 index 00000000..ae03260a --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/DependencyManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6789012345678901234abcdef012345 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/Models.meta b/UnityMcpBridge/Editor/Dependencies/Models.meta new file mode 100644 index 00000000..2174dd52 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2c3d4e5f6789012345678901234abcd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs b/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs new file mode 100644 index 00000000..5dd2edaf --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Result of a comprehensive dependency check + /// + [Serializable] + public class DependencyCheckResult + { + /// + /// List of all dependency statuses checked + /// + public List Dependencies { get; set; } + + /// + /// Overall system readiness for MCP operations + /// + public bool IsSystemReady { get; set; } + + /// + /// Whether all required dependencies are available + /// + public bool AllRequiredAvailable => Dependencies?.Where(d => d.IsRequired).All(d => d.IsAvailable) ?? false; + + /// + /// Whether any optional dependencies are missing + /// + public bool HasMissingOptional => Dependencies?.Where(d => !d.IsRequired).Any(d => !d.IsAvailable) ?? false; + + /// + /// Summary message about the dependency state + /// + public string Summary { get; set; } + + /// + /// Recommended next steps for the user + /// + public List RecommendedActions { get; set; } + + /// + /// Timestamp when this check was performed + /// + public DateTime CheckedAt { get; set; } + + public DependencyCheckResult() + { + Dependencies = new List(); + RecommendedActions = new List(); + CheckedAt = DateTime.UtcNow; + } + + /// + /// Get dependencies by availability status + /// + public List GetMissingDependencies() + { + return Dependencies?.Where(d => !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Get missing required dependencies + /// + public List GetMissingRequired() + { + return Dependencies?.Where(d => d.IsRequired && !d.IsAvailable).ToList() ?? new List(); + } + + /// + /// Generate a user-friendly summary of the dependency state + /// + public void GenerateSummary() + { + var missing = GetMissingDependencies(); + var missingRequired = GetMissingRequired(); + + if (missing.Count == 0) + { + Summary = "All dependencies are available and ready."; + IsSystemReady = true; + } + else if (missingRequired.Count == 0) + { + Summary = $"System is ready. {missing.Count} optional dependencies are missing."; + IsSystemReady = true; + } + else + { + Summary = $"System is not ready. {missingRequired.Count} required dependencies are missing."; + IsSystemReady = false; + } + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta new file mode 100644 index 00000000..a88c3bb2 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/Models/DependencyCheckResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 789012345678901234abcdef01234567 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs b/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs new file mode 100644 index 00000000..e755ecad --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs @@ -0,0 +1,65 @@ +using System; + +namespace MCPForUnity.Editor.Dependencies.Models +{ + /// + /// Represents the status of a dependency check + /// + [Serializable] + public class DependencyStatus + { + /// + /// Name of the dependency being checked + /// + public string Name { get; set; } + + /// + /// Whether the dependency is available and functional + /// + public bool IsAvailable { get; set; } + + /// + /// Version information if available + /// + public string Version { get; set; } + + /// + /// Path to the dependency executable/installation + /// + public string Path { get; set; } + + /// + /// Additional details about the dependency status + /// + public string Details { get; set; } + + /// + /// Error message if dependency check failed + /// + public string ErrorMessage { get; set; } + + /// + /// Whether this dependency is required for basic functionality + /// + public bool IsRequired { get; set; } + + /// + /// Suggested installation method or URL + /// + public string InstallationHint { get; set; } + + public DependencyStatus(string name, bool isRequired = true) + { + Name = name; + IsRequired = isRequired; + IsAvailable = false; + } + + public override string ToString() + { + var status = IsAvailable ? "✓" : "✗"; + var version = !string.IsNullOrEmpty(Version) ? $" ({Version})" : ""; + return $"{status} {Name}{version}"; + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta b/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta new file mode 100644 index 00000000..d6eb1d59 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/Models/DependencyStatus.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6789012345678901234abcdef0123456 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta new file mode 100644 index 00000000..22a6b1db --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c3d4e5f6789012345678901234abcdef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs new file mode 100644 index 00000000..7fba58f9 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs @@ -0,0 +1,50 @@ +using MCPForUnity.Editor.Dependencies.Models; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Interface for platform-specific dependency detection + /// + public interface IPlatformDetector + { + /// + /// Platform name this detector handles + /// + string PlatformName { get; } + + /// + /// Whether this detector can run on the current platform + /// + bool CanDetect { get; } + + /// + /// Detect Python installation on this platform + /// + DependencyStatus DetectPython(); + + /// + /// Detect UV package manager on this platform + /// + DependencyStatus DetectUV(); + + /// + /// Detect MCP server installation on this platform + /// + DependencyStatus DetectMCPServer(); + + /// + /// Get platform-specific installation recommendations + /// + string GetInstallationRecommendations(); + + /// + /// Get platform-specific Python installation URL + /// + string GetPythonInstallUrl(); + + /// + /// Get platform-specific UV installation URL + /// + string GetUVInstallUrl(); + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta new file mode 100644 index 00000000..d2cd9f07 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9012345678901234abcdef0123456789 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs new file mode 100644 index 00000000..4ace9756 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs @@ -0,0 +1,212 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Linux-specific dependency detection + /// + public class LinuxPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Linux"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths on Linux + var candidates = new[] + { + "python3", + "python", + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/python/bin/python3", + "/snap/bin/python3" + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths including system, snap, and user-local locations."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/source/"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#linux"; + } + + public override string GetInstallationRecommendations() + { + return @"Linux Installation Recommendations: + +1. Python: Install via package manager or pyenv + - Ubuntu/Debian: sudo apt install python3 python3-pip + - Fedora/RHEL: sudo dnf install python3 python3-pip + - Arch: sudo pacman -S python python-pip + - Or use pyenv: https://github.com/pyenv/pyenv + +2. UV Package Manager: Install via curl + - Run: curl -LsSf https://astral.sh/uv/install.sh | sh + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by Unity MCP Bridge + +Note: Make sure ~/.local/bin is in your PATH for user-local installations."; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Set PATH to include common locations + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Enhance PATH for Unity's GUI environment + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/usr/local/bin", + "/usr/bin", + "/bin", + "/snap/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + fullPath = output; + return true; + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta new file mode 100644 index 00000000..4f8267fd --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2345678901234abcdef0123456789abc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs new file mode 100644 index 00000000..c89e7cb9 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs @@ -0,0 +1,212 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// macOS-specific dependency detection + /// + public class MacOSPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "macOS"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths on macOS + var candidates = new[] + { + "python3", + "python", + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/homebrew/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.13/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3", + "/Library/Frameworks/Python.framework/Versions/3.10/bin/python3" + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'which' command + if (TryFindInPath("python3", out string pathResult) || + TryFindInPath("python", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths including Homebrew, Framework, and system locations."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://www.python.org/downloads/macos/"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#macos"; + } + + public override string GetInstallationRecommendations() + { + return @"macOS Installation Recommendations: + +1. Python: Install via Homebrew (recommended) or python.org + - Homebrew: brew install python3 + - Direct download: https://python.org/downloads/macos/ + +2. UV Package Manager: Install via curl or Homebrew + - Curl: curl -LsSf https://astral.sh/uv/install.sh | sh + - Homebrew: brew install uv + +3. MCP Server: Will be installed automatically by Unity MCP Bridge + +Note: If using Homebrew, make sure /opt/homebrew/bin is in your PATH."; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Set PATH to include common locations + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Enhance PATH for Unity's GUI environment + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var pathAdditions = new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + Path.Combine(homeDir, ".local", "bin") + }; + + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? ""; + psi.EnvironmentVariables["PATH"] = string.Join(":", pathAdditions) + ":" + currentPath; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && File.Exists(output)) + { + fullPath = output; + return true; + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta new file mode 100644 index 00000000..b43864a2 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12345678901234abcdef0123456789ab +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs new file mode 100644 index 00000000..98044f17 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs @@ -0,0 +1,161 @@ +using System; +using System.Diagnostics; +using System.IO; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Base class for platform-specific dependency detection + /// + public abstract class PlatformDetectorBase : IPlatformDetector + { + public abstract string PlatformName { get; } + public abstract bool CanDetect { get; } + + public abstract DependencyStatus DetectPython(); + public abstract string GetPythonInstallUrl(); + public abstract string GetUVInstallUrl(); + public abstract string GetInstallationRecommendations(); + + public virtual DependencyStatus DetectUV() + { + var status = new DependencyStatus("UV Package Manager", isRequired: true) + { + InstallationHint = GetUVInstallUrl() + }; + + try + { + // Use existing UV detection from ServerInstaller + string uvPath = ServerInstaller.FindUvPath(); + if (!string.IsNullOrEmpty(uvPath)) + { + if (TryValidateUV(uvPath, out string version)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = uvPath; + status.Details = $"Found UV {version} at {uvPath}"; + return status; + } + } + + status.ErrorMessage = "UV package manager not found. Please install UV."; + status.Details = "UV is required for managing Python dependencies."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting UV: {ex.Message}"; + } + + return status; + } + + public virtual DependencyStatus DetectMCPServer() + { + var status = new DependencyStatus("MCP Server", isRequired: false); + + try + { + // Check if server is installed + string serverPath = ServerInstaller.GetServerPath(); + string serverPy = Path.Combine(serverPath, "server.py"); + + if (File.Exists(serverPy)) + { + status.IsAvailable = true; + status.Path = serverPath; + + // Try to get version + string versionFile = Path.Combine(serverPath, "server_version.txt"); + if (File.Exists(versionFile)) + { + status.Version = File.ReadAllText(versionFile).Trim(); + } + + status.Details = $"MCP Server found at {serverPath}"; + } + else + { + // Check for embedded server + if (ServerPathResolver.TryFindEmbeddedServerSource(out string embeddedPath)) + { + status.IsAvailable = true; + status.Path = embeddedPath; + status.Details = "MCP Server available (embedded in package)"; + } + else + { + status.ErrorMessage = "MCP Server not found"; + status.Details = "Server will be installed automatically when needed"; + } + } + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting MCP Server: {ex.Message}"; + } + + return status; + } + + protected bool TryValidateUV(string uvPath, out string version) + { + version = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = uvPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("uv ")) + { + version = output.Substring(3); // Remove "uv " prefix + return true; + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + protected bool TryParseVersion(string version, out int major, out int minor) + { + major = 0; + minor = 0; + + try + { + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return int.TryParse(parts[0], out major) && int.TryParse(parts[1], out minor); + } + } + catch + { + // Ignore parsing errors + } + + return false; + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta new file mode 100644 index 00000000..4821e757 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/PlatformDetectorBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44d715aedea2b8b41bf914433bbb2c49 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs new file mode 100644 index 00000000..bd9c6f03 --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs @@ -0,0 +1,191 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies.PlatformDetectors +{ + /// + /// Windows-specific dependency detection + /// + public class WindowsPlatformDetector : PlatformDetectorBase + { + public override string PlatformName => "Windows"; + + public override bool CanDetect => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public override DependencyStatus DetectPython() + { + var status = new DependencyStatus("Python", isRequired: true) + { + InstallationHint = GetPythonInstallUrl() + }; + + try + { + // Check common Python installation paths + var candidates = new[] + { + "python.exe", + "python3.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python313", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python312", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python311", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Python313", "python.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Python312", "python.exe") + }; + + foreach (var candidate in candidates) + { + if (TryValidatePython(candidate, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} at {fullPath}"; + return status; + } + } + + // Try PATH resolution using 'where' command + if (TryFindInPath("python.exe", out string pathResult) || + TryFindInPath("python3.exe", out pathResult)) + { + if (TryValidatePython(pathResult, out string version, out string fullPath)) + { + status.IsAvailable = true; + status.Version = version; + status.Path = fullPath; + status.Details = $"Found Python {version} in PATH at {fullPath}"; + return status; + } + } + + status.ErrorMessage = "Python not found. Please install Python 3.10 or later."; + status.Details = "Checked common installation paths and PATH environment variable."; + } + catch (Exception ex) + { + status.ErrorMessage = $"Error detecting Python: {ex.Message}"; + } + + return status; + } + + public override string GetPythonInstallUrl() + { + return "https://apps.microsoft.com/store/detail/python-313/9NCVDN91XZQP"; + } + + public override string GetUVInstallUrl() + { + return "https://docs.astral.sh/uv/getting-started/installation/#windows"; + } + + public override string GetInstallationRecommendations() + { + return @"Windows Installation Recommendations: + +1. Python: Install from Microsoft Store or python.org + - Microsoft Store: Search for 'Python 3.12' or 'Python 3.13' + - Direct download: https://python.org/downloads/windows/ + +2. UV Package Manager: Install via PowerShell + - Run: powershell -ExecutionPolicy ByPass -c ""irm https://astral.sh/uv/install.ps1 | iex"" + - Or download from: https://github.com/astral-sh/uv/releases + +3. MCP Server: Will be installed automatically by Unity MCP Bridge"; + } + + private bool TryValidatePython(string pythonPath, out string version, out string fullPath) + { + version = null; + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = pythonPath, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(5000); + + if (process.ExitCode == 0 && output.StartsWith("Python ")) + { + version = output.Substring(7); // Remove "Python " prefix + fullPath = pythonPath; + + // Validate minimum version (Python 4+ or Python 3.10+) + if (TryParseVersion(version, out var major, out var minor)) + { + return major > 3 || (major >= 3 && minor >= 10); + } + } + } + catch + { + // Ignore validation errors + } + + return false; + } + + private bool TryFindInPath(string executable, out string fullPath) + { + fullPath = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "where", + Arguments = executable, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) return false; + + string output = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(3000); + + if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + // Take the first result + var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length > 0) + { + fullPath = lines[0].Trim(); + return File.Exists(fullPath); + } + } + } + catch + { + // Ignore errors + } + + return false; + } + } +} diff --git a/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta new file mode 100644 index 00000000..e7e53d7d --- /dev/null +++ b/UnityMcpBridge/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 012345678901234abcdef0123456789a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file 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/McpConfigurationHelper.cs b/UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs new file mode 100644 index 00000000..8e727efb --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs @@ -0,0 +1,297 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for MCP client configuration management with sophisticated + /// logic for preserving existing configs and handling different client types + /// + public static class McpConfigurationHelper + { + private const string LOCK_CONFIG_KEY = "MCPForUnity.LockCursorConfig"; + + /// + /// Writes MCP configuration to the specified path using sophisticated logic + /// that preserves existing configuration and only writes when necessary + /// + public static string WriteMcpConfiguration(string pythonDir, string configPath, McpClient mcpClient = null) + { + // 0) Respect explicit lock (hidden pref or UI toggle) + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; + + // Read existing config if it exists + string existingJson = "{}"; + if (File.Exists(configPath)) + { + try + { + existingJson = File.ReadAllText(configPath); + } + catch (Exception e) + { + Debug.LogWarning($"Error reading existing config: {e.Message}."); + } + } + + // Parse the existing JSON while preserving all properties + dynamic existingConfig; + try + { + if (string.IsNullOrWhiteSpace(existingJson)) + { + existingConfig = new JObject(); + } + else + { + existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new JObject(); + } + } + catch + { + // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object + if (!string.IsNullOrWhiteSpace(existingJson)) + { + Debug.LogWarning("UnityMCP: Configuration file could not be parsed; rewriting server block."); + } + existingConfig = new 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 = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant(); + if ((name == "uv" || name == "uv.exe") && IsValidUvBinary(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; + else + existingRoot = JObject.FromObject(existingConfig); + + existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); + + string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); + + McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); + + try + { + if (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } + + /// + /// Configures a Codex client with sophisticated TOML handling + /// + public static string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient) + { + try + { + if (EditorPrefs.GetBool(LOCK_CONFIG_KEY, false)) + return "Skipped (locked)"; + } + catch { } + + string existingToml = string.Empty; + if (File.Exists(configPath)) + { + try + { + existingToml = File.ReadAllText(configPath); + } + catch (Exception e) + { + 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") && IsValidUvBinary(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 (File.Exists(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } + + /// + /// Validates UV binary by running --version command + /// + private static bool IsValidUvBinary(string path) + { + try + { + if (!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; } + } + + /// + /// Compares two string arrays for equality + /// + 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; + } + + /// + /// Gets the appropriate config file path for the given MCP client based on OS + /// + public static string GetClientConfigPath(McpClient mcpClient) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return mcpClient.windowsConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return string.IsNullOrEmpty(mcpClient.macConfigPath) + ? mcpClient.linuxConfigPath + : mcpClient.macConfigPath; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return mcpClient.linuxConfigPath; + } + else + { + return mcpClient.linuxConfigPath; // fallback + } + } + + /// + /// Creates the directory for the config file if it doesn't exist + /// + public static void EnsureConfigDirectoryExists(string configPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs.meta b/UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs.meta new file mode 100644 index 00000000..17de56c8 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigurationHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e45ac2a13b4c1ba468b8e3aa67b292ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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/McpPathResolver.cs b/UnityMcpBridge/Editor/Helpers/McpPathResolver.cs new file mode 100644 index 00000000..8e683965 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpPathResolver.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using UnityEngine; +using UnityEditor; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Shared helper for resolving Python server directory paths with support for + /// development mode, embedded servers, and installed packages + /// + public static class McpPathResolver + { + private const string USE_EMBEDDED_SERVER_KEY = "MCPForUnity.UseEmbeddedServer"; + + /// + /// Resolves the Python server directory path with comprehensive logic + /// including development mode support and fallback mechanisms + /// + public static string FindPackagePythonDirectory(bool debugLogsEnabled = false) + { + string pythonDir = McpConfigFileHelper.ResolveServerSource(); + + try + { + // Only check dev paths if we're using a file-based package (development mode) + bool isDevelopmentMode = IsDevelopmentMode(); + if (isDevelopmentMode) + { + string currentPackagePath = Path.GetDirectoryName(Application.dataPath); + string[] devPaths = { + 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"))) + { + if (debugLogsEnabled) + { + Debug.Log($"Currently in development mode. Package: {devPath}"); + } + return devPath; + } + } + } + + // Resolve via shared helper (handles local registry and older fallback) only if dev override on + if (EditorPrefs.GetBool(USE_EMBEDDED_SERVER_KEY, 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) + { + Debug.LogWarning("Could not find Python directory with server.py; falling back to installed path"); + } + } + } + catch (Exception e) + { + Debug.LogError($"Error finding package path: {e.Message}"); + } + + return pythonDir; + } + + /// + /// Checks if the current Unity project is in development mode + /// (i.e., the package is referenced as a local file path in manifest.json) + /// + private static bool IsDevelopmentMode() + { + try + { + // Only treat as development if manifest explicitly references a local file path for the package + string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); + if (!File.Exists(manifestPath)) return false; + + string manifestContent = File.ReadAllText(manifestPath); + // Look specifically for our package dependency set to a file: URL + // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk + if (manifestContent.IndexOf("\"com.coplaydev.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + int idx = manifestContent.IndexOf("com.coplaydev.unity-mcp", StringComparison.OrdinalIgnoreCase); + // Crude but effective: check for "file:" in the same line/value + if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 + && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + catch + { + return false; + } + } + + /// + /// Gets the appropriate PATH prepend for the current platform when running external processes + /// + public static string GetPathPrepend() + { + if (Application.platform == RuntimePlatform.OSXEditor) + return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + else if (Application.platform == RuntimePlatform.LinuxEditor) + return "/usr/local/bin:/usr/bin:/bin"; + return null; + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/McpPathResolver.cs.meta b/UnityMcpBridge/Editor/Helpers/McpPathResolver.cs.meta new file mode 100644 index 00000000..38f19973 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpPathResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c76f0c7ff138ba4a952481e04bc3974 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/PackageDetector.cs b/UnityMcpBridge/Editor/Helpers/PackageDetector.cs 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..031a6aed 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,23 +20,23 @@ 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) { Debug.LogError($"MCP-FOR-UNITY: Failed to install Python server: {ex.Message}"); - Debug.LogWarning("MCP-FOR-UNITY: You may need to manually install the Python server. Check the MCP for Unity Editor Window for instructions."); + Debug.LogWarning("MCP-FOR-UNITY: You may need to manually install the Python server. Check the MCP For Unity Window for instructions."); } } } 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..f41e03c3 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerInstaller.cs @@ -419,109 +419,65 @@ 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:; } } - public static bool RepairPythonEnvironment() + public static bool RebuildMcpServer() { try { - string serverSrc = GetServerPath(); - bool hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); - if (!hasServer) - { - // In dev mode or if not installed yet, try the embedded/dev source - if (TryGetEmbeddedServerSource(out string embeddedSrc) && File.Exists(Path.Combine(embeddedSrc, "server.py"))) - { - serverSrc = embeddedSrc; - hasServer = true; - } - else - { - // Attempt to install then retry - EnsureServerInstalled(); - serverSrc = GetServerPath(); - hasServer = File.Exists(Path.Combine(serverSrc, "server.py")); - } - } - - if (!hasServer) - { - Debug.LogWarning("RepairPythonEnvironment: server.py not found; ensure server is installed first."); - return false; - } - - // Remove stale venv and pinned version file if present - string venvPath = Path.Combine(serverSrc, ".venv"); - if (Directory.Exists(venvPath)) - { - try { Directory.Delete(venvPath, recursive: true); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .venv: {ex.Message}"); } - } - string pyPin = Path.Combine(serverSrc, ".python-version"); - if (File.Exists(pyPin)) - { - try { File.Delete(pyPin); } catch (Exception ex) { Debug.LogWarning($"Failed to delete .python-version: {ex.Message}"); } - } - - string uvPath = FindUvPath(); - if (uvPath == null) + // Find embedded source + if (!TryGetEmbeddedServerSource(out string embeddedSrc)) { - Debug.LogError("UV not found. Please install uv (https://docs.astral.sh/uv/)." ); + Debug.LogError("RebuildMcpServer: Could not find embedded server source."); return false; } - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = uvPath, - Arguments = "sync", - WorkingDirectory = serverSrc, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; + string saveLocation = GetSaveLocation(); + string destRoot = Path.Combine(saveLocation, ServerFolder); + string destSrc = Path.Combine(destRoot, "src"); - using var proc = new System.Diagnostics.Process { StartInfo = psi }; - 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); }; + // Kill any running uv processes for this server + TryKillUvForPath(destSrc); - if (!proc.Start()) + // Delete the entire installed server directory + if (Directory.Exists(destRoot)) { - Debug.LogError("Failed to start uv process."); - return false; + try + { + Directory.Delete(destRoot, recursive: true); + Debug.Log($"MCP-FOR-UNITY: Deleted existing server at {destRoot}"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to delete existing server: {ex.Message}"); + return false; + } } - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); + // Re-copy from embedded source + string embeddedRoot = Path.GetDirectoryName(embeddedSrc) ?? embeddedSrc; + Directory.CreateDirectory(destRoot); + CopyDirectoryRecursive(embeddedRoot, destRoot); - if (!proc.WaitForExit(60000)) + // Write version file + string embeddedVer = ReadVersionFile(Path.Combine(embeddedSrc, VersionFileName)) ?? "unknown"; + try { - try { proc.Kill(); } catch { } - Debug.LogError("uv sync timed out."); - return false; + File.WriteAllText(Path.Combine(destSrc, VersionFileName), embeddedVer); } - - // Ensure async buffers flushed - proc.WaitForExit(); - - string stdout = sbOut.ToString(); - string stderr = sbErr.ToString(); - - if (proc.ExitCode != 0) + catch (Exception ex) { - Debug.LogError($"uv sync failed: {stderr}\n{stdout}"); - return false; + Debug.LogWarning($"Failed to write version file: {ex.Message}"); } - Debug.Log("MCP-FOR-UNITY: Python environment repaired successfully."); + Debug.Log($"MCP-FOR-UNITY: Server rebuilt successfully at {destRoot} (version {embeddedVer})"); return true; } catch (Exception ex) { - Debug.LogError($"RepairPythonEnvironment failed: {ex.Message}"); + Debug.LogError($"RebuildMcpServer failed: {ex.Message}"); return false; } } diff --git a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs index 5684b19a..0e462945 100644 --- a/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs +++ b/UnityMcpBridge/Editor/Helpers/ServerPathResolver.cs @@ -12,7 +12,7 @@ public static class ServerPathResolver /// or common development locations. Returns true if found and sets srcPath to the folder /// containing server.py. /// - public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLegacyPackageId = true) + public static bool TryFindEmbeddedServerSource(out string srcPath) { // 1) Repo development layouts commonly used alongside this package try @@ -43,7 +43,7 @@ public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLe var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); if (owner != null) { - if (TryResolveWithinPackage(owner, out srcPath, warnOnLegacyPackageId)) + if (TryResolveWithinPackage(owner, out srcPath)) { return true; } @@ -52,7 +52,7 @@ public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLe // Secondary: scan all registered packages locally foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) { - if (TryResolveWithinPackage(p, out srcPath, warnOnLegacyPackageId)) + if (TryResolveWithinPackage(p, out srcPath)) { return true; } @@ -65,7 +65,7 @@ public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLe { foreach (var pkg in list.Result) { - if (TryResolveWithinPackage(pkg, out srcPath, warnOnLegacyPackageId)) + if (TryResolveWithinPackage(pkg, out srcPath)) { return true; } @@ -99,24 +99,16 @@ public static bool TryFindEmbeddedServerSource(out string srcPath, bool warnOnLe return false; } - private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath, bool warnOnLegacyPackageId) + private static bool TryResolveWithinPackage(UnityEditor.PackageManager.PackageInfo p, out string srcPath) { const string CurrentId = "com.coplaydev.unity-mcp"; - const string LegacyId = "com.justinpbarnett.unity-mcp"; srcPath = null; - if (p == null || (p.name != CurrentId && p.name != LegacyId)) + if (p == null || p.name != CurrentId) { return false; } - if (warnOnLegacyPackageId && p.name == LegacyId) - { - Debug.LogWarning( - "MCP for Unity: Detected legacy package id 'com.justinpbarnett.unity-mcp'. " + - "Please update Packages/manifest.json to 'com.coplaydev.unity-mcp' to avoid future breakage."); - } - string packagePath = p.resolvedPath; // Preferred tilde folder (embedded but excluded from import) @@ -147,5 +139,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..dcc469b0 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 { } @@ -386,6 +387,7 @@ public static void Start() // Start background listener with cooperative cancellation cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); + CommandRegistry.Initialize(); EditorApplication.update += ProcessCommands; // Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { } @@ -516,160 +518,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 +808,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/Setup.meta b/UnityMcpBridge/Editor/Setup.meta new file mode 100644 index 00000000..1157b1e9 --- /dev/null +++ b/UnityMcpBridge/Editor/Setup.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 600c9cb20c329d761bfa799158a87bac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Setup/SetupWizard.cs b/UnityMcpBridge/Editor/Setup/SetupWizard.cs new file mode 100644 index 00000000..a97926ea --- /dev/null +++ b/UnityMcpBridge/Editor/Setup/SetupWizard.cs @@ -0,0 +1,150 @@ +using System; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Windows; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Handles automatic triggering of the setup wizard + /// + [InitializeOnLoad] + public static class SetupWizard + { + private const string SETUP_COMPLETED_KEY = "MCPForUnity.SetupCompleted"; + private const string SETUP_DISMISSED_KEY = "MCPForUnity.SetupDismissed"; + private static bool _hasCheckedThisSession = false; + + static SetupWizard() + { + // Skip in batch mode + if (Application.isBatchMode) + return; + + // Show setup wizard on package import + EditorApplication.delayCall += CheckSetupNeeded; + } + + /// + /// Check if setup wizard should be shown + /// + private static void CheckSetupNeeded() + { + if (_hasCheckedThisSession) + return; + + _hasCheckedThisSession = true; + + try + { + // Check if setup was already completed or dismissed in previous sessions + bool setupCompleted = EditorPrefs.GetBool(SETUP_COMPLETED_KEY, false); + bool setupDismissed = EditorPrefs.GetBool(SETUP_DISMISSED_KEY, false); + + // Only show setup wizard if it hasn't been completed or dismissed before + if (!(setupCompleted || setupDismissed)) + { + McpLog.Info("Package imported - showing setup wizard", always: false); + + var dependencyResult = DependencyManager.CheckAllDependencies(); + EditorApplication.delayCall += () => ShowSetupWizard(dependencyResult); + } + else + { + McpLog.Info("Setup wizard skipped - previously completed or dismissed", always: false); + } + } + catch (Exception ex) + { + McpLog.Error($"Error checking setup status: {ex.Message}"); + } + } + + /// + /// Show the setup wizard window + /// + public static void ShowSetupWizard(DependencyCheckResult dependencyResult = null) + { + try + { + dependencyResult ??= DependencyManager.CheckAllDependencies(); + SetupWizardWindow.ShowWindow(dependencyResult); + } + catch (Exception ex) + { + McpLog.Error($"Error showing setup wizard: {ex.Message}"); + } + } + + /// + /// Mark setup as completed + /// + public static void MarkSetupCompleted() + { + EditorPrefs.SetBool(SETUP_COMPLETED_KEY, true); + McpLog.Info("Setup marked as completed"); + } + + /// + /// Mark setup as dismissed + /// + public static void MarkSetupDismissed() + { + EditorPrefs.SetBool(SETUP_DISMISSED_KEY, true); + McpLog.Info("Setup marked as dismissed"); + } + + /// + /// Force show setup wizard (for manual invocation) + /// + [MenuItem("Window/MCP For Unity/Setup Wizard", priority = 1)] + public static void ShowSetupWizardManual() + { + ShowSetupWizard(); + } + + /// + /// Check dependencies and show status + /// + [MenuItem("Window/MCP For Unity/Check Dependencies", priority = 3)] + public static void CheckDependencies() + { + var result = DependencyManager.CheckAllDependencies(); + + if (!result.IsSystemReady) + { + bool showWizard = EditorUtility.DisplayDialog( + "MCP for Unity - Dependencies", + $"System Status: {result.Summary}\n\nWould you like to open the Setup Wizard?", + "Open Setup Wizard", + "Close" + ); + + if (showWizard) + { + ShowSetupWizard(result); + } + } + else + { + EditorUtility.DisplayDialog( + "MCP for Unity - Dependencies", + "✓ All dependencies are available and ready!\n\nMCP for Unity is ready to use.", + "OK" + ); + } + } + + /// + /// Open MCP Client Configuration window + /// + [MenuItem("Window/MCP For Unity/Open MCP Window", priority = 4)] + public static void OpenClientConfiguration() + { + Windows.MCPForUnityEditorWindow.ShowWindow(); + } + } +} diff --git a/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta b/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta new file mode 100644 index 00000000..1a0e4e5f --- /dev/null +++ b/UnityMcpBridge/Editor/Setup/SetupWizard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 345678901234abcdef0123456789abcd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs b/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs new file mode 100644 index 00000000..7229be97 --- /dev/null +++ b/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs @@ -0,0 +1,726 @@ +using System; +using System.Linq; +using MCPForUnity.Editor.Data; +using MCPForUnity.Editor.Dependencies; +using MCPForUnity.Editor.Dependencies.Models; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Models; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Setup +{ + /// + /// Setup wizard window for guiding users through dependency installation + /// + public class SetupWizardWindow : EditorWindow + { + private DependencyCheckResult _dependencyResult; + private Vector2 _scrollPosition; + private int _currentStep = 0; + private McpClients _mcpClients; + private int _selectedClientIndex = 0; + + private readonly string[] _stepTitles = { + "Setup", + "Configure", + "Complete" + }; + + public static void ShowWindow(DependencyCheckResult dependencyResult = null) + { + var window = GetWindow("MCP for Unity Setup"); + window.minSize = new Vector2(500, 400); + window.maxSize = new Vector2(800, 600); + window._dependencyResult = dependencyResult ?? DependencyManager.CheckAllDependencies(); + window.Show(); + } + + private void OnEnable() + { + if (_dependencyResult == null) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + + _mcpClients = new McpClients(); + + // Check client configurations on startup + foreach (var client in _mcpClients.clients) + { + CheckClientConfiguration(client); + } + } + + private void OnGUI() + { + DrawHeader(); + DrawProgressBar(); + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + switch (_currentStep) + { + case 0: DrawSetupStep(); break; + case 1: DrawConfigureStep(); break; + case 2: DrawCompleteStep(); break; + } + + EditorGUILayout.EndScrollView(); + + DrawFooter(); + } + + private void DrawHeader() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + GUILayout.Label("MCP for Unity Setup Wizard", EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + GUILayout.Label($"Step {_currentStep + 1} of {_stepTitles.Length}"); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // Step title + var titleStyle = new GUIStyle(EditorStyles.largeLabel) + { + fontSize = 16, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(_stepTitles[_currentStep], titleStyle); + EditorGUILayout.Space(); + } + + private void DrawProgressBar() + { + var rect = EditorGUILayout.GetControlRect(false, 4); + var progress = (_currentStep + 1) / (float)_stepTitles.Length; + EditorGUI.ProgressBar(rect, progress, ""); + EditorGUILayout.Space(); + } + + private void DrawSetupStep() + { + // Welcome section + DrawSectionTitle("MCP for Unity Setup"); + + EditorGUILayout.LabelField( + "This wizard will help you set up MCP for Unity to connect AI assistants with your Unity Editor.", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + // Dependency check section + EditorGUILayout.BeginHorizontal(); + DrawSectionTitle("System Check", 14); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Refresh", GUILayout.Width(60), GUILayout.Height(20))) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + EditorGUILayout.EndHorizontal(); + + // Show simplified dependency status + foreach (var dep in _dependencyResult.Dependencies) + { + DrawSimpleDependencyStatus(dep); + } + + // Overall status and installation guidance + EditorGUILayout.Space(); + if (!_dependencyResult.IsSystemReady) + { + // Only show critical warnings when dependencies are actually missing + EditorGUILayout.HelpBox( + "⚠️ Missing Dependencies: MCP for Unity requires Python 3.10+ and UV package manager to function properly.", + MessageType.Warning + ); + + EditorGUILayout.Space(); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + DrawErrorStatus("Installation Required"); + + var recommendations = DependencyManager.GetInstallationRecommendations(); + EditorGUILayout.LabelField(recommendations, EditorStyles.wordWrappedLabel); + + EditorGUILayout.Space(); + if (GUILayout.Button("Open Installation Links", GUILayout.Height(25))) + { + OpenInstallationUrls(); + } + EditorGUILayout.EndVertical(); + } + else + { + DrawSuccessStatus("System Ready"); + EditorGUILayout.LabelField("All requirements are met. You can proceed to configure your AI clients.", EditorStyles.wordWrappedLabel); + } + } + + + + private void DrawCompleteStep() + { + DrawSectionTitle("Setup Complete"); + + // Refresh dependency check with caching to avoid heavy operations on every repaint + if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + + if (_dependencyResult.IsSystemReady) + { + DrawSuccessStatus("MCP for Unity Ready!"); + + EditorGUILayout.HelpBox( + "🎉 MCP for Unity is now set up and ready to use!\n\n" + + "• Dependencies verified\n" + + "• MCP server ready\n" + + "• Client configuration accessible", + MessageType.Info + ); + + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("Documentation", GUILayout.Height(30))) + { + Application.OpenURL("https://github.com/CoplayDev/unity-mcp"); + } + if (GUILayout.Button("Client Settings", GUILayout.Height(30))) + { + Windows.MCPForUnityEditorWindow.ShowWindow(); + } + EditorGUILayout.EndHorizontal(); + } + else + { + DrawErrorStatus("Setup Incomplete - Package Non-Functional"); + + EditorGUILayout.HelpBox( + "🚨 MCP for Unity CANNOT work - dependencies still missing!\n\n" + + "Install ALL required dependencies before the package will function.", + MessageType.Error + ); + + var missingDeps = _dependencyResult.GetMissingRequired(); + if (missingDeps.Count > 0) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Still Missing:", EditorStyles.boldLabel); + foreach (var dep in missingDeps) + { + EditorGUILayout.LabelField($"✗ {dep.Name}", EditorStyles.label); + } + } + + EditorGUILayout.Space(); + if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) + { + _currentStep = 0; + } + } + } + + // Helper methods for consistent UI components + private void DrawSectionTitle(string title, int fontSize = 16) + { + var titleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = fontSize, + fontStyle = FontStyle.Bold + }; + EditorGUILayout.LabelField(title, titleStyle); + EditorGUILayout.Space(); + } + + private void DrawSuccessStatus(string message) + { + var originalColor = GUI.color; + GUI.color = Color.green; + EditorGUILayout.LabelField($"✓ {message}", EditorStyles.boldLabel); + GUI.color = originalColor; + EditorGUILayout.Space(); + } + + private void DrawErrorStatus(string message) + { + var originalColor = GUI.color; + GUI.color = Color.red; + EditorGUILayout.LabelField($"✗ {message}", EditorStyles.boldLabel); + GUI.color = originalColor; + EditorGUILayout.Space(); + } + + private void DrawSimpleDependencyStatus(DependencyStatus dep) + { + EditorGUILayout.BeginHorizontal(); + + var statusIcon = dep.IsAvailable ? "✓" : "✗"; + var statusColor = dep.IsAvailable ? Color.green : Color.red; + + var originalColor = GUI.color; + GUI.color = statusColor; + GUILayout.Label(statusIcon, GUILayout.Width(20)); + EditorGUILayout.LabelField(dep.Name, EditorStyles.boldLabel); + GUI.color = originalColor; + + if (!dep.IsAvailable && !string.IsNullOrEmpty(dep.ErrorMessage)) + { + EditorGUILayout.LabelField($"({dep.ErrorMessage})", EditorStyles.miniLabel); + } + + EditorGUILayout.EndHorizontal(); + } + + private void DrawConfigureStep() + { + DrawSectionTitle("AI Client Configuration"); + + // Check dependencies first (with caching to avoid heavy operations on every repaint) + if (_dependencyResult == null || (DateTime.UtcNow - _dependencyResult.CheckedAt).TotalSeconds > 2) + { + _dependencyResult = DependencyManager.CheckAllDependencies(); + } + if (!_dependencyResult.IsSystemReady) + { + DrawErrorStatus("Cannot Configure - System Requirements Not Met"); + + EditorGUILayout.HelpBox( + "Client configuration requires system dependencies to be installed first. Please complete setup before proceeding.", + MessageType.Warning + ); + + if (GUILayout.Button("Go Back to Setup", GUILayout.Height(30))) + { + _currentStep = 0; + } + return; + } + + EditorGUILayout.LabelField( + "Configure your AI assistants to work with Unity. Select a client below to set it up:", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + // Client selection and configuration + if (_mcpClients.clients.Count > 0) + { + // Client selector dropdown + string[] clientNames = _mcpClients.clients.Select(c => c.name).ToArray(); + EditorGUI.BeginChangeCheck(); + _selectedClientIndex = EditorGUILayout.Popup("Select AI Client:", _selectedClientIndex, clientNames); + if (EditorGUI.EndChangeCheck()) + { + _selectedClientIndex = Mathf.Clamp(_selectedClientIndex, 0, _mcpClients.clients.Count - 1); + // Refresh client status when selection changes + CheckClientConfiguration(_mcpClients.clients[_selectedClientIndex]); + } + + EditorGUILayout.Space(); + + var selectedClient = _mcpClients.clients[_selectedClientIndex]; + DrawClientConfigurationInWizard(selectedClient); + + EditorGUILayout.Space(); + + // Batch configuration option + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("Quick Setup", EditorStyles.boldLabel); + EditorGUILayout.LabelField( + "Automatically configure all detected AI clients at once:", + EditorStyles.wordWrappedLabel + ); + EditorGUILayout.Space(); + + if (GUILayout.Button("Configure All Detected Clients", GUILayout.Height(30))) + { + ConfigureAllClientsInWizard(); + } + EditorGUILayout.EndVertical(); + } + else + { + EditorGUILayout.HelpBox("No AI clients detected. Make sure you have Claude Code, Cursor, or VSCode installed.", MessageType.Info); + } + + EditorGUILayout.Space(); + EditorGUILayout.HelpBox( + "💡 You might need to restart your AI client after configuring.", + MessageType.Info + ); + } + + private void DrawFooter() + { + EditorGUILayout.Space(); + EditorGUILayout.BeginHorizontal(); + + // Back button + GUI.enabled = _currentStep > 0; + if (GUILayout.Button("Back", GUILayout.Width(60))) + { + _currentStep--; + } + + GUILayout.FlexibleSpace(); + + // Skip button + if (GUILayout.Button("Skip", GUILayout.Width(60))) + { + bool dismiss = EditorUtility.DisplayDialog( + "Skip Setup", + "⚠️ Skipping setup will leave MCP for Unity non-functional!\n\n" + + "You can restart setup from: Window > MCP for Unity > Setup Wizard (Required)", + "Skip Anyway", + "Cancel" + ); + + if (dismiss) + { + SetupWizard.MarkSetupDismissed(); + Close(); + } + } + + // Next/Done button + GUI.enabled = true; + string buttonText = _currentStep == _stepTitles.Length - 1 ? "Done" : "Next"; + + if (GUILayout.Button(buttonText, GUILayout.Width(80))) + { + if (_currentStep == _stepTitles.Length - 1) + { + SetupWizard.MarkSetupCompleted(); + Close(); + } + else + { + _currentStep++; + } + } + + GUI.enabled = true; + EditorGUILayout.EndHorizontal(); + } + + private void DrawClientConfigurationInWizard(McpClient client) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"{client.name} Configuration", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // Show current status + var statusColor = GetClientStatusColor(client); + var originalColor = GUI.color; + GUI.color = statusColor; + EditorGUILayout.LabelField($"Status: {client.configStatus}", EditorStyles.label); + GUI.color = originalColor; + + EditorGUILayout.Space(); + + // Configuration buttons + EditorGUILayout.BeginHorizontal(); + + if (client.mcpType == McpTypes.ClaudeCode) + { + // Special handling for Claude Code + bool claudeAvailable = !string.IsNullOrEmpty(ExecPath.ResolveClaude()); + if (claudeAvailable) + { + bool isConfigured = client.status == McpStatus.Configured; + string buttonText = isConfigured ? "Unregister" : "Register"; + if (GUILayout.Button($"{buttonText} with Claude Code")) + { + if (isConfigured) + { + UnregisterFromClaudeCode(client); + } + else + { + RegisterWithClaudeCode(client); + } + } + } + else + { + EditorGUILayout.HelpBox("Claude Code not found. Please install Claude Code first.", MessageType.Warning); + if (GUILayout.Button("Open Claude Code Website")) + { + Application.OpenURL("https://claude.ai/download"); + } + } + } + else + { + // Standard client configuration + if (GUILayout.Button($"Configure {client.name}")) + { + ConfigureClientInWizard(client); + } + + if (GUILayout.Button("Manual Setup")) + { + ShowManualSetupInWizard(client); + } + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + + private Color GetClientStatusColor(McpClient client) + { + return client.status switch + { + McpStatus.Configured => Color.green, + McpStatus.Running => Color.green, + McpStatus.Connected => Color.green, + McpStatus.IncorrectPath => Color.yellow, + McpStatus.CommunicationError => Color.yellow, + McpStatus.NoResponse => Color.yellow, + _ => Color.red + }; + } + + private void ConfigureClientInWizard(McpClient client) + { + try + { + string result = PerformClientConfiguration(client); + + EditorUtility.DisplayDialog( + $"{client.name} Configuration", + result, + "OK" + ); + + // Refresh client status + CheckClientConfiguration(client); + Repaint(); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog( + "Configuration Error", + $"Failed to configure {client.name}: {ex.Message}", + "OK" + ); + } + } + + private void ConfigureAllClientsInWizard() + { + int successCount = 0; + int totalCount = _mcpClients.clients.Count; + + foreach (var client in _mcpClients.clients) + { + try + { + if (client.mcpType == McpTypes.ClaudeCode) + { + if (!string.IsNullOrEmpty(ExecPath.ResolveClaude()) && client.status != McpStatus.Configured) + { + RegisterWithClaudeCode(client); + successCount++; + } + else if (client.status == McpStatus.Configured) + { + successCount++; // Already configured + } + } + else + { + string result = PerformClientConfiguration(client); + if (result.Contains("success", System.StringComparison.OrdinalIgnoreCase)) + { + successCount++; + } + } + + CheckClientConfiguration(client); + } + catch (System.Exception ex) + { + McpLog.Error($"Failed to configure {client.name}: {ex.Message}"); + } + } + + EditorUtility.DisplayDialog( + "Batch Configuration Complete", + $"Successfully configured {successCount} out of {totalCount} clients.\n\n" + + "Restart your AI clients for changes to take effect.", + "OK" + ); + + Repaint(); + } + + private void RegisterWithClaudeCode(McpClient client) + { + try + { + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + string claudePath = ExecPath.ResolveClaude(); + string uvPath = ExecPath.ResolveUv() ?? "uv"; + + string args = $"mcp add UnityMCP -- \"{uvPath}\" run --directory \"{pythonDir}\" server.py"; + + if (!ExecPath.TryRun(claudePath, args, null, out var stdout, out var stderr, 15000, McpPathResolver.GetPathPrepend())) + { + if ((stdout + stderr).Contains("already exists", System.StringComparison.OrdinalIgnoreCase)) + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "MCP for Unity is already registered with Claude Code.", "OK"); + } + else + { + throw new System.Exception($"Registration failed: {stderr}"); + } + } + else + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "Successfully registered MCP for Unity with Claude Code!", "OK"); + } + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Registration Error", $"Failed to register with Claude Code: {ex.Message}", "OK"); + } + } + + private void UnregisterFromClaudeCode(McpClient client) + { + try + { + string claudePath = ExecPath.ResolveClaude(); + if (ExecPath.TryRun(claudePath, "mcp remove UnityMCP", null, out var stdout, out var stderr, 10000, McpPathResolver.GetPathPrepend())) + { + CheckClientConfiguration(client); + EditorUtility.DisplayDialog("Claude Code", "Successfully unregistered MCP for Unity from Claude Code.", "OK"); + } + else + { + throw new System.Exception($"Unregistration failed: {stderr}"); + } + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Unregistration Error", $"Failed to unregister from Claude Code: {ex.Message}", "OK"); + } + } + + private string PerformClientConfiguration(McpClient client) + { + // This mirrors the logic from MCPForUnityEditorWindow.ConfigureMcpClient + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + + if (string.IsNullOrEmpty(pythonDir)) + { + return "Manual configuration required - Python server directory not found."; + } + + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); + return McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + } + + private void ShowManualSetupInWizard(McpClient client) + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + string pythonDir = McpPathResolver.FindPackagePythonDirectory(); + string uvPath = ServerInstaller.FindUvPath(); + + if (string.IsNullOrEmpty(uvPath)) + { + EditorUtility.DisplayDialog("Manual Setup", "UV package manager not found. Please install UV first.", "OK"); + return; + } + + // Build manual configuration using the sophisticated helper logic + string result = McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, client); + string manualConfig; + + if (result == "Configured successfully") + { + // Read back the configuration that was written + try + { + manualConfig = System.IO.File.ReadAllText(configPath); + } + catch + { + manualConfig = "Configuration written successfully, but could not read back for display."; + } + } + else + { + manualConfig = $"Configuration failed: {result}"; + } + + EditorUtility.DisplayDialog( + $"Manual Setup - {client.name}", + $"Configuration file location:\n{configPath}\n\n" + + $"Configuration result:\n{manualConfig}", + "OK" + ); + } + + private void CheckClientConfiguration(McpClient client) + { + // Basic status check - could be enhanced to mirror MCPForUnityEditorWindow logic + try + { + string configPath = McpConfigurationHelper.GetClientConfigPath(client); + if (System.IO.File.Exists(configPath)) + { + client.configStatus = "Configured"; + client.status = McpStatus.Configured; + } + else + { + client.configStatus = "Not Configured"; + client.status = McpStatus.NotConfigured; + } + } + catch + { + client.configStatus = "Error"; + client.status = McpStatus.Error; + } + } + + private void OpenInstallationUrls() + { + var (pythonUrl, uvUrl) = DependencyManager.GetInstallationUrls(); + + bool openPython = EditorUtility.DisplayDialog( + "Open Installation URLs", + "Open Python installation page?", + "Yes", + "No" + ); + + if (openPython) + { + Application.OpenURL(pythonUrl); + } + + bool openUV = EditorUtility.DisplayDialog( + "Open Installation URLs", + "Open UV installation page?", + "Yes", + "No" + ); + + if (openUV) + { + Application.OpenURL(uvUrl); + } + } + } +} diff --git a/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta b/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta new file mode 100644 index 00000000..5361de3d --- /dev/null +++ b/UnityMcpBridge/Editor/Setup/SetupWizardWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45678901234abcdef0123456789abcde +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index afc14448..79003d55 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -1,51 +1,138 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Tools.MenuItems; -using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor.Tools { /// - /// Registry for all MCP command handlers (Refactored Version) + /// Registry for all MCP command handlers via reflection. /// public static class CommandRegistry { - // Maps command names (matching those called from Python via ctx.bridge.unity_editor.HandlerName) - // to the corresponding static HandleCommand method in the appropriate tool class. - private static readonly Dictionary> _handlers = new() + private static readonly Dictionary> _handlers = new(); + private static bool _initialized = false; + + /// + /// Initialize and auto-discover all tools marked with [McpForUnityTool] + /// + public static void Initialize() { - { "manage_script", ManageScript.HandleCommand }, - { "manage_scene", ManageScene.HandleCommand }, - { "manage_editor", ManageEditor.HandleCommand }, - { "manage_gameobject", ManageGameObject.HandleCommand }, - { "manage_asset", ManageAsset.HandleCommand }, - { "read_console", ReadConsole.HandleCommand }, - { "manage_menu_item", ManageMenuItem.HandleCommand }, - { "manage_shader", ManageShader.HandleCommand}, - { "manage_prefabs", ManagePrefabs.HandleCommand}, - }; + if (_initialized) return; + + AutoDiscoverTools(); + _initialized = true; + } /// - /// Gets a command handler by name. + /// Convert PascalCase or camelCase to snake_case /// - /// Name of the command handler (e.g., "HandleManageAsset"). - /// The command handler function if found, null otherwise. - public static Func GetHandler(string commandName) + private static string ToSnakeCase(string name) { - if (!_handlers.TryGetValue(commandName, out var handler)) + if (string.IsNullOrEmpty(name)) return name; + + // Insert underscore before uppercase letters (except first) + var s1 = Regex.Replace(name, "(.)([A-Z][a-z]+)", "$1_$2"); + var s2 = Regex.Replace(s1, "([a-z0-9])([A-Z])", "$1_$2"); + return s2.ToLower(); + } + + /// + /// Auto-discover all types with [McpForUnityTool] attribute + /// + private static void AutoDiscoverTools() + { + try { - throw new InvalidOperationException( - $"Unknown or unsupported command type: {commandName}"); + var toolTypes = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic) + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return new Type[0]; } + }) + .Where(t => t.GetCustomAttribute() != null); + + foreach (var type in toolTypes) + { + RegisterToolType(type); + } + + McpLog.Info($"Auto-discovered {_handlers.Count} tools"); + } + catch (Exception ex) + { + McpLog.Error($"Failed to auto-discover MCP tools: {ex.Message}"); } + } - return handler; + private static void RegisterToolType(Type type) + { + var attr = type.GetCustomAttribute(); + + // Get command name (explicit or auto-generated) + string commandName = attr.CommandName; + if (string.IsNullOrEmpty(commandName)) + { + commandName = ToSnakeCase(type.Name); + } + + // Check for duplicate command names + if (_handlers.ContainsKey(commandName)) + { + McpLog.Warn( + $"Duplicate command name '{commandName}' detected. " + + $"Tool {type.Name} will override previously registered handler." + ); + } + + // Find HandleCommand method + var method = type.GetMethod( + "HandleCommand", + BindingFlags.Public | BindingFlags.Static, + null, + new[] { typeof(JObject) }, + null + ); + + if (method == null) + { + McpLog.Warn( + $"MCP tool {type.Name} is marked with [McpForUnityTool] " + + $"but has no public static HandleCommand(JObject) method" + ); + return; + } + + try + { + var handler = (Func)Delegate.CreateDelegate( + typeof(Func), + method + ); + _handlers[commandName] = handler; + } + catch (Exception ex) + { + McpLog.Error($"Failed to register tool {type.Name}: {ex.Message}"); + } } - public static void Add(string commandName, Func handler) + /// + /// Get a command handler by name + /// + public static Func GetHandler(string commandName) { - _handlers.Add(commandName, handler); + if (!_handlers.TryGetValue(commandName, out var handler)) + { + throw new InvalidOperationException( + $"Unknown or unsupported command type: {commandName}" + ); + } + return handler; } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs index 52a5bcac..1a952f37 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -22,6 +22,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles asset management operations within the Unity project. /// + [McpForUnityTool("manage_asset")] public static class ManageAsset { // --- Main Handler --- diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs index f26502dd..f8255224 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -15,6 +15,7 @@ namespace MCPForUnity.Editor.Tools /// Handles operations related to controlling and querying the Unity Editor state, /// including managing Tags and Layers. /// + [McpForUnityTool("manage_editor")] public static class ManageEditor { // Constant for starting user layer index diff --git a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs index 4c11f343..40504a87 100644 --- a/UnityMcpBridge/Editor/Tools/ManageGameObject.cs +++ b/UnityMcpBridge/Editor/Tools/ManageGameObject.cs @@ -19,6 +19,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles GameObject manipulation within the current scene (CRUD, find, components). /// + [McpForUnityTool("manage_gameobject")] public static class ManageGameObject { // Shared JsonSerializer to avoid per-call allocation overhead @@ -27,7 +28,7 @@ public static class ManageGameObject Converters = new List { new Vector3Converter(), - new Vector2Converter(), + new Vector2Converter(), new QuaternionConverter(), new ColorConverter(), new RectConverter(), @@ -35,7 +36,7 @@ public static class ManageGameObject new UnityEngineObjectConverter() } }); - + // --- Main Handler --- public static object HandleCommand(JObject @params) @@ -879,7 +880,7 @@ string searchMethod // return Response.Success( // $"GameObject '{targetGo.name}' modified successfully.", // GetGameObjectData(targetGo)); - + } private static object DeleteGameObject(JToken targetToken, string searchMethod) @@ -962,23 +963,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 +1003,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 +1182,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 +1231,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 +1694,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 +1717,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 +1727,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 +1884,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 +1909,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 +1938,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 +1955,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 +2038,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 +2072,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 +2081,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 +2101,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 +2154,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 +2269,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 +2458,7 @@ public static List GetAIPropertySuggestions(string userInput, List GetRuleBasedSuggestions(string userInput, List /// Handles scene management operations like loading, saving, creating, and querying hierarchy. /// + [McpForUnityTool("manage_scene")] public static class ManageScene { private sealed class SceneCommand @@ -472,4 +473,3 @@ private static object GetGameObjectDataRecursive(GameObject go) } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0231c858..2d970486 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -49,6 +49,7 @@ namespace MCPForUnity.Editor.Tools /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// + [McpForUnityTool("manage_script")] public static class ManageScript { /// @@ -2658,4 +2659,3 @@ public static void ImportAndRequestCompile(string relPath, bool synchronous = tr #endif } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageShader.cs b/UnityMcpBridge/Editor/Tools/ManageShader.cs index c2dfbc2f..2d7f4d0a 100644 --- a/UnityMcpBridge/Editor/Tools/ManageShader.cs +++ b/UnityMcpBridge/Editor/Tools/ManageShader.cs @@ -12,6 +12,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles CRUD operations for shader files within the Unity project. /// + [McpForUnityTool("manage_shader")] public static class ManageShader { /// @@ -339,4 +340,4 @@ fixed4 frag (v2f i) : SV_Target }"; } } -} \ No newline at end of file +} diff --git a/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs b/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs new file mode 100644 index 00000000..bb4e0431 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs @@ -0,0 +1,37 @@ +using System; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Marks a class as an MCP tool handler for auto-discovery. + /// The class must have a public static HandleCommand(JObject) method. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + public class McpForUnityToolAttribute : Attribute + { + /// + /// The command name used to route requests to this tool. + /// If not specified, defaults to the PascalCase class name converted to snake_case. + /// + public string CommandName { get; } + + /// + /// Create an MCP tool attribute with auto-generated command name. + /// The command name will be derived from the class name (PascalCase → snake_case). + /// Example: ManageAsset → manage_asset + /// + public McpForUnityToolAttribute() + { + CommandName = null; // Will be auto-generated + } + + /// + /// Create an MCP tool attribute with explicit command name. + /// + /// The command name (e.g., "manage_asset") + public McpForUnityToolAttribute(string commandName) + { + CommandName = commandName; + } + } +} diff --git a/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs.meta b/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs.meta new file mode 100644 index 00000000..57242c17 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/McpForUnityToolAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 804d07b886f4e4eb39316bbef34687c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs index 0f213c68..e4b7eaf7 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -4,6 +4,7 @@ namespace MCPForUnity.Editor.Tools.MenuItems { + [McpForUnityTool("manage_menu_item")] public static class ManageMenuItem { /// diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs index aaf67b14..9e68d20e 100644 --- a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -9,6 +9,7 @@ namespace MCPForUnity.Editor.Tools.Prefabs { + [McpForUnityTool("manage_prefabs")] public static class ManagePrefabs { private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject"; diff --git a/UnityMcpBridge/Editor/Tools/ReadConsole.cs b/UnityMcpBridge/Editor/Tools/ReadConsole.cs index 5bbf557b..e94e5d51 100644 --- a/UnityMcpBridge/Editor/Tools/ReadConsole.cs +++ b/UnityMcpBridge/Editor/Tools/ReadConsole.cs @@ -14,6 +14,7 @@ namespace MCPForUnity.Editor.Tools /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// + [McpForUnityTool("read_console")] public static class ReadConsole { // (Calibration removed) @@ -43,8 +44,8 @@ static ReadConsole() ); if (logEntriesType == null) throw new Exception("Could not find internal type UnityEditor.LogEntries"); - - + + // Include NonPublic binding flags as internal APIs might change accessibility BindingFlags staticFlags = @@ -104,9 +105,9 @@ static ReadConsole() _instanceIdField = logEntryType.GetField("instanceID", instanceFlags); if (_instanceIdField == null) throw new Exception("Failed to reflect LogEntry.instanceID"); - + // (Calibration removed) - + } catch (Exception e) { @@ -303,14 +304,18 @@ bool includeStacktrace // --- Formatting --- string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null; - // Get first line if stack is present and requested, otherwise use full message - string messageOnly = - (includeStacktrace && !string.IsNullOrEmpty(stackTrace)) - ? message.Split( - new[] { '\n', '\r' }, - StringSplitOptions.RemoveEmptyEntries - )[0] - : message; + // Always get first line for the message, use full message only if no stack trace exists + string[] messageLines = message.Split( + new[] { '\n', '\r' }, + StringSplitOptions.RemoveEmptyEntries + ); + string messageOnly = messageLines.Length > 0 ? messageLines[0] : message; + + // If not including stacktrace, ensure we only show the first line + if (!includeStacktrace) + { + stackTrace = null; + } object formattedEntry = null; switch (format) @@ -505,7 +510,7 @@ private static string ExtractStackTrace(string fullMessage) || trimmedLine.StartsWith("UnityEditor.") || trimmedLine.Contains("(at ") || // Covers "(at Assets/..." pattern - // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) + // Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something) ( trimmedLine.Length > 0 && char.IsUpper(trimmedLine[0]) @@ -568,4 +573,3 @@ May change between versions. */ } } - diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index cdaa6c17..98a5295e 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -31,24 +31,24 @@ 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; - [MenuItem("Window/MCP for Unity")] + [MenuItem("Window/MCP For Unity")] public static void ShowWindow() { - GetWindow("MCP for Unity"); + GetWindow("MCP For Unity"); } private void OnEnable() @@ -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,16 +226,16 @@ 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", + "MCP For Unity", titleStyle ); @@ -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) { @@ -368,25 +368,25 @@ private void DrawServerStatusSection() } EditorGUILayout.Space(4); - // Repair Python Env button with tooltip tag + // Rebuild MCP Server button with tooltip tag using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); GUIContent repairLabel = new GUIContent( - "Repair Python Env", - "Deletes the server's .venv and runs 'uv sync' to rebuild a clean environment. Use this if modules are missing or Python upgraded." + "Rebuild MCP Server", + "Deletes the installed server and re-copies it from the package. Use this to update the server after making source code changes or if the installation is corrupted." ); if (GUILayout.Button(repairLabel, GUILayout.Width(160), GUILayout.Height(22))) { - bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RepairPythonEnvironment(); + bool ok = global::MCPForUnity.Editor.Helpers.ServerInstaller.RebuildMcpServer(); if (ok) { - EditorUtility.DisplayDialog("MCP for Unity", "Python environment repaired.", "OK"); + EditorUtility.DisplayDialog("MCP For Unity", "Server rebuilt successfully.", "OK"); UpdatePythonServerInstallationStatus(); } else { - EditorUtility.DisplayDialog("MCP for Unity", "Repair failed. Please check Console for details.", "OK"); + EditorUtility.DisplayDialog("MCP For Unity", "Rebuild failed. Please check Console for details.", "OK"); } } } @@ -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,325 +1099,58 @@ 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 string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) + // New method to show manual instructions without changing status + private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) { - // 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 }; - - // Read existing config if it exists - string existingJson = "{}"; - if (File.Exists(configPath)) - { - try - { - existingJson = File.ReadAllText(configPath); - } - catch (Exception e) - { - UnityEngine.Debug.LogWarning($"Error reading existing config: {e.Message}."); - } - } - - // Parse the existing JSON while preserving all properties - dynamic existingConfig; - try - { - if (string.IsNullOrWhiteSpace(existingJson)) - { - existingConfig = new Newtonsoft.Json.Linq.JObject(); - } - else - { - existingConfig = JsonConvert.DeserializeObject(existingJson) ?? new Newtonsoft.Json.Linq.JObject(); - } - } - catch + // 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) { - // If user has partial/invalid JSON (e.g., mid-edit), start from a fresh object - if (!string.IsNullOrWhiteSpace(existingJson)) - { - UnityEngine.Debug.LogWarning("UnityMCP: VSCode mcp.json could not be parsed; rewriting servers block."); - } - existingConfig = new Newtonsoft.Json.Linq.JObject(); + UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); + return; } - // 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; - else - existingRoot = JObject.FromObject(existingConfig); - - existingRoot = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(existingRoot, uvPath, serverSrc, mcpClient); - - string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - - McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); - - try - { - if (IsValidUv(uvPath)) UnityEditor.EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); - UnityEditor.EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); - } - catch { } - - return "Configured successfully"; - } - - private void ShowManualConfigurationInstructions( - string configPath, - McpClient mcpClient - ) - { - mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); - - ShowManualInstructionsWindow(configPath, mcpClient); + 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); } - // 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 string FindPackagePythonDirectory() { - string pythonDir = McpConfigFileHelper.ResolveServerSource(); - - try - { - // Only check dev paths if we're using a file-based package (development mode) - bool isDevelopmentMode = IsDevelopmentMode(); - if (isDevelopmentMode) - { - string currentPackagePath = Path.GetDirectoryName(Application.dataPath); - string[] devPaths = { - 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"))) - { - if (debugLogsEnabled) - { - UnityEngine.Debug.Log($"Currently in development mode. Package: {devPath}"); - } - return devPath; - } - } - } - - // 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) - { - UnityEngine.Debug.LogError($"Error finding package path: {e.Message}"); - } - - return pythonDir; + // Use shared helper for consistent path resolution across both windows + return McpPathResolver.FindPackagePythonDirectory(debugLogsEnabled); } - private bool IsDevelopmentMode() + private string ConfigureMcpClient(McpClient mcpClient) { try { - // Only treat as development if manifest explicitly references a local file path for the package - string manifestPath = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json"); - if (!File.Exists(manifestPath)) return false; + // Use shared helper for consistent config path resolution + string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); - string manifestContent = File.ReadAllText(manifestPath); - // Look specifically for our package dependency set to a file: URL - // This avoids auto-enabling dev mode just because a repo exists elsewhere on disk - if (manifestContent.IndexOf("\"com.justinpbarnett.unity-mcp\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - int idx = manifestContent.IndexOf("com.justinpbarnett.unity-mcp", StringComparison.OrdinalIgnoreCase); - // Crude but effective: check for "file:" in the same line/value - if (manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase) >= 0 - && manifestContent.IndexOf("\n", idx, StringComparison.OrdinalIgnoreCase) > manifestContent.IndexOf("file:", idx, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - return false; - } - catch - { - return false; - } - } + // Create directory if it doesn't exist + McpConfigurationHelper.EnsureConfigDirectoryExists(configPath); - private string ConfigureMcpClient(McpClient mcpClient) - { - try - { - // Determine the config file path based on OS - string configPath; + // Find the server.py file location using shared helper + string pythonDir = FindPackagePythonDirectory(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - configPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - configPath = mcpClient.linuxConfigPath; - } - else + if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) { - return "Unsupported OS"; + ShowManualInstructionsWindow(configPath, mcpClient); + return "Manual Configuration Required"; } - // 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(); - - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - ShowManualInstructionsWindow(configPath, mcpClient); - return "Manual Configuration Required"; - } + string result = mcpClient.mcpType == McpTypes.Codex + ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) + : McpConfigurationHelper.WriteMcpConfiguration(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,117 +1183,7 @@ 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 void ShowCursorManualConfigurationInstructions( - string configPath, - McpClient mcpClient - ) - { - mcpClient.SetStatus(McpStatus.Error, "Manual configuration required"); - - // Get the Python directory path using Package Manager API - string pythonDir = FindPackagePythonDirectory(); - - // Create the manual configuration message - string uvPath = FindUvPath(); - if (uvPath == null) - { - UnityEngine.Debug.LogError("UV package manager not found. Cannot configure manual setup."); - return; } - - McpConfig jsonConfig = new() - { - mcpServers = new McpConfigServers - { - unityMCP = new McpConfigServer - { - command = uvPath, - args = new[] { "run", "--directory", pythonDir, "server.py" }, - }, - }, - }; - - JsonSerializerSettings jsonSettings = new() { Formatting = Formatting.Indented }; - string manualConfigJson = JsonConvert.SerializeObject(jsonConfig, jsonSettings); - - ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient); } private void LoadValidationLevelSetting() @@ -1601,12 +1224,6 @@ private string GetValidationLevelDescription(int index) }; } - public static string GetCurrentValidationLevel() - { - string savedLevel = EditorPrefs.GetString("MCPForUnity_ScriptValidationLevel", "standard"); - return savedLevel; - } - private void CheckMcpConfiguration(McpClient mcpClient) { try @@ -1617,31 +1234,9 @@ private void CheckMcpConfiguration(McpClient mcpClient) CheckClaudeCodeConfiguration(mcpClient); return; } - - string configPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - configPath = mcpClient.windowsConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ) - { - configPath = string.IsNullOrEmpty(mcpClient.macConfigPath) - ? mcpClient.linuxConfigPath - : mcpClient.macConfigPath; - } - else if ( - RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ) - { - configPath = mcpClient.linuxConfigPath; - } - else - { - mcpClient.SetStatus(McpStatus.UnsupportedOS); - return; - } + + // Use shared helper for consistent config path resolution + string configPath = McpConfigurationHelper.GetClientConfigPath(mcpClient); if (!File.Exists(configPath)) { @@ -1652,42 +1247,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 +1290,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) } break; } - + // Common logic for checking configuration status if (configExists) { @@ -1711,8 +1306,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) try { string rewriteResult = mcpClient.mcpType == McpTypes.Codex - ? ConfigureCodexClient(pythonDir, configPath, mcpClient) - : WriteToConfig(pythonDir, configPath, mcpClient); + ? McpConfigurationHelper.ConfigureCodexClient(pythonDir, configPath, mcpClient) + : McpConfigurationHelper.WriteMcpConfiguration(pythonDir, configPath, mcpClient); if (rewriteResult == "Configured successfully") { if (debugLogsEnabled) @@ -1812,35 +1407,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 +1500,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 +1509,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 +1536,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 +1544,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 +1562,7 @@ private void CheckClaudeCodeConfiguration(McpClient mcpClient) } } } - + // No configuration found for this project mcpClient.SetStatus(McpStatus.NotConfigured); } @@ -2004,7 +1599,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/README.md b/UnityMcpBridge/README.md index b073a5fc..b26b9f19 100644 --- a/UnityMcpBridge/README.md +++ b/UnityMcpBridge/README.md @@ -29,7 +29,7 @@ The window has four areas: Server Status, Unity Bridge, MCP Client Configuration - Ports: Unity (varies; shown in UI), MCP 6500. - Actions: - Auto-Setup: Registers/updates your selected MCP client(s), ensures bridge connectivity. Shows “Connected ✓” after success. - - Repair Python Env: Rebuilds a clean Python environment (deletes `.venv`, runs `uv sync`). + - Rebuild MCP Server: Rebuilds the Python based MCP server - Select server folder…: Choose the folder containing `server.py`. - Verify again: Re-checks server presence. - If Python isn’t detected, use “Open Install Instructions”. 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/registry/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/registry/__init__.py new file mode 100644 index 00000000..5beb708b --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/registry/__init__.py @@ -0,0 +1,14 @@ +""" +Registry package for MCP tool auto-discovery. +""" +from .tool_registry import ( + mcp_for_unity_tool, + get_registered_tools, + clear_registry +) + +__all__ = [ + 'mcp_for_unity_tool', + 'get_registered_tools', + 'clear_registry' +] diff --git a/UnityMcpBridge/UnityMcpServer~/src/registry/tool_registry.py b/UnityMcpBridge/UnityMcpServer~/src/registry/tool_registry.py new file mode 100644 index 00000000..bbe36439 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/registry/tool_registry.py @@ -0,0 +1,51 @@ +""" +Tool registry for auto-discovery of MCP tools. +""" +from typing import Callable, Any + +# Global registry to collect decorated tools +_tool_registry: list[dict[str, Any]] = [] + + +def mcp_for_unity_tool( + name: str | None = None, + description: str | None = None, + **kwargs +) -> Callable: + """ + Decorator for registering MCP tools in the server's tools directory. + + Tools are registered in the global tool registry. + + Args: + name: Tool name (defaults to function name) + description: Tool description + **kwargs: Additional arguments passed to @mcp.tool() + + Example: + @mcp_for_unity_tool(description="Does something cool") + async def my_custom_tool(ctx: Context, ...): + pass + """ + def decorator(func: Callable) -> Callable: + tool_name = name if name is not None else func.__name__ + _tool_registry.append({ + 'func': func, + 'name': tool_name, + 'description': description, + 'kwargs': kwargs + }) + + return func + + return decorator + + +def get_registered_tools() -> list[dict[str, Any]]: + """Get all registered tools""" + return _tool_registry.copy() + + +def clear_registry(): + """Clear the tool registry (useful for testing)""" + _tool_registry.clear() 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/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index 5bf45f2e..6ede53d3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,35 +1,60 @@ +""" +MCP Tools package - Auto-discovers and registers all tools in this directory. +""" +import importlib import logging +from pathlib import Path +import pkgutil from mcp.server.fastmcp import FastMCP +from telemetry_decorator import telemetry_tool -from .manage_script_edits import register_manage_script_edits_tools -from .manage_script import register_manage_script_tools -from .manage_scene import register_manage_scene_tools -from .manage_editor import register_manage_editor_tools -from .manage_gameobject import register_manage_gameobject_tools -from .manage_asset import register_manage_asset_tools -from .manage_prefabs import register_manage_prefabs_tools -from .manage_shader import register_manage_shader_tools -from .read_console import register_read_console_tools -from .manage_menu_item import register_manage_menu_item_tools -from .resource_tools import register_resource_tools +from registry import get_registered_tools, mcp_for_unity_tool logger = logging.getLogger("mcp-for-unity-server") +# Export decorator for easy imports within tools +__all__ = ['register_all_tools', 'mcp_for_unity_tool'] + def register_all_tools(mcp: FastMCP): - """Register all refactored tools with the MCP server.""" - # Prefer the surgical edits tool so LLMs discover it first - logger.info("Registering MCP for Unity Server refactored tools...") - register_manage_script_edits_tools(mcp) - register_manage_script_tools(mcp) - register_manage_scene_tools(mcp) - register_manage_editor_tools(mcp) - register_manage_gameobject_tools(mcp) - register_manage_asset_tools(mcp) - register_manage_prefabs_tools(mcp) - register_manage_shader_tools(mcp) - register_read_console_tools(mcp) - register_manage_menu_item_tools(mcp) - register_resource_tools(mcp) - logger.info("MCP for Unity Server tool registration complete.") + """ + Auto-discover and register all tools in the tools/ directory. + + Any .py file in this directory with @mcp_for_unity_tool decorated + functions will be automatically registered. + """ + logger.info("Auto-discovering MCP for Unity Server tools...") + # Dynamic import of all modules in this directory + tools_dir = Path(__file__).parent + + for _, module_name, _ in pkgutil.iter_modules([str(tools_dir)]): + # Skip private modules and __init__ + if module_name.startswith('_'): + continue + + try: + importlib.import_module(f'.{module_name}', __package__) + except Exception as e: + logger.warning(f"Failed to import tool module {module_name}: {e}") + + tools = get_registered_tools() + + if not tools: + logger.warning("No MCP tools registered!") + return + + for tool_info in tools: + func = tool_info['func'] + tool_name = tool_info['name'] + description = tool_info['description'] + kwargs = tool_info['kwargs'] + + # Apply the @mcp.tool decorator and telemetry + wrapped = mcp.tool( + name=tool_name, description=description, **kwargs)(func) + wrapped = telemetry_tool(tool_name)(wrapped) + tool_info['func'] = wrapped + logger.info(f"Registered tool: {tool_name} - {description}") + + logger.info(f"Registered {len(tools)} MCP tools") diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index a442b422..5e21d2ce 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -4,83 +4,80 @@ import asyncio from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import async_send_command_with_retry -from telemetry_decorator import telemetry_tool - -def register_manage_asset_tools(mcp: FastMCP): - """Registers the manage_asset tool with the MCP server.""" - @mcp.tool(name="manage_asset", description="Performs asset operations (import, create, modify, delete, etc.) in Unity.") - @telemetry_tool("manage_asset") - async def manage_asset( - ctx: Context, - action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], - path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], - asset_type: Annotated[str, - "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, - properties: Annotated[dict[str, Any], - "Dictionary of properties for 'create'/'modify'."] | None = None, - destination: Annotated[str, - "Target path for 'duplicate'/'move'."] | None = None, - generate_preview: Annotated[bool, - "Generate a preview/thumbnail for the asset when supported."] = False, - search_pattern: Annotated[str, - "Search pattern (e.g., '*.prefab')."] | None = None, - filter_type: Annotated[str, "Filter type for search"] | None = None, - filter_date_after: Annotated[str, - "Date after which to filter"] | None = None, - page_size: Annotated[int, "Page size for pagination"] | None = None, - page_number: Annotated[int, "Page number for pagination"] | None = None - ) -> dict[str, Any]: - ctx.info(f"Processing manage_asset: {action}") - # Ensure properties is a dict if None - if properties is None: - properties = {} +@mcp_for_unity_tool( + description="Performs asset operations (import, create, modify, delete, etc.) in Unity." +) +async def manage_asset( + ctx: Context, + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], + path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], + asset_type: Annotated[str, + "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, + properties: Annotated[dict[str, Any], + "Dictionary of properties for 'create'/'modify'."] | None = None, + destination: Annotated[str, + "Target path for 'duplicate'/'move'."] | None = None, + generate_preview: Annotated[bool, + "Generate a preview/thumbnail for the asset when supported."] = False, + search_pattern: Annotated[str, + "Search pattern (e.g., '*.prefab')."] | None = None, + filter_type: Annotated[str, "Filter type for search"] | None = None, + filter_date_after: Annotated[str, + "Date after which to filter"] | None = None, + page_size: Annotated[int, "Page size for pagination"] | None = None, + page_number: Annotated[int, "Page number for pagination"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing manage_asset: {action}") + # Ensure properties is a dict if None + if properties is None: + properties = {} - # Coerce numeric inputs defensively - def _coerce_int(value, default=None): - if value is None: + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): return default + return int(float(s)) + except Exception: + return default - page_size = _coerce_int(page_size) - page_number = _coerce_int(page_number) + page_size = _coerce_int(page_size) + page_number = _coerce_int(page_number) - # Prepare parameters for the C# handler - params_dict = { - "action": action.lower(), - "path": path, - "assetType": asset_type, - "properties": properties, - "destination": destination, - "generatePreview": generate_preview, - "searchPattern": search_pattern, - "filterType": filter_type, - "filterDateAfter": filter_date_after, - "pageSize": page_size, - "pageNumber": page_number - } + # Prepare parameters for the C# handler + params_dict = { + "action": action.lower(), + "path": path, + "assetType": asset_type, + "properties": properties, + "destination": destination, + "generatePreview": generate_preview, + "searchPattern": search_pattern, + "filterType": filter_type, + "filterDateAfter": filter_date_after, + "pageSize": page_size, + "pageNumber": page_number + } - # Remove None values to avoid sending unnecessary nulls - params_dict = {k: v for k, v in params_dict.items() if v is not None} + # Remove None values to avoid sending unnecessary nulls + params_dict = {k: v for k, v in params_dict.items() if v is not None} - # Get the current asyncio event loop - loop = asyncio.get_running_loop() + # Get the current asyncio event loop + loop = asyncio.get_running_loop() - # Use centralized async retry helper to avoid blocking the event loop - result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) - # Return the result obtained from Unity - return result if isinstance(result, dict) else {"success": False, "message": str(result)} + # Use centralized async retry helper to avoid blocking the event loop + result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) + # Return the result obtained from Unity + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index 644209f7..c0de76c2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,60 +1,57 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from telemetry import is_telemetry_enabled, record_tool_usage - from unity_connection import send_command_with_retry -def register_manage_editor_tools(mcp: FastMCP): - """Register all editor management tools with the MCP server.""" - - @mcp.tool(name="manage_editor", description="Controls and queries the Unity editor's state and settings") - @telemetry_tool("manage_editor") - def manage_editor( - ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", - "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], - wait_for_completion: Annotated[bool, - "Optional. If True, waits for certain actions"] | None = None, - tool_name: Annotated[str, - "Tool name when setting active tool"] | None = None, - tag_name: Annotated[str, - "Tag name when adding and removing tags"] | None = None, - layer_name: Annotated[str, - "Layer name when adding and removing layers"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_editor: {action}") - try: - # Diagnostics: quick telemetry checks - if action == "telemetry_status": - return {"success": True, "telemetry_enabled": is_telemetry_enabled()} - - if action == "telemetry_ping": - record_tool_usage("diagnostic_ping", True, 1.0, None) - return {"success": True, "message": "telemetry ping queued"} - # Prepare parameters, removing None values - params = { - "action": action, - "waitForCompletion": wait_for_completion, - "toolName": tool_name, # Corrected parameter name to match C# - "tagName": tag_name, # Pass tag name - "layerName": layer_name, # Pass layer name - # Add other parameters based on the action being performed - # "width": width, - # "height": height, - # etc. - } - params = {k: v for k, v in params.items() if v is not None} - - # Send command using centralized retry helper - response = send_command_with_retry("manage_editor", params) - - # Preserve structured failure data; unwrap success into a friendlier shape - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} +@mcp_for_unity_tool( + description="Controls and queries the Unity editor's state and settings" +) +def manage_editor( + ctx: Context, + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], + wait_for_completion: Annotated[bool, + "Optional. If True, waits for certain actions"] | None = None, + tool_name: Annotated[str, + "Tool name when setting active tool"] | None = None, + tag_name: Annotated[str, + "Tag name when adding and removing tags"] | None = None, + layer_name: Annotated[str, + "Layer name when adding and removing layers"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_editor: {action}") + try: + # Diagnostics: quick telemetry checks + if action == "telemetry_status": + return {"success": True, "telemetry_enabled": is_telemetry_enabled()} + + if action == "telemetry_ping": + record_tool_usage("diagnostic_ping", True, 1.0, None) + return {"success": True, "message": "telemetry ping queued"} + # Prepare parameters, removing None values + params = { + "action": action, + "waitForCompletion": wait_for_completion, + "toolName": tool_name, # Corrected parameter name to match C# + "tagName": tag_name, # Pass tag name + "layerName": layer_name, # Pass layer name + # Add other parameters based on the action being performed + # "width": width, + # "height": height, + # etc. + } + params = {k: v for k, v in params.items() if v is not None} + + # Send command using centralized retry helper + response = send_command_with_retry("manage_editor", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing editor: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index 41d4a1c0..a8ca1609 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,148 +1,145 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -def register_manage_gameobject_tools(mcp: FastMCP): - """Register all GameObject management tools with the MCP server.""" - - @mcp.tool(name="manage_gameobject", description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data.") - @telemetry_tool("manage_gameobject") - def manage_gameobject( - ctx: Context, - action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], - target: Annotated[str, - "GameObject identifier by name or path for modify/delete/component actions"] | None = None, - search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], - "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, - name: Annotated[str, - "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, - tag: Annotated[str, - "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, - parent: Annotated[str, - "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, - position: Annotated[list[float], - "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, - rotation: Annotated[list[float], - "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, - scale: Annotated[list[float], - "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, - components_to_add: Annotated[list[str], - "List of component names to add"] | None = None, - primitive_type: Annotated[str, - "Primitive type for 'create' action"] | None = None, - save_as_prefab: Annotated[bool, - "If True, saves the created GameObject as a prefab"] | None = None, - prefab_path: Annotated[str, "Path for prefab creation"] | None = None, - prefab_folder: Annotated[str, - "Folder for prefab creation"] | None = None, - # --- Parameters for 'modify' --- - set_active: Annotated[bool, - "If True, sets the GameObject active"] | None = None, - layer: Annotated[str, "Layer name"] | None = None, - components_to_remove: Annotated[list[str], - "List of component names to remove"] | None = None, - component_properties: Annotated[dict[str, dict[str, Any]], - """Dictionary of component names to their properties to set. For example: - `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject - `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component - Example set nested property: - - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, - # --- Parameters for 'find' --- - search_term: Annotated[str, - "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, - find_all: Annotated[bool, - "If True, finds all GameObjects matching the search term"] | None = None, - search_in_children: Annotated[bool, - "If True, searches in children of the GameObject"] | None = None, - search_inactive: Annotated[bool, - "If True, searches inactive GameObjects"] | None = None, - # -- Component Management Arguments -- - component_name: Annotated[str, - "Component name for 'add_component' and 'remove_component' actions"] | None = None, - # Controls whether serialization of private [SerializeField] fields is included - includeNonPublicSerialized: Annotated[bool, - "Controls whether serialization of private [SerializeField] fields is included"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_gameobject: {action}") - try: - # Validate parameter usage to prevent silent failures - if action == "find": - if name is not None: - return { - "success": False, - "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" - } - if search_term is None: - return { - "success": False, - "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." - } +@mcp_for_unity_tool( + description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." +) +def manage_gameobject( + ctx: Context, + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components", "get_component"], "Perform CRUD operations on GameObjects and components."], + target: Annotated[str, + "GameObject identifier by name or path for modify/delete/component actions"] | None = None, + search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], + "How to find objects. Used with 'find' and some 'target' lookups."] | None = None, + name: Annotated[str, + "GameObject name for 'create' (initial name) and 'modify' (rename) actions ONLY. For 'find' action, use 'search_term' instead."] | None = None, + tag: Annotated[str, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, + parent: Annotated[str, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, + position: Annotated[list[float], + "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, + rotation: Annotated[list[float], + "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, + scale: Annotated[list[float], + "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, + components_to_add: Annotated[list[str], + "List of component names to add"] | None = None, + primitive_type: Annotated[str, + "Primitive type for 'create' action"] | None = None, + save_as_prefab: Annotated[bool, + "If True, saves the created GameObject as a prefab"] | None = None, + prefab_path: Annotated[str, "Path for prefab creation"] | None = None, + prefab_folder: Annotated[str, + "Folder for prefab creation"] | None = None, + # --- Parameters for 'modify' --- + set_active: Annotated[bool, + "If True, sets the GameObject active"] | None = None, + layer: Annotated[str, "Layer name"] | None = None, + components_to_remove: Annotated[list[str], + "List of component names to remove"] | None = None, + component_properties: Annotated[dict[str, dict[str, Any]], + """Dictionary of component names to their properties to set. For example: + `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject + `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component + Example set nested property: + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, + # --- Parameters for 'find' --- + search_term: Annotated[str, + "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, + find_all: Annotated[bool, + "If True, finds all GameObjects matching the search term"] | None = None, + search_in_children: Annotated[bool, + "If True, searches in children of the GameObject"] | None = None, + search_inactive: Annotated[bool, + "If True, searches inactive GameObjects"] | None = None, + # -- Component Management Arguments -- + component_name: Annotated[str, + "Component name for 'add_component' and 'remove_component' actions"] | None = None, + # Controls whether serialization of private [SerializeField] fields is included + includeNonPublicSerialized: Annotated[bool, + "Controls whether serialization of private [SerializeField] fields is included"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_gameobject: {action}") + try: + # Validate parameter usage to prevent silent failures + if action == "find": + if name is not None: + return { + "success": False, + "message": "For 'find' action, use 'search_term' parameter, not 'name'. Remove 'name' parameter. Example: search_term='Player', search_method='by_name'" + } + if search_term is None: + return { + "success": False, + "message": "For 'find' action, 'search_term' parameter is required. Use search_term (not 'name') to specify what to find." + } - if action in ["create", "modify"]: - if search_term is not None: - return { - "success": False, - "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." - } + if action in ["create", "modify"]: + if search_term is not None: + return { + "success": False, + "message": f"For '{action}' action, use 'name' parameter, not 'search_term'." + } - # Prepare parameters, removing None values - params = { - "action": action, - "target": target, - "searchMethod": search_method, - "name": name, - "tag": tag, - "parent": parent, - "position": position, - "rotation": rotation, - "scale": scale, - "componentsToAdd": components_to_add, - "primitiveType": primitive_type, - "saveAsPrefab": save_as_prefab, - "prefabPath": prefab_path, - "prefabFolder": prefab_folder, - "setActive": set_active, - "layer": layer, - "componentsToRemove": components_to_remove, - "componentProperties": component_properties, - "searchTerm": search_term, - "findAll": find_all, - "searchInChildren": search_in_children, - "searchInactive": search_inactive, - "componentName": component_name, - "includeNonPublicSerialized": includeNonPublicSerialized - } - params = {k: v for k, v in params.items() if v is not None} + # Prepare parameters, removing None values + params = { + "action": action, + "target": target, + "searchMethod": search_method, + "name": name, + "tag": tag, + "parent": parent, + "position": position, + "rotation": rotation, + "scale": scale, + "componentsToAdd": components_to_add, + "primitiveType": primitive_type, + "saveAsPrefab": save_as_prefab, + "prefabPath": prefab_path, + "prefabFolder": prefab_folder, + "setActive": set_active, + "layer": layer, + "componentsToRemove": components_to_remove, + "componentProperties": component_properties, + "searchTerm": search_term, + "findAll": find_all, + "searchInChildren": search_in_children, + "searchInactive": search_inactive, + "componentName": component_name, + "includeNonPublicSerialized": includeNonPublicSerialized + } + params = {k: v for k, v in params.items() if v is not None} - # --- Handle Prefab Path Logic --- - # Check if 'saveAsPrefab' is explicitly True in params - if action == "create" and params.get("saveAsPrefab"): - if "prefabPath" not in params: - if "name" not in params or not params["name"]: - return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} - # Use the provided prefab_folder (which has a default) and the name to construct the path - constructed_path = f"{prefab_folder}/{params['name']}.prefab" - # Ensure clean path separators (Unity prefers '/') - params["prefabPath"] = constructed_path.replace("\\", "/") - elif not params["prefabPath"].lower().endswith(".prefab"): - return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} - # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided - # The C# side only needs the final prefabPath - params.pop("prefabFolder", None) - # -------------------------------- + # --- Handle Prefab Path Logic --- + # Check if 'saveAsPrefab' is explicitly True in params + if action == "create" and params.get("saveAsPrefab"): + if "prefabPath" not in params: + if "name" not in params or not params["name"]: + return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} + # Use the provided prefab_folder (which has a default) and the name to construct the path + constructed_path = f"{prefab_folder}/{params['name']}.prefab" + # Ensure clean path separators (Unity prefers '/') + params["prefabPath"] = constructed_path.replace("\\", "/") + elif not params["prefabPath"].lower().endswith(".prefab"): + return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} + # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided + # The C# side only needs the final prefabPath + params.pop("prefabFolder", None) + # -------------------------------- - # Use centralized retry helper - response = send_command_with_retry("manage_gameobject", params) + # Use centralized retry helper + response = send_command_with_retry("manage_gameobject", params) - # Check if the response indicates success - # If the response is not successful, raise an exception with the error message - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + # Check if the response indicates success + # If the response is not successful, raise an exception with the error message + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "GameObject operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} - except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file + except Exception as e: + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index 3e7620a6..5463614d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -4,41 +4,38 @@ import asyncio from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import async_send_command_with_retry -def register_manage_menu_item_tools(mcp: FastMCP): - """Registers the manage_menu_item tool with the MCP server.""" - - @mcp.tool(name="manage_menu_item", description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.") - @telemetry_tool("manage_menu_item") - async def manage_menu_item( - ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."], - menu_path: Annotated[str, - "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, - search: Annotated[str, - "Optional filter string for 'list' (e.g., 'Save')"] | None = None, - refresh: Annotated[bool, - "Optional flag to force refresh of the menu cache when listing"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_menu_item: {action}") - # Prepare parameters for the C# handler - params_dict: dict[str, Any] = { - "action": action, - "menuPath": menu_path, - "search": search, - "refresh": refresh, - } - # Remove None values - params_dict = {k: v for k, v in params_dict.items() if v is not None} +@mcp_for_unity_tool( + description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'." +) +async def manage_menu_item( + ctx: Context, + action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."], + menu_path: Annotated[str, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, + search: Annotated[str, + "Optional filter string for 'list' (e.g., 'Save')"] | None = None, + refresh: Annotated[bool, + "Optional flag to force refresh of the menu cache when listing"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_menu_item: {action}") + # Prepare parameters for the C# handler + params_dict: dict[str, Any] = { + "action": action, + "menuPath": menu_path, + "search": search, + "refresh": refresh, + } + # Remove None values + params_dict = {k: v for k, v in params_dict.items() if v is not None} - # Get the current asyncio event loop - loop = asyncio.get_running_loop() + # Get the current asyncio event loop + loop = asyncio.get_running_loop() - # Use centralized async retry helper - result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) - return result if isinstance(result, dict) else {"success": False, "message": str(result)} + # Use centralized async retry helper + result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py index 7c65f28f..ea89201c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -1,61 +1,58 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -def register_manage_prefabs_tools(mcp: FastMCP) -> None: - """Register prefab management tools with the MCP server.""" - - @mcp.tool(name="manage_prefabs", description="Bridge for prefab management commands (stage control and creation).") - @telemetry_tool("manage_prefabs") - def manage_prefabs( - ctx: Context, - action: Annotated[Literal[ - "open_stage", - "close_stage", - "save_open_stage", - "create_from_gameobject", - ], "Manage prefabs (stage control and creation)."], - prefab_path: Annotated[str, - "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, - mode: Annotated[str, - "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, - save_before_close: Annotated[bool, - "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, - target: Annotated[str, - "Scene GameObject name required for create_from_gameobject"] | None = None, - allow_overwrite: Annotated[bool, - "Allow replacing an existing prefab at the same path"] | None = None, - search_inactive: Annotated[bool, - "Include inactive objects when resolving the target name"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_prefabs: {action}") - try: - params: dict[str, Any] = {"action": action} +@mcp_for_unity_tool( + description="Bridge for prefab management commands (stage control and creation)." +) +def manage_prefabs( + ctx: Context, + action: Annotated[Literal[ + "open_stage", + "close_stage", + "save_open_stage", + "create_from_gameobject", + ], "Manage prefabs (stage control and creation)."], + prefab_path: Annotated[str, + "Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None, + mode: Annotated[str, + "Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None, + save_before_close: Annotated[bool, + "When true, `close_stage` will save the prefab before exiting the stage."] | None = None, + target: Annotated[str, + "Scene GameObject name required for create_from_gameobject"] | None = None, + allow_overwrite: Annotated[bool, + "Allow replacing an existing prefab at the same path"] | None = None, + search_inactive: Annotated[bool, + "Include inactive objects when resolving the target name"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_prefabs: {action}") + try: + params: dict[str, Any] = {"action": action} - if prefab_path: - params["prefabPath"] = prefab_path - if mode: - params["mode"] = mode - if save_before_close is not None: - params["saveBeforeClose"] = bool(save_before_close) - if target: - params["target"] = target - if allow_overwrite is not None: - params["allowOverwrite"] = bool(allow_overwrite) - if search_inactive is not None: - params["searchInactive"] = bool(search_inactive) - response = send_command_with_retry("manage_prefabs", params) + if prefab_path: + params["prefabPath"] = prefab_path + if mode: + params["mode"] = mode + if save_before_close is not None: + params["saveBeforeClose"] = bool(save_before_close) + if target: + params["target"] = target + if allow_overwrite is not None: + params["allowOverwrite"] = bool(allow_overwrite) + if search_inactive is not None: + params["searchInactive"] = bool(search_inactive) + response = send_command_with_retry("manage_prefabs", params) - if isinstance(response, dict) and response.get("success"): - return { - "success": True, - "message": response.get("message", "Prefab operation successful."), - "data": response.get("data"), - } - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - except Exception as exc: - return {"success": False, "message": f"Python error managing prefabs: {exc}"} + if isinstance(response, dict) and response.get("success"): + return { + "success": True, + "message": response.get("message", "Prefab operation successful."), + "data": response.get("data"), + } + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + except Exception as exc: + return {"success": False, "message": f"Python error managing prefabs: {exc}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index fb5a1bca..09494e4a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,61 +1,56 @@ from typing import Annotated, Literal, Any -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -def register_manage_scene_tools(mcp: FastMCP): - """Register all scene management tools with the MCP server.""" - - @mcp.tool(name="manage_scene", description="Manage Unity scenes") - @telemetry_tool("manage_scene") - def manage_scene( - ctx: Context, - action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], - name: Annotated[str, - "Scene name. Not required get_active/get_build_settings"] | None = None, - path: Annotated[str, - "Asset path for scene operations (default: 'Assets/')"] | None = None, - build_index: Annotated[int, - "Build index for load/build settings actions"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_scene: {action}") - try: - # Coerce numeric inputs defensively - def _coerce_int(value, default=None): - if value is None: +@mcp_for_unity_tool(description="Manage Unity scenes") +def manage_scene( + ctx: Context, + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], + name: Annotated[str, + "Scene name. Not required get_active/get_build_settings"] | None = None, + path: Annotated[str, + "Asset path for scene operations (default: 'Assets/')"] | None = None, + build_index: Annotated[int, + "Build index for load/build settings actions"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_scene: {action}") + try: + # Coerce numeric inputs defensively + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): return default - - coerced_build_index = _coerce_int(build_index, default=None) - - params = {"action": action} - if name: - params["name"] = name - if path: - params["path"] = path - if coerced_build_index is not None: - params["buildIndex"] = coerced_build_index - - # Use centralized retry helper - response = send_command_with_retry("manage_scene", params) - - # Preserve structured failure data; unwrap success into a friendlier shape - if isinstance(response, dict) and response.get("success"): - return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} + return int(float(s)) + except Exception: + return default + + coerced_build_index = _coerce_int(build_index, default=None) + + params = {"action": action} + if name: + params["name"] = name + if path: + params["path"] = path + if coerced_build_index is not None: + params["buildIndex"] = coerced_build_index + + # Use centralized retry helper + response = send_command_with_retry("manage_scene", params) + + # Preserve structured failure data; unwrap success into a friendlier shape + if isinstance(response, dict) and response.get("success"): + return {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + return {"success": False, "message": f"Python error managing scene: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index fef1e92d..cad6a88c 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -5,561 +5,548 @@ from mcp.server.fastmcp import FastMCP, Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -try: - from telemetry_decorator import telemetry_tool - HAS_TELEMETRY = True -except ImportError: - HAS_TELEMETRY = False - - def telemetry_tool(tool_name: str): - def decorator(func): - return func - return decorator - - -def register_manage_script_tools(mcp: FastMCP): - """Register all script management tools with the MCP server.""" - - def _split_uri(uri: str) -> tuple[str, str]: - """Split an incoming URI or path into (name, directory) suitable for Unity. - - Rules: - - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) - - file://... → percent-decode, normalize, strip host and leading slashes, - then, if any 'Assets' segment exists, return path relative to that 'Assets' root. - Otherwise, fall back to original name/dir behavior. - - plain paths → decode/normalize separators; if they contain an 'Assets' segment, - return relative to 'Assets'. - """ - raw_path: str - if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/"):] - elif uri.startswith("file://"): - parsed = urlparse(uri) - host = (parsed.netloc or "").strip() - p = parsed.path or "" - # UNC: file://server/share/... -> //server/share/... - if host and host.lower() != "localhost": - p = f"//{host}{p}" - # Use percent-decoded path, preserving leading slashes - raw_path = unquote(p) - else: - raw_path = uri - - # Percent-decode any residual encodings and normalize separators - raw_path = unquote(raw_path).replace("\\", "/") - # Strip leading slash only for Windows drive-letter forms like "/C:/..." - if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": - raw_path = raw_path[1:] - - # Normalize path (collapse ../, ./) - norm = os.path.normpath(raw_path).replace("\\", "/") - - # If an 'Assets' segment exists, compute path relative to it (case-insensitive) - parts = [p for p in norm.split("/") if p not in ("", ".")] - idx = next((i for i, seg in enumerate(parts) - if seg.lower() == "assets"), None) - assets_rel = "/".join(parts[idx:]) if idx is not None else None - - effective_path = assets_rel if assets_rel else norm - # For POSIX absolute paths outside Assets, drop the leading '/' - # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). - if effective_path.startswith("/"): - effective_path = effective_path[1:] - - name = os.path.splitext(os.path.basename(effective_path))[0] - directory = os.path.dirname(effective_path) - return name, directory - - @mcp.tool(name="apply_text_edits", description=( - """Apply small text edits to a C# script identified by URI. - IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! - RECOMMENDED WORKFLOW: - 1. First call resources/read with start_line/line_count to verify exact content - 2. Count columns carefully (or use find_in_file to locate patterns) - 3. Apply your edit with precise coordinates - 4. Consider script_apply_edits with anchors for safer pattern-based replacements - Notes: - - For method/class operations, use script_apply_edits (safer, structured edits) - - For pattern-based replacements, consider anchor operations in script_apply_edits - - Lines, columns are 1-indexed - - Tabs count as 1 column""" - )) - @telemetry_tool("apply_text_edits") - def apply_text_edits( - ctx: Context, - uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], - edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], - precondition_sha256: Annotated[str, - "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, - strict: Annotated[bool, - "Optional strict flag, used to enforce strict mode"] | None = None, - options: Annotated[dict[str, Any], - "Optional options, used to pass additional options to the script editor"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing apply_text_edits: {uri}") - name, directory = _split_uri(uri) - # Normalize common aliases/misuses for resilience: - # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} - # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} - # If normalization is required, read current contents to map indices -> 1-based line/col. - def _needs_normalization(arr: list[dict[str, Any]]) -> bool: - for e in arr or []: - if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): - return True - return False - - normalized_edits: list[dict[str, Any]] = [] - warnings: list[str] = [] - if _needs_normalization(edits): - # Read file to support index->line/col conversion when needed - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": directory, - }) - if not (isinstance(read_resp, dict) and read_resp.get("success")): - return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - data = read_resp.get("data", {}) - contents = data.get("contents") - if not contents and data.get("contentsEncoded"): - try: - contents = base64.b64decode(data.get("encodedContents", "").encode( - "utf-8")).decode("utf-8", "replace") - except Exception: - contents = contents or "" - - # Helper to map 0-based character index to 1-based line/col - def line_col_from_index(idx: int) -> tuple[int, int]: - if idx <= 0: - return 1, 1 - # Count lines up to idx and position within line - nl_count = contents.count("\n", 0, idx) - line = nl_count + 1 - last_nl = contents.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 - return line, col - - for e in edits or []: - e2 = dict(e) - # Map text->newText if needed - if "newText" not in e2 and "text" in e2: - e2["newText"] = e2.pop("text") - - if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: - # Guard: explicit fields must be 1-based. - zero_based = False +def _split_uri(uri: str) -> tuple[str, str]: + """Split an incoming URI or path into (name, directory) suitable for Unity. + + Rules: + - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - file://... → percent-decode, normalize, strip host and leading slashes, + then, if any 'Assets' segment exists, return path relative to that 'Assets' root. + Otherwise, fall back to original name/dir behavior. + - plain paths → decode/normalize separators; if they contain an 'Assets' segment, + return relative to 'Assets'. + """ + raw_path: str + if uri.startswith("unity://path/"): + raw_path = uri[len("unity://path/"):] + elif uri.startswith("file://"): + parsed = urlparse(uri) + host = (parsed.netloc or "").strip() + p = parsed.path or "" + # UNC: file://server/share/... -> //server/share/... + if host and host.lower() != "localhost": + p = f"//{host}{p}" + # Use percent-decoded path, preserving leading slashes + raw_path = unquote(p) + else: + raw_path = uri + + # Percent-decode any residual encodings and normalize separators + raw_path = unquote(raw_path).replace("\\", "/") + # Strip leading slash only for Windows drive-letter forms like "/C:/..." + if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":": + raw_path = raw_path[1:] + + # Normalize path (collapse ../, ./) + norm = os.path.normpath(raw_path).replace("\\", "/") + + # If an 'Assets' segment exists, compute path relative to it (case-insensitive) + parts = [p for p in norm.split("/") if p not in ("", ".")] + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) + assets_rel = "/".join(parts[idx:]) if idx is not None else None + + effective_path = assets_rel if assets_rel else norm + # For POSIX absolute paths outside Assets, drop the leading '/' + # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp'). + if effective_path.startswith("/"): + effective_path = effective_path[1:] + + name = os.path.splitext(os.path.basename(effective_path))[0] + directory = os.path.dirname(effective_path) + return name, directory + + +@mcp_for_unity_tool(description=( + """Apply small text edits to a C# script identified by URI. + IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! + RECOMMENDED WORKFLOW: + 1. First call resources/read with start_line/line_count to verify exact content + 2. Count columns carefully (or use find_in_file to locate patterns) + 3. Apply your edit with precise coordinates + 4. Consider script_apply_edits with anchors for safer pattern-based replacements + Notes: + - For method/class operations, use script_apply_edits (safer, structured edits) + - For pattern-based replacements, consider anchor operations in script_apply_edits + - Lines, columns are 1-indexed + - Tabs count as 1 column""" +)) +def apply_text_edits( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], + precondition_sha256: Annotated[str, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, + strict: Annotated[bool, + "Optional strict flag, used to enforce strict mode"] | None = None, + options: Annotated[dict[str, Any], + "Optional options, used to pass additional options to the script editor"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing apply_text_edits: {uri}") + name, directory = _split_uri(uri) + + # Normalize common aliases/misuses for resilience: + # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} + # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} + # If normalization is required, read current contents to map indices -> 1-based line/col. + def _needs_normalization(arr: list[dict[str, Any]]) -> bool: + for e in arr or []: + if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): + return True + return False + + normalized_edits: list[dict[str, Any]] = [] + warnings: list[str] = [] + if _needs_normalization(edits): + # Read file to support index->line/col conversion when needed + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": directory, + }) + if not (isinstance(read_resp, dict) and read_resp.get("success")): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + data = read_resp.get("data", {}) + contents = data.get("contents") + if not contents and data.get("contentsEncoded"): + try: + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") + except Exception: + contents = contents or "" + + # Helper to map 0-based character index to 1-based line/col + def line_col_from_index(idx: int) -> tuple[int, int]: + if idx <= 0: + return 1, 1 + # Count lines up to idx and position within line + nl_count = contents.count("\n", 0, idx) + line = nl_count + 1 + last_nl = contents.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + return line, col + + for e in edits or []: + e2 = dict(e) + # Map text->newText if needed + if "newText" not in e2 and "text" in e2: + e2["newText"] = e2.pop("text") + + if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: + # Guard: explicit fields must be 1-based. + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): + try: + if int(e2.get(k, 1)) < 1: + zero_based = True + except Exception: + pass + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} + # Normalize by clamping to 1 and warn for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: - zero_based = True + e2[k] = 1 except Exception: pass - if zero_based: - if strict: - return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} - # Normalize by clamping to 1 and warn - for k in ("startLine", "startCol", "endLine", "endCol"): - try: - if int(e2.get(k, 1)) < 1: - e2[k] = 1 - except Exception: - pass - warnings.append( - "zero_based_explicit_fields_normalized") - normalized_edits.append(e2) - continue - - rng = e2.get("range") - if isinstance(rng, dict): - # LSP style: 0-based - s = rng.get("start", {}) - t = rng.get("end", {}) - e2["startLine"] = int(s.get("line", 0)) + 1 - e2["startCol"] = int(s.get("character", 0)) + 1 - e2["endLine"] = int(t.get("line", 0)) + 1 - e2["endCol"] = int(t.get("character", 0)) + 1 + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + continue + + rng = e2.get("range") + if isinstance(rng, dict): + # LSP style: 0-based + s = rng.get("start", {}) + t = rng.get("end", {}) + e2["startLine"] = int(s.get("line", 0)) + 1 + e2["startCol"] = int(s.get("character", 0)) + 1 + e2["endLine"] = int(t.get("line", 0)) + 1 + e2["endCol"] = int(t.get("character", 0)) + 1 + e2.pop("range", None) + normalized_edits.append(e2) + continue + if isinstance(rng, (list, tuple)) and len(rng) == 2: + try: + a = int(rng[0]) + b = int(rng[1]) + if b < a: + a, b = b, a + sl, sc = line_col_from_index(a) + el, ec = line_col_from_index(b) + e2["startLine"] = sl + e2["startCol"] = sc + e2["endLine"] = el + e2["endCol"] = ec e2.pop("range", None) normalized_edits.append(e2) continue - if isinstance(rng, (list, tuple)) and len(rng) == 2: + except Exception: + pass + # Could not normalize this edit + return { + "success": False, + "code": "missing_field", + "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", + "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} + } + else: + # Even when edits appear already in explicit form, validate 1-based coordinates. + normalized_edits = [] + for e in edits or []: + e2 = dict(e) + has_all = all(k in e2 for k in ( + "startLine", "startCol", "endLine", "endCol")) + if has_all: + zero_based = False + for k in ("startLine", "startCol", "endLine", "endCol"): try: - a = int(rng[0]) - b = int(rng[1]) - if b < a: - a, b = b, a - sl, sc = line_col_from_index(a) - el, ec = line_col_from_index(b) - e2["startLine"] = sl - e2["startCol"] = sc - e2["endLine"] = el - e2["endCol"] = ec - e2.pop("range", None) - normalized_edits.append(e2) - continue + if int(e2.get(k, 1)) < 1: + zero_based = True except Exception: pass - # Could not normalize this edit - return { - "success": False, - "code": "missing_field", - "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", - "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e} - } - else: - # Even when edits appear already in explicit form, validate 1-based coordinates. - normalized_edits = [] - for e in edits or []: - e2 = dict(e) - has_all = all(k in e2 for k in ( - "startLine", "startCol", "endLine", "endCol")) - if has_all: - zero_based = False + if zero_based: + if strict: + return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: - zero_based = True + e2[k] = 1 except Exception: pass - if zero_based: - if strict: - return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} - for k in ("startLine", "startCol", "endLine", "endCol"): - try: - if int(e2.get(k, 1)) < 1: - e2[k] = 1 - except Exception: - pass - if "zero_based_explicit_fields_normalized" not in warnings: - warnings.append( - "zero_based_explicit_fields_normalized") - normalized_edits.append(e2) - - # Preflight: detect overlapping ranges among normalized line/col spans - def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: - return ( - int(e.get("startLine", 1)) if key_start else int( - e.get("endLine", 1)), - int(e.get("startCol", 1)) if key_start else int( - e.get("endCol", 1)), - ) - - def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: - return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) - - # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. - spans = [] - for e in normalized_edits or []: - try: - s = _pos_tuple(e, True) - t = _pos_tuple(e, False) - if s != t: - spans.append((s, t)) - except Exception: - # If coordinates missing or invalid, let the server validate later - pass - - if spans: - spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) - for i in range(1, len(spans_sorted)): - prev_end = spans_sorted[i-1][1] - curr_start = spans_sorted[i][0] - # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start - if not _le(prev_end, curr_start): - conflicts = [{ - "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, - "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, - "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, - "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, - }] - return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} - - # Note: Do not auto-compute precondition if missing; callers should supply it - # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and - # preserves existing call-count expectations in clients/tests. - - # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance - opts: dict[str, Any] = dict(options or {}) + if "zero_based_explicit_fields_normalized" not in warnings: + warnings.append( + "zero_based_explicit_fields_normalized") + normalized_edits.append(e2) + + # Preflight: detect overlapping ranges among normalized line/col spans + def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: + return ( + int(e.get("startLine", 1)) if key_start else int( + e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int( + e.get("endCol", 1)), + ) + + def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: + return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1]) + + # Consider only true replace ranges (non-zero length). Pure insertions (zero-width) don't overlap. + spans = [] + for e in normalized_edits or []: try: - if len(normalized_edits) > 1 and "applyMode" not in opts: - opts["applyMode"] = "atomic" + s = _pos_tuple(e, True) + t = _pos_tuple(e, False) + if s != t: + spans.append((s, t)) except Exception: + # If coordinates missing or invalid, let the server validate later pass - # Support optional debug preview for span-by-span simulation without write - if opts.get("debug_preview"): - try: - import difflib - # Apply locally to preview final result - lines = [] - # Build an indexable original from a read if we normalized from read; otherwise skip - prev = "" - # We cannot guarantee file contents here without a read; return normalized spans only - return { - "success": True, - "message": "Preview only (no write)", - "data": { - "normalizedEdits": normalized_edits, - "preview": True - } - } - except Exception as e: - return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} - params = { - "action": "apply_text_edits", - "name": name, - "path": directory, - "edits": normalized_edits, - "precondition_sha256": precondition_sha256, - "options": opts, - } - params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict): - data = resp.setdefault("data", {}) - data.setdefault("normalizedEdits", normalized_edits) - if warnings: - data.setdefault("warnings", warnings) - if resp.get("success") and (options or {}).get("force_sentinel_reload"): - # Optional: flip sentinel via menu if explicitly requested - try: - import threading - import time - import json - import glob - import os + if spans: + spans_sorted = sorted(spans, key=lambda p: (p[0][0], p[0][1])) + for i in range(1, len(spans_sorted)): + prev_end = spans_sorted[i-1][1] + curr_start = spans_sorted[i][0] + # Overlap if prev_end > curr_start (strict), i.e., not prev_end <= curr_start + if not _le(prev_end, curr_start): + conflicts = [{ + "startA": {"line": spans_sorted[i-1][0][0], "col": spans_sorted[i-1][0][1]}, + "endA": {"line": spans_sorted[i-1][1][0], "col": spans_sorted[i-1][1][1]}, + "startB": {"line": spans_sorted[i][0][0], "col": spans_sorted[i][0][1]}, + "endB": {"line": spans_sorted[i][1][0], "col": spans_sorted[i][1][1]}, + }] + return {"success": False, "code": "overlap", "data": {"status": "overlap", "conflicts": conflicts}} + + # Note: Do not auto-compute precondition if missing; callers should supply it + # via mcp__unity__get_sha or a prior read. This avoids hidden extra calls and + # preserves existing call-count expectations in clients/tests. + + # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance + opts: dict[str, Any] = dict(options or {}) + try: + if len(normalized_edits) > 1 and "applyMode" not in opts: + opts["applyMode"] = "atomic" + except Exception: + pass + # Support optional debug preview for span-by-span simulation without write + if opts.get("debug_preview"): + try: + import difflib + # Apply locally to preview final result + lines = [] + # Build an indexable original from a read if we normalized from read; otherwise skip + prev = "" + # We cannot guarantee file contents here without a read; return normalized spans only + return { + "success": True, + "message": "Preview only (no write)", + "data": { + "normalizedEdits": normalized_edits, + "preview": True + } + } + except Exception as e: + return {"success": False, "code": "preview_failed", "message": f"debug_preview failed: {e}", "data": {"normalizedEdits": normalized_edits}} + + params = { + "action": "apply_text_edits", + "name": name, + "path": directory, + "edits": normalized_edits, + "precondition_sha256": precondition_sha256, + "options": opts, + } + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict): + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", normalized_edits) + if warnings: + data.setdefault("warnings", warnings) + if resp.get("success") and (options or {}).get("force_sentinel_reload"): + # Optional: flip sentinel via menu if explicitly requested + try: + import threading + import time + import json + import glob + import os - def _latest_status() -> dict | None: - try: - files = sorted(glob.glob(os.path.expanduser( - "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) - if not files: - return None - with open(files[0], "r") as f: - return json.loads(f.read()) - except Exception: + def _latest_status() -> dict | None: + try: + files = sorted(glob.glob(os.path.expanduser( + "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + if not files: return None + with open(files[0], "r") as f: + return json.loads(f.read()) + except Exception: + return None - def _flip_async(): - try: - time.sleep(0.1) - st = _latest_status() - if st and st.get("reloading"): - return - send_command_with_retry( - "execute_menu_item", - {"menuPath": "MCP/Flip Reload Sentinel"}, - max_retries=0, - retry_ms=0, - ) - except Exception: - pass - threading.Thread(target=_flip_async, daemon=True).start() - except Exception: - pass - return resp + def _flip_async(): + try: + time.sleep(0.1) + st = _latest_status() + if st and st.get("reloading"): + return + send_command_with_retry( + "execute_menu_item", + {"menuPath": "MCP/Flip Reload Sentinel"}, + max_retries=0, + retry_ms=0, + ) + except Exception: + pass + threading.Thread(target=_flip_async, daemon=True).start() + except Exception: + pass return resp - return {"success": False, "message": str(resp)} - - @mcp.tool(name="create_script", description=("Create a new C# script at the given project path.")) - @telemetry_tool("create_script") - def create_script( - ctx: Context, - path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], - contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], - script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, - namespace: Annotated[str, "Namespace for the script"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing create_script: {path}") - name = os.path.splitext(os.path.basename(path))[0] - directory = os.path.dirname(path) - # Local validation to avoid round-trips on obviously bad input - norm_path = os.path.normpath( - (path or "").replace("\\", "/")).replace("\\", "/") - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} - if ".." in norm_path.split("/") or norm_path.startswith("/"): - return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} - if not name: - return {"success": False, "code": "bad_path", "message": "path must include a script file name."} - if not norm_path.lower().endswith(".cs"): - return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} - params: dict[str, Any] = { - "action": "create", + return resp + return {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Create a new C# script at the given project path.")) +def create_script( + ctx: Context, + path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing create_script: {path}") + name = os.path.splitext(os.path.basename(path))[0] + directory = os.path.dirname(path) + # Local validation to avoid round-trips on obviously bad input + norm_path = os.path.normpath( + (path or "").replace("\\", "/")).replace("\\", "/") + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} + if ".." in norm_path.split("/") or norm_path.startswith("/"): + return {"success": False, "code": "bad_path", "message": "path must not contain traversal or be absolute."} + if not name: + return {"success": False, "code": "bad_path", "message": "path must include a script file name."} + if not norm_path.lower().endswith(".cs"): + return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} + params: dict[str, Any] = { + "action": "create", + "name": name, + "path": directory, + "namespace": namespace, + "scriptType": script_type, + } + if contents: + params["encodedContents"] = base64.b64encode( + contents.encode("utf-8")).decode("utf-8") + params["contentsEncoded"] = True + params = {k: v for k, v in params.items() if v is not None} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Delete a C# script by URI or Assets-relative path.")) +def delete_script( + ctx: Context, + uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + """Delete a C# script by URI.""" + ctx.info(f"Processing delete_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + params = {"action": "delete", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Validate a C# script and return diagnostics.")) +def validate_script( + ctx: Context, + uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + level: Annotated[Literal['basic', 'standard'], + "Validation level"] = "basic", + include_diagnostics: Annotated[bool, + "Include full diagnostics and summary"] = False +) -> dict[str, Any]: + ctx.info(f"Processing validate_script: {uri}") + name, directory = _split_uri(uri) + if not directory or directory.split("/")[0].lower() != "assets": + return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} + if level not in ("basic", "standard"): + return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} + params = { + "action": "validate", + "name": name, + "path": directory, + "level": level, + } + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + diags = resp.get("data", {}).get("diagnostics", []) or [] + warnings = sum(1 for d in diags if str( + d.get("severity", "")).lower() == "warning") + errors = sum(1 for d in diags if str( + d.get("severity", "")).lower() in ("error", "fatal")) + if include_diagnostics: + return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} + return {"success": True, "data": {"warnings": warnings, "errors": errors}} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + + +@mcp_for_unity_tool(description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) +def manage_script( + ctx: Context, + action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], + name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], + path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create", + "C# code for 'create'/'update'"] | None = None, + script_type: Annotated[str, "Script type (e.g., 'C#')", + "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_script: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, "name": name, - "path": directory, + "path": path, "namespace": namespace, "scriptType": script_type, } + + # Base64 encode the contents if they exist to avoid JSON escaping issues if contents: - params["encodedContents"] = base64.b64encode( - contents.encode("utf-8")).decode("utf-8") - params["contentsEncoded"] = True + if action == 'create': + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + params = {k: v for k, v in params.items() if v is not None} - resp = send_command_with_retry("manage_script", params) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(name="delete_script", description=("Delete a C# script by URI or Assets-relative path.")) - @telemetry_tool("delete_script") - def delete_script( - ctx: Context, - uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] - ) -> dict[str, Any]: - """Delete a C# script by URI.""" - ctx.info(f"Processing delete_script: {uri}") - name, directory = _split_uri(uri) - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} - params = {"action": "delete", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + response = send_command_with_retry("manage_script", params) - @mcp.tool(name="validate_script", description=("Validate a C# script and return diagnostics.")) - @telemetry_tool("validate_script") - def validate_script( - ctx: Context, - uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], - level: Annotated[Literal['basic', 'standard'], - "Validation level"] = "basic", - include_diagnostics: Annotated[bool, - "Include full diagnostics and summary"] = False - ) -> dict[str, Any]: - ctx.info(f"Processing validate_script: {uri}") - name, directory = _split_uri(uri) - if not directory or directory.split("/")[0].lower() != "assets": - return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} - if level not in ("basic", "standard"): - return {"success": False, "code": "bad_level", "message": "level must be 'basic' or 'standard'."} - params = { - "action": "validate", - "name": name, - "path": directory, - "level": level, - } - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict) and resp.get("success"): - diags = resp.get("data", {}).get("diagnostics", []) or [] - warnings = sum(1 for d in diags if str( - d.get("severity", "")).lower() == "warning") - errors = sum(1 for d in diags if str( - d.get("severity", "")).lower() in ("error", "fatal")) - if include_diagnostics: - return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} - return {"success": True, "data": {"warnings": warnings, "errors": errors}} - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + if isinstance(response, dict): + if response.get("success"): + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] - @mcp.tool(name="manage_script", description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) - @telemetry_tool("manage_script") - def manage_script( - ctx: Context, - action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], - name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], - path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], - contents: Annotated[str, "Contents of the script to create", - "C# code for 'create'/'update'"] | None = None, - script_type: Annotated[str, "Script type (e.g., 'C#')", - "Type hint (e.g., 'MonoBehaviour')"] | None = None, - namespace: Annotated[str, "Namespace for the script"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_script: {action}") - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - } + return { + "success": True, + "message": response.get("message", "Operation successful."), + "data": response.get("data"), + } + return response - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents: - if action == 'create': - params["encodedContents"] = base64.b64encode( - contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - params = {k: v for k, v in params.items() if v is not None} - - response = send_command_with_retry("manage_script", params) - - if isinstance(response, dict): - if response.get("success"): - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode( - response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return { - "success": True, - "message": response.get("message", "Operation successful."), - "data": response.get("data"), - } - return response - - return {"success": False, "message": str(response)} + return {"success": False, "message": str(response)} - except Exception as e: - return { - "success": False, - "message": f"Python error managing script: {str(e)}", - } + except Exception as e: + return { + "success": False, + "message": f"Python error managing script: {str(e)}", + } - @mcp.tool(name="manage_script_capabilities", description=( - """Get manage_script capabilities (supported ops, limits, and guards). - Returns: - - ops: list of supported structured ops - - text_ops: list of supported text ops - - max_edit_payload_bytes: server edit payload cap - - guards: header/using guard enabled flag""" - )) - @telemetry_tool("manage_script_capabilities") - def manage_script_capabilities(ctx: Context) -> dict[str, Any]: - ctx.info("Processing manage_script_capabilities") - try: - # Keep in sync with server/Editor ManageScript implementation - ops = [ - "replace_class", "delete_class", "replace_method", "delete_method", - "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" - ] - text_ops = ["replace_range", "regex_replace", "prepend", "append"] - # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback - max_edit_payload_bytes = 256 * 1024 - guards = {"using_guard": True} - extras = {"get_sha": True} - return {"success": True, "data": { - "ops": ops, - "text_ops": text_ops, - "max_edit_payload_bytes": max_edit_payload_bytes, - "guards": guards, - "extras": extras, - }} - except Exception as e: - return {"success": False, "error": f"capabilities error: {e}"} - - @mcp.tool(name="get_sha", description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") - @telemetry_tool("get_sha") - def get_sha( - ctx: Context, - uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] - ) -> dict[str, Any]: - ctx.info(f"Processing get_sha: {uri}") - try: - name, directory = _split_uri(uri) - params = {"action": "get_sha", "name": name, "path": directory} - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict) and resp.get("success"): - data = resp.get("data", {}) - minimal = {"sha256": data.get( - "sha256"), "lengthBytes": data.get("lengthBytes")} - return {"success": True, "data": minimal} - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - except Exception as e: - return {"success": False, "message": f"get_sha error: {e}"} + +@mcp_for_unity_tool(description=( + """Get manage_script capabilities (supported ops, limits, and guards). + Returns: + - ops: list of supported structured ops + - text_ops: list of supported text ops + - max_edit_payload_bytes: server edit payload cap + - guards: header/using guard enabled flag""" +)) +def manage_script_capabilities(ctx: Context) -> dict[str, Any]: + ctx.info("Processing manage_script_capabilities") + try: + # Keep in sync with server/Editor ManageScript implementation + ops = [ + "replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_insert", "anchor_delete", "anchor_replace" + ] + text_ops = ["replace_range", "regex_replace", "prepend", "append"] + # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback + max_edit_payload_bytes = 256 * 1024 + guards = {"using_guard": True} + extras = {"get_sha": True} + return {"success": True, "data": { + "ops": ops, + "text_ops": text_ops, + "max_edit_payload_bytes": max_edit_payload_bytes, + "guards": guards, + "extras": extras, + }} + except Exception as e: + return {"success": False, "error": f"capabilities error: {e}"} + + +@mcp_for_unity_tool(description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") +def get_sha( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] +) -> dict[str, Any]: + ctx.info(f"Processing get_sha: {uri}") + try: + name, directory = _split_uri(uri) + params = {"action": "get_sha", "name": name, "path": directory} + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + data = resp.get("data", {}) + minimal = {"sha256": data.get( + "sha256"), "lengthBytes": data.get("lengthBytes")} + return {"success": True, "data": minimal} + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + except Exception as e: + return {"success": False, "message": f"get_sha error: {e}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py deleted file mode 100644 index 261eb502..00000000 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ /dev/null @@ -1,968 +0,0 @@ -import base64 -import hashlib -import re -from typing import Annotated, Any - -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - -from unity_connection import send_command_with_retry - - -def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: - text = original_text - for edit in edits or []: - op = ( - (edit.get("op") - or edit.get("operation") - or edit.get("type") - or edit.get("mode") - or "") - .strip() - .lower() - ) - - if not op: - allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError( - f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." - ) - - if op == "prepend": - prepend_text = edit.get("text", "") - text = (prepend_text if prepend_text.endswith( - "\n") else prepend_text + "\n") + text - elif op == "append": - append_text = edit.get("text", "") - if not text.endswith("\n"): - text += "\n" - text += append_text - if not text.endswith("\n"): - text += "\n" - elif op == "anchor_insert": - anchor = edit.get("anchor", "") - position = (edit.get("position") or "before").lower() - insert_text = edit.get("text", "") - flags = re.MULTILINE | ( - re.IGNORECASE if edit.get("ignore_case") else 0) - - # Find the best match using improved heuristics - match = _find_best_anchor_match( - anchor, text, flags, bool(edit.get("prefer_last", True))) - if not match: - if edit.get("allow_noop", True): - continue - raise RuntimeError(f"anchor not found: {anchor}") - idx = match.start() if position == "before" else match.end() - text = text[:idx] + insert_text + text[idx:] - elif op == "replace_range": - start_line = int(edit.get("startLine", 1)) - start_col = int(edit.get("startCol", 1)) - end_line = int(edit.get("endLine", start_line)) - end_col = int(edit.get("endCol", 1)) - replacement = edit.get("text", "") - lines = text.splitlines(keepends=True) - max_line = len(lines) + 1 # 1-based, exclusive end - if (start_line < 1 or end_line < start_line or end_line > max_line - or start_col < 1 or end_col < 1): - raise RuntimeError("replace_range out of bounds") - - def index_of(line: int, col: int) -> int: - if line <= len(lines): - return sum(len(l) for l in lines[: line - 1]) + (col - 1) - return sum(len(l) for l in lines) - a = index_of(start_line, start_col) - b = index_of(end_line, end_col) - text = text[:a] + replacement + text[b:] - elif op == "regex_replace": - pattern = edit.get("pattern", "") - repl = edit.get("replacement", "") - # Translate $n backrefs (our input) to Python \g - repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) - count = int(edit.get("count", 0)) # 0 = replace all - flags = re.MULTILINE - if edit.get("ignore_case"): - flags |= re.IGNORECASE - text = re.sub(pattern, repl_py, text, count=count, flags=flags) - else: - allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError( - f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") - return text - - -def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): - """ - Find the best anchor match using improved heuristics. - - For patterns like \\s*}\\s*$ that are meant to find class-ending braces, - this function uses heuristics to choose the most semantically appropriate match: - - 1. If prefer_last=True, prefer the last match (common for class-end insertions) - 2. Use indentation levels to distinguish class vs method braces - 3. Consider context to avoid matches inside strings/comments - - Args: - pattern: Regex pattern to search for - text: Text to search in - flags: Regex flags - prefer_last: If True, prefer the last match over the first - - Returns: - Match object of the best match, or None if no match found - """ - - # Find all matches - matches = list(re.finditer(pattern, text, flags)) - if not matches: - return None - - # If only one match, return it - if len(matches) == 1: - return matches[0] - - # For patterns that look like they're trying to match closing braces at end of lines - is_closing_brace_pattern = '}' in pattern and ( - '$' in pattern or pattern.endswith(r'\s*')) - - if is_closing_brace_pattern and prefer_last: - # Use heuristics to find the best closing brace match - return _find_best_closing_brace_match(matches, text) - - # Default behavior: use last match if prefer_last, otherwise first match - return matches[-1] if prefer_last else matches[0] - - -def _find_best_closing_brace_match(matches, text: str): - """ - Find the best closing brace match using C# structure heuristics. - - Enhanced heuristics for scope-aware matching: - 1. Prefer matches with lower indentation (likely class-level) - 2. Prefer matches closer to end of file - 3. Avoid matches that seem to be inside method bodies - 4. For #endregion patterns, ensure class-level context - 5. Validate insertion point is at appropriate scope - - Args: - matches: List of regex match objects - text: The full text being searched - - Returns: - The best match object - """ - if not matches: - return None - - scored_matches = [] - lines = text.splitlines() - - for match in matches: - score = 0 - start_pos = match.start() - - # Find which line this match is on - lines_before = text[:start_pos].count('\n') - line_num = lines_before - - if line_num < len(lines): - line_content = lines[line_num] - - # Calculate indentation level (lower is better for class braces) - indentation = len(line_content) - len(line_content.lstrip()) - - # Prefer lower indentation (class braces are typically less indented than method braces) - # Max 20 points for indentation=0 - score += max(0, 20 - indentation) - - # Prefer matches closer to end of file (class closing braces are typically at the end) - distance_from_end = len(lines) - line_num - # More points for being closer to end - score += max(0, 10 - distance_from_end) - - # Look at surrounding context to avoid method braces - context_start = max(0, line_num - 3) - context_end = min(len(lines), line_num + 2) - context_lines = lines[context_start:context_end] - - # Penalize if this looks like it's inside a method (has method-like patterns above) - for context_line in context_lines: - if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): - score -= 5 # Penalty for being near method signatures - - # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) - if indentation <= 4 and distance_from_end <= 3: - score += 15 # Bonus for likely class-ending brace - - scored_matches.append((score, match)) - - # Return the match with the highest score - scored_matches.sort(key=lambda x: x[0], reverse=True) - best_match = scored_matches[0][1] - - return best_match - - -def _infer_class_name(script_name: str) -> str: - # Default to script name as class name (common Unity pattern) - return (script_name or "").strip() - - -def _extract_code_after(keyword: str, request: str) -> str: - # Deprecated with NL removal; retained as no-op for compatibility - idx = request.lower().find(keyword) - if idx >= 0: - return request[idx + len(keyword):].strip() - return "" -# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services - - -def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: - """Best-effort normalization of script "name" and "path". - - Accepts any of: - - name = "SmartReach", path = "Assets/Scripts/Interaction" - - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" - - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" - - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) - - name or path using uri prefixes: unity://path/..., file://... - - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" - - Returns (name_without_extension, directory_path_under_Assets). - """ - n = (name or "").strip() - p = (path or "").strip() - - def strip_prefix(s: str) -> str: - if s.startswith("unity://path/"): - return s[len("unity://path/"):] - if s.startswith("file://"): - return s[len("file://"):] - return s - - def collapse_duplicate_tail(s: str) -> str: - # Collapse trailing "/X.cs/X.cs" to "/X.cs" - parts = s.split("/") - if len(parts) >= 2 and parts[-1] == parts[-2]: - parts = parts[:-1] - return "/".join(parts) - - # Prefer a full path if provided in either field - candidate = "" - for v in (n, p): - v2 = strip_prefix(v) - if v2.endswith(".cs") or v2.startswith("Assets/"): - candidate = v2 - break - - if candidate: - candidate = collapse_duplicate_tail(candidate) - # If a directory was passed in path and file in name, join them - if not candidate.endswith(".cs") and n.endswith(".cs"): - v2 = strip_prefix(n) - candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) - if candidate.endswith(".cs"): - parts = candidate.split("/") - file_name = parts[-1] - dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" - base = file_name[:- - 3] if file_name.lower().endswith(".cs") else file_name - return base, dir_path - - # Fall back: remove extension from name if present and return given path - base_name = n[:-3] if n.lower().endswith(".cs") else n - return base_name, (p or "Assets") - - -def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: - if not isinstance(resp, dict): - return resp - data = resp.setdefault("data", {}) - data.setdefault("normalizedEdits", edits) - if routing: - data["routing"] = routing - return resp - - -def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, - normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: - payload: dict[str, Any] = {"success": False, - "code": code, "message": message} - data: dict[str, Any] = {} - if expected: - data["expected"] = expected - if rewrite: - data["rewrite_suggestion"] = rewrite - if normalized is not None: - data["normalizedEdits"] = normalized - if routing: - data["routing"] = routing - if extra: - data.update(extra) - if data: - payload["data"] = data - return payload - -# Natural-language parsing removed; clients should send structured edits. - - -def register_manage_script_edits_tools(mcp: FastMCP): - @mcp.tool(name="script_apply_edits", description=( - """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. - Best practices: - - Prefer anchor_* ops for pattern-based insert/replace near stable markers - - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) - - Avoid whole-file regex deletes; validators will guard unbalanced braces - - For tail insertions, prefer anchor/regex_replace on final brace (class closing) - - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits - Canonical fields (use these exact keys): - - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace - - className: string (defaults to 'name' if omitted on method/class ops) - - methodName: string (required for replace_method, delete_method) - - replacement: string (required for replace_method, insert_method) - - position: start | end | after | before (insert_method only) - - afterMethodName / beforeMethodName: string (required when position='after'/'before') - - anchor: regex string (for anchor_* ops) - - text: string (for anchor_insert/anchor_replace) - Examples: - 1) Replace a method: - { - "name": "SmartReach", - "path": "Assets/Scripts/Interaction", - "edits": [ - { - "op": "replace_method", - "className": "SmartReach", - "methodName": "HasTarget", - "replacement": "public bool HasTarget(){ return currentTarget!=null; }" - } - ], - "options": {"validate": "standard", "refresh": "immediate"} - } - "2) Insert a method after another: - { - "name": "SmartReach", - "path": "Assets/Scripts/Interaction", - "edits": [ - { - "op": "insert_method", - "className": "SmartReach", - "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", - "position": "after", - "afterMethodName": "GetCurrentTarget" - } - ], - } - ]""" - )) - @telemetry_tool("script_apply_edits") - def script_apply_edits( - ctx: Context, - name: Annotated[str, "Name of the script to edit"], - path: Annotated[str, "Path to the script to edit under Assets/ directory"], - edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], - options: Annotated[dict[str, Any], - "Options for the script edit"] | None = None, - script_type: Annotated[str, - "Type of the script to edit"] = "MonoBehaviour", - namespace: Annotated[str, - "Namespace of the script to edit"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing script_apply_edits: {name}") - # Normalize locator first so downstream calls target the correct script file. - name, path = _normalize_script_locator(name, path) - # Normalize unsupported or aliased ops to known structured/text paths - - def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: - # Unwrap single-key wrappers like {"replace_method": {...}} - for wrapper_key in ( - "replace_method", "insert_method", "delete_method", - "replace_class", "delete_class", - "anchor_insert", "anchor_replace", "anchor_delete", - ): - if wrapper_key in edit and isinstance(edit[wrapper_key], dict): - inner = dict(edit[wrapper_key]) - inner["op"] = wrapper_key - edit = inner - break - - e = dict(edit) - op = (e.get("op") or e.get("operation") or e.get( - "type") or e.get("mode") or "").strip().lower() - if op: - e["op"] = op - - # Common field aliases - if "class_name" in e and "className" not in e: - e["className"] = e.pop("class_name") - if "class" in e and "className" not in e: - e["className"] = e.pop("class") - if "method_name" in e and "methodName" not in e: - e["methodName"] = e.pop("method_name") - # Some clients use a generic 'target' for method name - if "target" in e and "methodName" not in e: - e["methodName"] = e.pop("target") - if "method" in e and "methodName" not in e: - e["methodName"] = e.pop("method") - if "new_content" in e and "replacement" not in e: - e["replacement"] = e.pop("new_content") - if "newMethod" in e and "replacement" not in e: - e["replacement"] = e.pop("newMethod") - if "new_method" in e and "replacement" not in e: - e["replacement"] = e.pop("new_method") - if "content" in e and "replacement" not in e: - e["replacement"] = e.pop("content") - if "after" in e and "afterMethodName" not in e: - e["afterMethodName"] = e.pop("after") - if "after_method" in e and "afterMethodName" not in e: - e["afterMethodName"] = e.pop("after_method") - if "before" in e and "beforeMethodName" not in e: - e["beforeMethodName"] = e.pop("before") - if "before_method" in e and "beforeMethodName" not in e: - e["beforeMethodName"] = e.pop("before_method") - # anchor_method → before/after based on position (default after) - if "anchor_method" in e: - anchor = e.pop("anchor_method") - pos = (e.get("position") or "after").strip().lower() - if pos == "before" and "beforeMethodName" not in e: - e["beforeMethodName"] = anchor - elif "afterMethodName" not in e: - e["afterMethodName"] = anchor - if "anchorText" in e and "anchor" not in e: - e["anchor"] = e.pop("anchorText") - if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): - e["anchor"] = e.pop("pattern") - if "newText" in e and "text" not in e: - e["text"] = e.pop("newText") - - # CI compatibility (T‑A/T‑E): - # Accept method-anchored anchor_insert and upgrade to insert_method - # Example incoming shape: - # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} - if ( - e.get("op") == "anchor_insert" - and not e.get("anchor") - and (e.get("afterMethodName") or e.get("beforeMethodName")) - ): - e["op"] = "insert_method" - if "replacement" not in e: - e["replacement"] = e.get("text", "") - - # LSP-like range edit -> replace_range - if "range" in e and isinstance(e["range"], dict): - rng = e.pop("range") - start = rng.get("start", {}) - end = rng.get("end", {}) - # Convert 0-based to 1-based line/col - e["op"] = "replace_range" - e["startLine"] = int(start.get("line", 0)) + 1 - e["startCol"] = int(start.get("character", 0)) + 1 - e["endLine"] = int(end.get("line", 0)) + 1 - e["endCol"] = int(end.get("character", 0)) + 1 - if "newText" in edit and "text" not in e: - e["text"] = edit.get("newText", "") - return e - - normalized_edits: list[dict[str, Any]] = [] - for raw in edits or []: - e = _unwrap_and_alias(raw) - op = (e.get("op") or e.get("operation") or e.get( - "type") or e.get("mode") or "").strip().lower() - - # Default className to script name if missing on structured method/class ops - if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): - e["className"] = name - - # Map common aliases for text ops - if op in ("text_replace",): - e["op"] = "replace_range" - normalized_edits.append(e) - continue - if op in ("regex_delete",): - e["op"] = "regex_replace" - e.setdefault("text", "") - normalized_edits.append(e) - continue - if op == "regex_replace" and ("replacement" not in e): - if "text" in e: - e["replacement"] = e.get("text", "") - elif "insert" in e or "content" in e: - e["replacement"] = e.get( - "insert") or e.get("content") or "" - if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): - e["op"] = "anchor_delete" - normalized_edits.append(e) - continue - normalized_edits.append(e) - - edits = normalized_edits - normalized_for_echo = edits - - # Validate required fields and produce machine-parsable hints - def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: - return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) - - for e in edits or []: - op = e.get("op", "") - if op == "replace_method": - if not e.get("methodName"): - return error_with_hint( - "replace_method requires 'methodName'.", - {"op": "replace_method", "required": [ - "className", "methodName", "replacement"]}, - {"edits[0].methodName": "HasTarget"} - ) - if not (e.get("replacement") or e.get("text")): - return error_with_hint( - "replace_method requires 'replacement' (inline or base64).", - {"op": "replace_method", "required": [ - "className", "methodName", "replacement"]}, - {"edits[0].replacement": "public bool X(){ return true; }"} - ) - elif op == "insert_method": - if not (e.get("replacement") or e.get("text")): - return error_with_hint( - "insert_method requires a non-empty 'replacement'.", - {"op": "insert_method", "required": ["className", "replacement"], "position": { - "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, - {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} - ) - pos = (e.get("position") or "").lower() - if pos == "after" and not e.get("afterMethodName"): - return error_with_hint( - "insert_method with position='after' requires 'afterMethodName'.", - {"op": "insert_method", "position": { - "after_requires": "afterMethodName"}}, - {"edits[0].afterMethodName": "GetCurrentTarget"} - ) - if pos == "before" and not e.get("beforeMethodName"): - return error_with_hint( - "insert_method with position='before' requires 'beforeMethodName'.", - {"op": "insert_method", "position": { - "before_requires": "beforeMethodName"}}, - {"edits[0].beforeMethodName": "GetCurrentTarget"} - ) - elif op == "delete_method": - if not e.get("methodName"): - return error_with_hint( - "delete_method requires 'methodName'.", - {"op": "delete_method", "required": [ - "className", "methodName"]}, - {"edits[0].methodName": "PrintSeries"} - ) - elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): - if not e.get("anchor"): - return error_with_hint( - f"{op} requires 'anchor' (regex).", - {"op": op, "required": ["anchor"]}, - {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} - ) - if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): - return error_with_hint( - f"{op} requires 'text'.", - {"op": op, "required": ["anchor", "text"]}, - {"edits[0].text": "/* comment */\n"} - ) - - # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", - "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} - TEXT = {"prepend", "append", "replace_range", "regex_replace"} - ops_set = {(e.get("op") or "").lower() for e in edits or []} - all_struct = ops_set.issubset(STRUCT) - all_text = ops_set.issubset(TEXT) - mixed = not (all_struct or all_text) - - # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. - if all_struct: - opts2 = dict(options or {}) - # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused - opts2.setdefault("refresh", "immediate") - params_struct: dict[str, Any] = { - "action": "edit", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": edits, - "options": opts2, - } - resp_struct = send_command_with_retry( - "manage_script", params_struct) - if isinstance(resp_struct, dict) and resp_struct.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") - - # 1) read from Unity - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - }) - if not isinstance(read_resp, dict) or not read_resp.get("success"): - return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - - data = read_resp.get("data") or read_resp.get( - "result", {}).get("data") or {} - contents = data.get("contents") - if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): - contents = base64.b64decode( - data["encodedContents"]).decode("utf-8") - if contents is None: - return {"success": False, "message": "No contents returned from Unity read."} - - # Optional preview/dry-run: apply locally and return diff without writing - preview = bool((options or {}).get("preview")) - - # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured - if mixed: - text_edits = [e for e in edits or [] if ( - e.get("op") or "").lower() in TEXT] - struct_edits = [e for e in edits or [] if ( - e.get("op") or "").lower() in STRUCT] - try: - base_text = contents - - def line_col_from_index(idx: int) -> tuple[int, int]: - line = base_text.count("\n", 0, idx) + 1 - last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + \ - 1 if last_nl >= 0 else idx + 1 - return line, col - - at_edits: list[dict[str, Any]] = [] - for e in text_edits: - opx = (e.get("op") or e.get("operation") or e.get( - "type") or e.get("mode") or "").strip().lower() - text_field = e.get("text") or e.get("insert") or e.get( - "content") or e.get("replacement") or "" - if opx == "anchor_insert": - anchor = e.get("anchor") or "" - position = (e.get("position") or "after").lower() - flags = re.MULTILINE | ( - re.IGNORECASE if e.get("ignore_case") else 0) - try: - # Use improved anchor matching logic - m = _find_best_anchor_match( - anchor, base_text, flags, prefer_last=True) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") - if not m: - return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") - idx = m.start() if position == "before" else m.end() - # Normalize insertion to avoid jammed methods - text_field_norm = text_field - if not text_field_norm.startswith("\n"): - text_field_norm = "\n" + text_field_norm - if not text_field_norm.endswith("\n"): - text_field_norm = text_field_norm + "\n" - sl, sc = line_col_from_index(idx) - at_edits.append( - {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) - # do not mutate base_text when building atomic spans - elif opx == "replace_range": - if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): - at_edits.append({ - "startLine": int(e.get("startLine", 1)), - "startCol": int(e.get("startCol", 1)), - "endLine": int(e.get("endLine", 1)), - "endCol": int(e.get("endCol", 1)), - "newText": text_field - }) - else: - return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - elif opx == "regex_replace": - pattern = e.get("pattern") or "" - try: - regex_obj = re.compile(pattern, re.MULTILINE | ( - re.IGNORECASE if e.get("ignore_case") else 0)) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") - m = regex_obj.search(base_text) - if not m: - continue - # Expand $1, $2... in replacement using this match - - def _expand_dollars(rep: str, _m=m) -> str: - return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) - repl = _expand_dollars(text_field) - sl, sc = line_col_from_index(m.start()) - el, ec = line_col_from_index(m.end()) - at_edits.append( - {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) - # do not mutate base_text when building atomic spans - elif opx in ("prepend", "append"): - if opx == "prepend": - sl, sc = 1, 1 - at_edits.append( - {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) - # prepend can be applied atomically without local mutation - else: - # Insert at true EOF position (handles both \n and \r\n correctly) - eof_idx = len(base_text) - sl, sc = line_col_from_index(eof_idx) - new_text = ("\n" if not base_text.endswith( - "\n") else "") + text_field - at_edits.append( - {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) - # do not mutate base_text when building atomic spans - else: - return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - - sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - if at_edits: - params_text: dict[str, Any] = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": at_edits, - "precondition_sha256": sha, - "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} - } - resp_text = send_command_with_retry( - "manage_script", params_text) - if not (isinstance(resp_text, dict) and resp_text.get("success")): - return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") - # Optional sentinel reload removed (deprecated) - except Exception as e: - return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") - - if struct_edits: - opts2 = dict(options or {}) - # Prefer debounced background refresh unless explicitly overridden - opts2.setdefault("refresh", "debounced") - params_struct: dict[str, Any] = { - "action": "edit", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": struct_edits, - "options": opts2 - } - resp_struct = send_command_with_retry( - "manage_script", params_struct) - if isinstance(resp_struct, dict) and resp_struct.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") - - return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") - - # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition - # so header guards and validation run on the C# side. - # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). - text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( - "mode") or "").strip().lower() for e in (edits or [])} - structured_kinds = {"replace_class", "delete_class", - "replace_method", "delete_method", "insert_method", "anchor_insert"} - if not text_ops.issubset(structured_kinds): - # Convert to apply_text_edits payload - try: - base_text = contents - - def line_col_from_index(idx: int) -> tuple[int, int]: - # 1-based line/col against base buffer - line = base_text.count("\n", 0, idx) + 1 - last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + \ - 1 if last_nl >= 0 else idx + 1 - return line, col - - at_edits: list[dict[str, Any]] = [] - import re as _re - for e in edits or []: - op = (e.get("op") or e.get("operation") or e.get( - "type") or e.get("mode") or "").strip().lower() - # aliasing for text field - text_field = e.get("text") or e.get( - "insert") or e.get("content") or "" - if op == "anchor_insert": - anchor = e.get("anchor") or "" - position = (e.get("position") or "after").lower() - # Use improved anchor matching logic with helpful errors, honoring ignore_case - try: - flags = re.MULTILINE | ( - re.IGNORECASE if e.get("ignore_case") else 0) - m = _find_best_anchor_match( - anchor, base_text, flags, prefer_last=True) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") - if not m: - return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") - idx = m.start() if position == "before" else m.end() - # Normalize insertion newlines - if text_field and not text_field.startswith("\n"): - text_field = "\n" + text_field - if text_field and not text_field.endswith("\n"): - text_field = text_field + "\n" - sl, sc = line_col_from_index(idx) - at_edits.append({ - "startLine": sl, - "startCol": sc, - "endLine": sl, - "endCol": sc, - "newText": text_field or "" - }) - # Do not mutate base buffer when building an atomic batch - elif op == "replace_range": - # Directly forward if already in line/col form - if "startLine" in e: - at_edits.append({ - "startLine": int(e.get("startLine", 1)), - "startCol": int(e.get("startCol", 1)), - "endLine": int(e.get("endLine", 1)), - "endCol": int(e.get("endCol", 1)), - "newText": text_field - }) - else: - # If only indices provided, skip (we don't support index-based here) - return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") - elif op == "regex_replace": - pattern = e.get("pattern") or "" - repl = text_field - flags = re.MULTILINE | ( - re.IGNORECASE if e.get("ignore_case") else 0) - # Early compile for clearer error messages - try: - regex_obj = re.compile(pattern, flags) - except Exception as ex: - return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") - # Use smart anchor matching for consistent behavior with anchor_insert - m = _find_best_anchor_match( - pattern, base_text, flags, prefer_last=True) - if not m: - continue - # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) - - def _expand_dollars(rep: str, _m=m) -> str: - return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) - repl_expanded = _expand_dollars(repl) - # Let C# side handle validation using Unity's built-in compiler services - sl, sc = line_col_from_index(m.start()) - el, ec = line_col_from_index(m.end()) - at_edits.append({ - "startLine": sl, - "startCol": sc, - "endLine": el, - "endCol": ec, - "newText": repl_expanded - }) - # Do not mutate base buffer when building an atomic batch - else: - return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") - - if not at_edits: - return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") - - sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - params: dict[str, Any] = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": at_edits, - "precondition_sha256": sha, - "options": { - "refresh": (options or {}).get("refresh", "debounced"), - "validate": (options or {}).get("validate", "standard"), - "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) - } - } - resp = send_command_with_retry("manage_script", params) - if isinstance(resp, dict) and resp.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm( - resp if isinstance(resp, dict) else { - "success": False, "message": str(resp)}, - normalized_for_echo, - routing="text" - ) - except Exception as e: - return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") - - # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. - # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. - if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): - try: - preview_text = _apply_edits_locally(contents, edits) - import difflib - diff = list(difflib.unified_diff(contents.splitlines( - ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) - if len(diff) > 800: - diff = diff[:800] + ["... (diff truncated) ..."] - if preview: - return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} - return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") - except Exception as e: - return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") - # 2) apply edits locally (only if not text-ops) - try: - new_contents = _apply_edits_locally(contents, edits) - except Exception as e: - return {"success": False, "message": f"Edit application failed: {e}"} - - # Short-circuit no-op edits to avoid false "applied" reports downstream - if new_contents == contents: - return _with_norm({ - "success": True, - "message": "No-op: contents unchanged", - "data": {"no_op": True, "evidence": {"reason": "identical_content"}} - }, normalized_for_echo, routing="text") - - if preview: - # Produce a compact unified diff limited to small context - import difflib - a = contents.splitlines() - b = new_contents.splitlines() - diff = list(difflib.unified_diff( - a, b, fromfile="before", tofile="after", n=3)) - # Limit diff size to keep responses small - if len(diff) > 2000: - diff = diff[:2000] + ["... (diff truncated) ..."] - return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} - - # 3) update to Unity - # Default refresh/validate for natural usage on text path as well - options = dict(options or {}) - options.setdefault("validate", "standard") - options.setdefault("refresh", "debounced") - - # Compute the SHA of the current file contents for the precondition - old_lines = contents.splitlines(keepends=True) - end_line = len(old_lines) + 1 # 1-based exclusive end - sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() - - # Apply a whole-file text edit rather than the deprecated 'update' action - params = { - "action": "apply_text_edits", - "name": name, - "path": path, - "namespace": namespace, - "scriptType": script_type, - "edits": [ - { - "startLine": 1, - "startCol": 1, - "endLine": end_line, - "endCol": 1, - "newText": new_contents, - } - ], - "precondition_sha256": sha, - "options": options or {"validate": "standard", "refresh": "debounced"}, - } - - write_resp = send_command_with_retry("manage_script", params) - if isinstance(write_resp, dict) and write_resp.get("success"): - pass # Optional sentinel reload removed (deprecated) - return _with_norm( - write_resp if isinstance(write_resp, dict) - else {"success": False, "message": str(write_resp)}, - normalized_for_echo, - routing="text", - ) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index e9ccc14a..9c199661 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,63 +1,60 @@ import base64 from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -def register_manage_shader_tools(mcp: FastMCP): - """Register all shader script management tools with the MCP server.""" - - @mcp.tool(name="manage_shader", description="Manages shader scripts in Unity (create, read, update, delete).") - @telemetry_tool("manage_shader") - def manage_shader( - ctx: Context, - action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], - name: Annotated[str, "Shader name (no .cs extension)"], - path: Annotated[str, "Asset path (default: \"Assets/\")"], - contents: Annotated[str, - "Shader code for 'create'/'update'"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing manage_shader: {action}") - try: - # Prepare parameters for Unity - params = { - "action": action, - "name": name, - "path": path, - } - - # Base64 encode the contents if they exist to avoid JSON escaping issues - if contents is not None: - if action in ['create', 'update']: - # Encode content for safer transmission - params["encodedContents"] = base64.b64encode( - contents.encode('utf-8')).decode('utf-8') - params["contentsEncoded"] = True - else: - params["contents"] = contents - - # Remove None values so they don't get sent as null - params = {k: v for k, v in params.items() if v is not None} - - # Send command via centralized retry helper - response = send_command_with_retry("manage_shader", params) - - # Process response from Unity - if isinstance(response, dict) and response.get("success"): - # If the response contains base64 encoded content, decode it - if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode( - response["data"]["encodedContents"]).decode('utf-8') - response["data"]["contents"] = decoded_contents - del response["data"]["encodedContents"] - del response["data"]["contentsEncoded"] - - return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} - return response if isinstance(response, dict) else {"success": False, "message": str(response)} - - except Exception as e: - # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} +@mcp_for_unity_tool( + description="Manages shader scripts in Unity (create, read, update, delete)." +) +def manage_shader( + ctx: Context, + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], + name: Annotated[str, "Shader name (no .cs extension)"], + path: Annotated[str, "Asset path (default: \"Assets/\")"], + contents: Annotated[str, + "Shader code for 'create'/'update'"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing manage_shader: {action}") + try: + # Prepare parameters for Unity + params = { + "action": action, + "name": name, + "path": path, + } + + # Base64 encode the contents if they exist to avoid JSON escaping issues + if contents is not None: + if action in ['create', 'update']: + # Encode content for safer transmission + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') + params["contentsEncoded"] = True + else: + params["contents"] = contents + + # Remove None values so they don't get sent as null + params = {k: v for k, v in params.items() if v is not None} + + # Send command via centralized retry helper + response = send_command_with_retry("manage_shader", params) + + # Process response from Unity + if isinstance(response, dict) and response.get("success"): + # If the response contains base64 encoded content, decode it + if response.get("data", {}).get("contentsEncoded"): + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') + response["data"]["contents"] = decoded_contents + del response["data"]["encodedContents"] + del response["data"]["contentsEncoded"] + + return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} + return response if isinstance(response, dict) else {"success": False, "message": str(response)} + + except Exception as e: + # Handle Python-side errors (e.g., connection issues) + return {"success": False, "message": f"Python error managing shader: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index c647cf8f..5fc9a096 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -3,88 +3,85 @@ """ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool - +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -def register_read_console_tools(mcp: FastMCP): - """Registers the read_console tool with the MCP server.""" - - @mcp.tool(name="read_console", description="Gets messages from or clears the Unity Editor console.") - @telemetry_tool("read_console") - def read_console( - ctx: Context, - action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], - types: Annotated[list[Literal['error', 'warning', - 'log', 'all']], "Message types to get"] | None = None, - count: Annotated[int, "Max messages to return"] | None = None, - filter_text: Annotated[str, "Text filter for messages"] | None = None, - since_timestamp: Annotated[str, - "Get messages after this timestamp (ISO 8601)"] | None = None, - format: Annotated[Literal['plain', 'detailed', - 'json'], "Output format"] | None = None, - include_stacktrace: Annotated[bool, - "Include stack traces in output"] | None = None - ) -> dict[str, Any]: - ctx.info(f"Processing read_console: {action}") - # Set defaults if values are None - action = action if action is not None else 'get' - types = types if types is not None else ['error', 'warning', 'log'] - format = format if format is not None else 'detailed' - include_stacktrace = include_stacktrace if include_stacktrace is not None else True +@mcp_for_unity_tool( + description="Gets messages from or clears the Unity Editor console." +) +def read_console( + ctx: Context, + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], + types: Annotated[list[Literal['error', 'warning', + 'log', 'all']], "Message types to get"] | None = None, + count: Annotated[int, "Max messages to return"] | None = None, + filter_text: Annotated[str, "Text filter for messages"] | None = None, + since_timestamp: Annotated[str, + "Get messages after this timestamp (ISO 8601)"] | None = None, + format: Annotated[Literal['plain', 'detailed', + 'json'], "Output format"] | None = None, + include_stacktrace: Annotated[bool, + "Include stack traces in output"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing read_console: {action}") + # Set defaults if values are None + action = action if action is not None else 'get' + types = types if types is not None else ['error', 'warning', 'log'] + format = format if format is not None else 'detailed' + include_stacktrace = include_stacktrace if include_stacktrace is not None else True - # Normalize action if it's a string - if isinstance(action, str): - action = action.lower() + # Normalize action if it's a string + if isinstance(action, str): + action = action.lower() - # Coerce count defensively (string/float -> int) - def _coerce_int(value, default=None): - if value is None: + # Coerce count defensively (string/float -> int) + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): return default - try: - if isinstance(value, bool): - return default - if isinstance(value, int): - return int(value) - s = str(value).strip() - if s.lower() in ("", "none", "null"): - return default - return int(float(s)) - except Exception: + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): return default + return int(float(s)) + except Exception: + return default - count = _coerce_int(count) + count = _coerce_int(count) - # Prepare parameters for the C# handler - params_dict = { - "action": action, - "types": types, - "count": count, - "filterText": filter_text, - "sinceTimestamp": since_timestamp, - "format": format.lower() if isinstance(format, str) else format, - "includeStacktrace": include_stacktrace - } + # Prepare parameters for the C# handler + params_dict = { + "action": action, + "types": types, + "count": count, + "filterText": filter_text, + "sinceTimestamp": since_timestamp, + "format": format.lower() if isinstance(format, str) else format, + "includeStacktrace": include_stacktrace + } - # Remove None values unless it's 'count' (as None might mean 'all') - params_dict = {k: v for k, v in params_dict.items() - if v is not None or k == 'count'} + # Remove None values unless it's 'count' (as None might mean 'all') + params_dict = {k: v for k, v in params_dict.items() + if v is not None or k == 'count'} - # Add count back if it was None, explicitly sending null might be important for C# logic - if 'count' not in params_dict: - params_dict['count'] = None + # Add count back if it was None, explicitly sending null might be important for C# logic + if 'count' not in params_dict: + params_dict['count'] = None - # Use centralized retry helper - resp = send_command_with_retry("read_console", params_dict) - if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: - # Strip stacktrace fields from returned lines if present - try: - lines = resp.get("data", {}).get("lines", []) - for line in lines: - if isinstance(line, dict) and "stacktrace" in line: - line.pop("stacktrace", None) - except Exception: - pass - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} + # Use centralized retry helper + resp = send_command_with_retry("read_console", params_dict) + if isinstance(resp, dict) and resp.get("success") and not include_stacktrace: + # Strip stacktrace fields from returned lines if present + try: + lines = resp.get("data", {}).get("lines", []) + for line in lines: + if isinstance(line, dict) and "stacktrace" in line: + line.pop("stacktrace", None) + except Exception: + pass + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 2ae06e85..a8398f75 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -11,9 +11,9 @@ from typing import Annotated, Any from urllib.parse import urlparse, unquote -from mcp.server.fastmcp import FastMCP, Context -from telemetry_decorator import telemetry_tool +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @@ -133,264 +133,260 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: return p -def register_resource_tools(mcp: FastMCP) -> None: - """Registers list_resources and read_resource wrapper tools.""" - - @mcp.tool(name="list_resources", description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) - @telemetry_tool("list_resources") - async def list_resources( - ctx: Context, - pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", - under: Annotated[str, - "Folder under project root, default is Assets"] = "Assets", - limit: Annotated[int, "Page limit"] = 200, - project_root: Annotated[str, "Project path"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing list_resources: {pattern}") +@mcp_for_unity_tool(description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) +async def list_resources( + ctx: Context, + pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", + under: Annotated[str, + "Folder under project root, default is Assets"] = "Assets", + limit: Annotated[int, "Page limit"] = 200, + project_root: Annotated[str, "Project path"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing list_resources: {pattern}") + try: + project = _resolve_project_root(project_root) + base = (project / under).resolve() try: - project = _resolve_project_root(project_root) - base = (project / under).resolve() - try: - base.relative_to(project) - except ValueError: - return {"success": False, "error": "Base path must be under project root"} - # Enforce listing only under Assets + base.relative_to(project) + except ValueError: + return {"success": False, "error": "Base path must be under project root"} + # Enforce listing only under Assets + try: + base.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Listing is restricted to Assets/"} + + matches: list[str] = [] + limit_int = _coerce_int(limit, default=200, minimum=1) + for p in base.rglob("*"): + if not p.is_file(): + continue + # Resolve symlinks and ensure the real path stays under project/Assets try: - base.relative_to(project / "Assets") - except ValueError: - return {"success": False, "error": "Listing is restricted to Assets/"} + rp = p.resolve() + rp.relative_to(project / "Assets") + except Exception: + continue + # Enforce .cs extension regardless of provided pattern + if p.suffix.lower() != ".cs": + continue + if pattern and not fnmatch.fnmatch(p.name, pattern): + continue + rel = p.relative_to(project).as_posix() + matches.append(f"unity://path/{rel}") + if len(matches) >= max(1, limit_int): + break - matches: list[str] = [] - limit_int = _coerce_int(limit, default=200, minimum=1) - for p in base.rglob("*"): - if not p.is_file(): - continue - # Resolve symlinks and ensure the real path stays under project/Assets - try: - rp = p.resolve() - rp.relative_to(project / "Assets") - except Exception: - continue - # Enforce .cs extension regardless of provided pattern - if p.suffix.lower() != ".cs": - continue - if pattern and not fnmatch.fnmatch(p.name, pattern): - continue - rel = p.relative_to(project).as_posix() - matches.append(f"unity://path/{rel}") - if len(matches) >= max(1, limit_int): - break + # Always include the canonical spec resource so NL clients can discover it + if "unity://spec/script-edits" not in matches: + matches.append("unity://spec/script-edits") - # Always include the canonical spec resource so NL clients can discover it - if "unity://spec/script-edits" not in matches: - matches.append("unity://spec/script-edits") + return {"success": True, "data": {"uris": matches, "count": len(matches)}} + except Exception as e: + return {"success": False, "error": str(e)} - return {"success": True, "data": {"uris": matches, "count": len(matches)}} - except Exception as e: - return {"success": False, "error": str(e)} - @mcp.tool(name="read_resource", description=("Reads a resource by unity://path/... URI with optional slicing.")) - @telemetry_tool("read_resource") - async def read_resource( - ctx: Context, - uri: Annotated[str, "The resource URI to read under Assets/"], - start_line: Annotated[int, - "The starting line number (0-based)"] | None = None, - line_count: Annotated[int, - "The number of lines to read"] | None = None, - head_bytes: Annotated[int, - "The number of bytes to read from the start of the file"] | None = None, - tail_lines: Annotated[int, - "The number of lines to read from the end of the file"] | None = None, - project_root: Annotated[str, - "The project root directory"] | None = None, - request: Annotated[str, "The request ID"] | None = None, - ) -> dict[str, Any]: - ctx.info(f"Processing read_resource: {uri}") - try: - # Serve the canonical spec directly when requested (allow bare or with scheme) - if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): - spec_json = ( - '{\n' - ' "name": "Unity MCP - Script Edits v1",\n' - ' "target_tool": "script_apply_edits",\n' - ' "canonical_rules": {\n' - ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' - ' "never_use": ["new_method","anchor_method","content","newText"],\n' - ' "defaults": {\n' - ' "className": "\u2190 server will default to \'name\' when omitted",\n' - ' "position": "end"\n' - ' }\n' - ' },\n' - ' "ops": [\n' - ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' - ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' - ' {"op":"delete_method","required":["className","methodName"]},\n' - ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' - ' ],\n' - ' "apply_text_edits_recipe": {\n' - ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' - ' "step2_apply": {\n' - ' "tool": "manage_script",\n' - ' "args": {\n' - ' "action": "apply_text_edits",\n' - ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' - ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' - ' "precondition_sha256": "",\n' - ' "options": {"refresh": "immediate", "validate": "standard"}\n' - ' }\n' - ' },\n' - ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' - ' },\n' - ' "examples": [\n' - ' {\n' - ' "title": "Replace a method",\n' - ' "args": {\n' - ' "name": "SmartReach",\n' - ' "path": "Assets/Scripts/Interaction",\n' - ' "edits": [\n' - ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' - ' ],\n' - ' "options": { "validate": "standard", "refresh": "immediate" }\n' - ' }\n' - ' },\n' - ' {\n' - ' "title": "Insert a method after another",\n' - ' "args": {\n' - ' "name": "SmartReach",\n' - ' "path": "Assets/Scripts/Interaction",\n' - ' "edits": [\n' - ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' - ' ]\n' - ' }\n' - ' }\n' - ' ]\n' - '}\n' - ) - sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() - return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} +@mcp_for_unity_tool(description=("Reads a resource by unity://path/... URI with optional slicing.")) +async def read_resource( + ctx: Context, + uri: Annotated[str, "The resource URI to read under Assets/"], + start_line: Annotated[int, + "The starting line number (0-based)"] | None = None, + line_count: Annotated[int, + "The number of lines to read"] | None = None, + head_bytes: Annotated[int, + "The number of bytes to read from the start of the file"] | None = None, + tail_lines: Annotated[int, + "The number of lines to read from the end of the file"] | None = None, + project_root: Annotated[str, + "The project root directory"] | None = None, + request: Annotated[str, "The request ID"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing read_resource: {uri}") + try: + # Serve the canonical spec directly when requested (allow bare or with scheme) + if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): + spec_json = ( + '{\n' + ' "name": "Unity MCP - Script Edits v1",\n' + ' "target_tool": "script_apply_edits",\n' + ' "canonical_rules": {\n' + ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' + ' "never_use": ["new_method","anchor_method","content","newText"],\n' + ' "defaults": {\n' + ' "className": "\u2190 server will default to \'name\' when omitted",\n' + ' "position": "end"\n' + ' }\n' + ' },\n' + ' "ops": [\n' + ' {"op":"replace_method","required":["className","methodName","replacement"],"optional":["returnType","parametersSignature","attributesContains"],"examples":[{"note":"match overload by signature","parametersSignature":"(int a, string b)"},{"note":"ensure attributes retained","attributesContains":"ContextMenu"}]},\n' + ' {"op":"insert_method","required":["className","replacement"],"position":{"enum":["start","end","after","before"],"after_requires":"afterMethodName","before_requires":"beforeMethodName"}},\n' + ' {"op":"delete_method","required":["className","methodName"]},\n' + ' {"op":"anchor_insert","required":["anchor","text"],"notes":"regex; position=before|after"}\n' + ' ],\n' + ' "apply_text_edits_recipe": {\n' + ' "step1_read": { "tool": "resources/read", "args": {"uri": "unity://path/Assets/Scripts/Interaction/SmartReach.cs"} },\n' + ' "step2_apply": {\n' + ' "tool": "manage_script",\n' + ' "args": {\n' + ' "action": "apply_text_edits",\n' + ' "name": "SmartReach", "path": "Assets/Scripts/Interaction",\n' + ' "edits": [{"startLine": 42, "startCol": 1, "endLine": 42, "endCol": 1, "newText": "[MyAttr]\\n"}],\n' + ' "precondition_sha256": "",\n' + ' "options": {"refresh": "immediate", "validate": "standard"}\n' + ' }\n' + ' },\n' + ' "note": "newText is for apply_text_edits ranges only; use replacement in script_apply_edits ops."\n' + ' },\n' + ' "examples": [\n' + ' {\n' + ' "title": "Replace a method",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"replace_method","className":"SmartReach","methodName":"HasTarget","replacement":"public bool HasTarget() { return currentTarget != null; }"}\n' + ' ],\n' + ' "options": { "validate": "standard", "refresh": "immediate" }\n' + ' }\n' + ' },\n' + ' {\n' + ' "title": "Insert a method after another",\n' + ' "args": {\n' + ' "name": "SmartReach",\n' + ' "path": "Assets/Scripts/Interaction",\n' + ' "edits": [\n' + ' {"op":"insert_method","className":"SmartReach","replacement":"public void PrintSeries() { Debug.Log(seriesName); }","position":"after","afterMethodName":"GetCurrentTarget"}\n' + ' ]\n' + ' }\n' + ' }\n' + ' ]\n' + '}\n' + ) + sha = hashlib.sha256(spec_json.encode("utf-8")).hexdigest() + return {"success": True, "data": {"text": spec_json, "metadata": {"sha256": sha}}} - project = _resolve_project_root(project_root) - p = _resolve_safe_path_from_uri(uri, project) - if not p or not p.exists() or not p.is_file(): - return {"success": False, "error": f"Resource not found: {uri}"} - try: - p.relative_to(project / "Assets") - except ValueError: - return {"success": False, "error": "Read restricted to Assets/"} - # Natural-language convenience: request like "last 120 lines", "first 200 lines", - # "show 40 lines around MethodName", etc. - if request: - req = request.strip().lower() - m = re.search(r"last\s+(\d+)\s+lines", req) - if m: - tail_lines = int(m.group(1)) - m = re.search(r"first\s+(\d+)\s+lines", req) - if m: - start_line = 1 - line_count = int(m.group(1)) - m = re.search(r"first\s+(\d+)\s*bytes", req) - if m: - head_bytes = int(m.group(1)) - m = re.search( - r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) - if m: - window = int(m.group(1)) - method = m.group(2) - # naive search for method header to get a line number - text_all = p.read_text(encoding="utf-8") - lines_all = text_all.splitlines() - pat = re.compile( - rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) - hit_line = None - for i, line in enumerate(lines_all, start=1): - if pat.search(line): - hit_line = i - break - if hit_line: - half = max(1, window // 2) - start_line = max(1, hit_line - half) - line_count = window + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} + try: + p.relative_to(project / "Assets") + except ValueError: + return {"success": False, "error": "Read restricted to Assets/"} + # Natural-language convenience: request like "last 120 lines", "first 200 lines", + # "show 40 lines around MethodName", etc. + if request: + req = request.strip().lower() + m = re.search(r"last\s+(\d+)\s+lines", req) + if m: + tail_lines = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s+lines", req) + if m: + start_line = 1 + line_count = int(m.group(1)) + m = re.search(r"first\s+(\d+)\s*bytes", req) + if m: + head_bytes = int(m.group(1)) + m = re.search( + r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + if m: + window = int(m.group(1)) + method = m.group(2) + # naive search for method header to get a line number + text_all = p.read_text(encoding="utf-8") + lines_all = text_all.splitlines() + pat = re.compile( + rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + hit_line = None + for i, line in enumerate(lines_all, start=1): + if pat.search(line): + hit_line = i + break + if hit_line: + half = max(1, window // 2) + start_line = max(1, hit_line - half) + line_count = window - # Coerce numeric inputs defensively (string/float -> int) - start_line = _coerce_int(start_line) - line_count = _coerce_int(line_count) - head_bytes = _coerce_int(head_bytes, minimum=1) - tail_lines = _coerce_int(tail_lines, minimum=1) + # Coerce numeric inputs defensively (string/float -> int) + start_line = _coerce_int(start_line) + line_count = _coerce_int(line_count) + head_bytes = _coerce_int(head_bytes, minimum=1) + tail_lines = _coerce_int(tail_lines, minimum=1) - # Compute SHA over full file contents (metadata-only default) - full_bytes = p.read_bytes() - full_sha = hashlib.sha256(full_bytes).hexdigest() + # Compute SHA over full file contents (metadata-only default) + full_bytes = p.read_bytes() + full_sha = hashlib.sha256(full_bytes).hexdigest() - # Selection only when explicitly requested via windowing args or request text hints - selection_requested = bool(head_bytes or tail_lines or ( - start_line is not None and line_count is not None) or request) - if selection_requested: - # Mutually exclusive windowing options precedence: - # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text - if head_bytes and head_bytes > 0: - raw = full_bytes[: head_bytes] - text = raw.decode("utf-8", errors="replace") - else: - text = full_bytes.decode("utf-8", errors="replace") - if tail_lines is not None and tail_lines > 0: - lines = text.splitlines() - n = max(0, tail_lines) - text = "\n".join(lines[-n:]) - elif start_line is not None and line_count is not None and line_count >= 0: - lines = text.splitlines() - s = max(0, start_line - 1) - e = min(len(lines), s + line_count) - text = "\n".join(lines[s:e]) - return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + # Selection only when explicitly requested via windowing args or request text hints + selection_requested = bool(head_bytes or tail_lines or ( + start_line is not None and line_count is not None) or request) + if selection_requested: + # Mutually exclusive windowing options precedence: + # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text + if head_bytes and head_bytes > 0: + raw = full_bytes[: head_bytes] + text = raw.decode("utf-8", errors="replace") else: - # Default: metadata only - return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} - except Exception as e: - return {"success": False, "error": str(e)} + text = full_bytes.decode("utf-8", errors="replace") + if tail_lines is not None and tail_lines > 0: + lines = text.splitlines() + n = max(0, tail_lines) + text = "\n".join(lines[-n:]) + elif start_line is not None and line_count is not None and line_count >= 0: + lines = text.splitlines() + s = max(0, start_line - 1) + e = min(len(lines), s + line_count) + text = "\n".join(lines[s:e]) + return {"success": True, "data": {"text": text, "metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + else: + # Default: metadata only + return {"success": True, "data": {"metadata": {"sha256": full_sha, "lengthBytes": len(full_bytes)}}} + except Exception as e: + return {"success": False, "error": str(e)} - @mcp.tool(name="find_in_file", description="Searches a file with a regex pattern and returns line numbers and excerpts.") - @telemetry_tool("find_in_file") - async def find_in_file( - ctx: Context, - uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], - pattern: Annotated[str, "The regex pattern to search for"], - ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, - project_root: Annotated[str, - "The project root directory"] | None = None, - max_results: Annotated[int, - "Cap results to avoid huge payloads"] = 200, - ) -> dict[str, Any]: - ctx.info(f"Processing find_in_file: {uri}") - try: - project = _resolve_project_root(project_root) - p = _resolve_safe_path_from_uri(uri, project) - if not p or not p.exists() or not p.is_file(): - return {"success": False, "error": f"Resource not found: {uri}"} - text = p.read_text(encoding="utf-8") - flags = re.MULTILINE - if ignore_case: - flags |= re.IGNORECASE - rx = re.compile(pattern, flags) +@mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.") +async def find_in_file( + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, + project_root: Annotated[str, + "The project root directory"] | None = None, + max_results: Annotated[int, + "Cap results to avoid huge payloads"] = 200, +) -> dict[str, Any]: + ctx.info(f"Processing find_in_file: {uri}") + try: + project = _resolve_project_root(project_root) + p = _resolve_safe_path_from_uri(uri, project) + if not p or not p.exists() or not p.is_file(): + return {"success": False, "error": f"Resource not found: {uri}"} - results = [] - max_results_int = _coerce_int(max_results, default=200, minimum=1) - lines = text.splitlines() - for i, line in enumerate(lines, start=1): - m = rx.search(line) - if m: - start_col = m.start() + 1 # 1-based - end_col = m.end() + 1 # 1-based, end exclusive - results.append({ - "startLine": i, - "startCol": start_col, - "endLine": i, - "endCol": end_col, - }) - if max_results_int and len(results) >= max_results_int: - break + text = p.read_text(encoding="utf-8") + flags = re.MULTILINE + if ignore_case: + flags |= re.IGNORECASE + rx = re.compile(pattern, flags) + + results = [] + max_results_int = _coerce_int(max_results, default=200, minimum=1) + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + m = rx.search(line) + if m: + start_col = m.start() + 1 # 1-based + end_col = m.end() + 1 # 1-based, end exclusive + results.append({ + "startLine": i, + "startCol": start_col, + "endLine": i, + "endCol": end_col, + }) + if max_results_int and len(results) >= max_results_int: + break - return {"success": True, "data": {"matches": results, "count": len(results)}} - except Exception as e: - return {"success": False, "error": str(e)} + return {"success": True, "data": {"matches": results, "count": len(results)}} + except Exception as e: + return {"success": False, "error": str(e)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/script_apply_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/script_apply_edits.py new file mode 100644 index 00000000..59fbbc61 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/script_apply_edits.py @@ -0,0 +1,966 @@ +import base64 +import hashlib +import re +from typing import Annotated, Any + +from mcp.server.fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: + text = original_text + for edit in edits or []: + op = ( + (edit.get("op") + or edit.get("operation") + or edit.get("type") + or edit.get("mode") + or "") + .strip() + .lower() + ) + + if not op: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)." + ) + + if op == "prepend": + prepend_text = edit.get("text", "") + text = (prepend_text if prepend_text.endswith( + "\n") else prepend_text + "\n") + text + elif op == "append": + append_text = edit.get("text", "") + if not text.endswith("\n"): + text += "\n" + text += append_text + if not text.endswith("\n"): + text += "\n" + elif op == "anchor_insert": + anchor = edit.get("anchor", "") + position = (edit.get("position") or "before").lower() + insert_text = edit.get("text", "") + flags = re.MULTILINE | ( + re.IGNORECASE if edit.get("ignore_case") else 0) + + # Find the best match using improved heuristics + match = _find_best_anchor_match( + anchor, text, flags, bool(edit.get("prefer_last", True))) + if not match: + if edit.get("allow_noop", True): + continue + raise RuntimeError(f"anchor not found: {anchor}") + idx = match.start() if position == "before" else match.end() + text = text[:idx] + insert_text + text[idx:] + elif op == "replace_range": + start_line = int(edit.get("startLine", 1)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) + replacement = edit.get("text", "") + lines = text.splitlines(keepends=True) + max_line = len(lines) + 1 # 1-based, exclusive end + if (start_line < 1 or end_line < start_line or end_line > max_line + or start_col < 1 or end_col < 1): + raise RuntimeError("replace_range out of bounds") + + def index_of(line: int, col: int) -> int: + if line <= len(lines): + return sum(len(l) for l in lines[: line - 1]) + (col - 1) + return sum(len(l) for l in lines) + a = index_of(start_line, start_col) + b = index_of(end_line, end_col) + text = text[:a] + replacement + text[b:] + elif op == "regex_replace": + pattern = edit.get("pattern", "") + repl = edit.get("replacement", "") + # Translate $n backrefs (our input) to Python \g + repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl) + count = int(edit.get("count", 0)) # 0 = replace all + flags = re.MULTILINE + if edit.get("ignore_case"): + flags |= re.IGNORECASE + text = re.sub(pattern, repl_py, text, count=count, flags=flags) + else: + allowed = "anchor_insert, prepend, append, replace_range, regex_replace" + raise RuntimeError( + f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + return text + + +def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): + """ + Find the best anchor match using improved heuristics. + + For patterns like \\s*}\\s*$ that are meant to find class-ending braces, + this function uses heuristics to choose the most semantically appropriate match: + + 1. If prefer_last=True, prefer the last match (common for class-end insertions) + 2. Use indentation levels to distinguish class vs method braces + 3. Consider context to avoid matches inside strings/comments + + Args: + pattern: Regex pattern to search for + text: Text to search in + flags: Regex flags + prefer_last: If True, prefer the last match over the first + + Returns: + Match object of the best match, or None if no match found + """ + + # Find all matches + matches = list(re.finditer(pattern, text, flags)) + if not matches: + return None + + # If only one match, return it + if len(matches) == 1: + return matches[0] + + # For patterns that look like they're trying to match closing braces at end of lines + is_closing_brace_pattern = '}' in pattern and ( + '$' in pattern or pattern.endswith(r'\s*')) + + if is_closing_brace_pattern and prefer_last: + # Use heuristics to find the best closing brace match + return _find_best_closing_brace_match(matches, text) + + # Default behavior: use last match if prefer_last, otherwise first match + return matches[-1] if prefer_last else matches[0] + + +def _find_best_closing_brace_match(matches, text: str): + """ + Find the best closing brace match using C# structure heuristics. + + Enhanced heuristics for scope-aware matching: + 1. Prefer matches with lower indentation (likely class-level) + 2. Prefer matches closer to end of file + 3. Avoid matches that seem to be inside method bodies + 4. For #endregion patterns, ensure class-level context + 5. Validate insertion point is at appropriate scope + + Args: + matches: List of regex match objects + text: The full text being searched + + Returns: + The best match object + """ + if not matches: + return None + + scored_matches = [] + lines = text.splitlines() + + for match in matches: + score = 0 + start_pos = match.start() + + # Find which line this match is on + lines_before = text[:start_pos].count('\n') + line_num = lines_before + + if line_num < len(lines): + line_content = lines[line_num] + + # Calculate indentation level (lower is better for class braces) + indentation = len(line_content) - len(line_content.lstrip()) + + # Prefer lower indentation (class braces are typically less indented than method braces) + # Max 20 points for indentation=0 + score += max(0, 20 - indentation) + + # Prefer matches closer to end of file (class closing braces are typically at the end) + distance_from_end = len(lines) - line_num + # More points for being closer to end + score += max(0, 10 - distance_from_end) + + # Look at surrounding context to avoid method braces + context_start = max(0, line_num - 3) + context_end = min(len(lines), line_num + 2) + context_lines = lines[context_start:context_end] + + # Penalize if this looks like it's inside a method (has method-like patterns above) + for context_line in context_lines: + if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): + score -= 5 # Penalty for being near method signatures + + # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) + if indentation <= 4 and distance_from_end <= 3: + score += 15 # Bonus for likely class-ending brace + + scored_matches.append((score, match)) + + # Return the match with the highest score + scored_matches.sort(key=lambda x: x[0], reverse=True) + best_match = scored_matches[0][1] + + return best_match + + +def _infer_class_name(script_name: str) -> str: + # Default to script name as class name (common Unity pattern) + return (script_name or "").strip() + + +def _extract_code_after(keyword: str, request: str) -> str: + # Deprecated with NL removal; retained as no-op for compatibility + idx = request.lower().find(keyword) + if idx >= 0: + return request[idx + len(keyword):].strip() + return "" +# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services + + +def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: + """Best-effort normalization of script "name" and "path". + + Accepts any of: + - name = "SmartReach", path = "Assets/Scripts/Interaction" + - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" + - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" + - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) + - name or path using uri prefixes: unity://path/..., file://... + - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" + + Returns (name_without_extension, directory_path_under_Assets). + """ + n = (name or "").strip() + p = (path or "").strip() + + def strip_prefix(s: str) -> str: + if s.startswith("unity://path/"): + return s[len("unity://path/"):] + if s.startswith("file://"): + return s[len("file://"):] + return s + + def collapse_duplicate_tail(s: str) -> str: + # Collapse trailing "/X.cs/X.cs" to "/X.cs" + parts = s.split("/") + if len(parts) >= 2 and parts[-1] == parts[-2]: + parts = parts[:-1] + return "/".join(parts) + + # Prefer a full path if provided in either field + candidate = "" + for v in (n, p): + v2 = strip_prefix(v) + if v2.endswith(".cs") or v2.startswith("Assets/"): + candidate = v2 + break + + if candidate: + candidate = collapse_duplicate_tail(candidate) + # If a directory was passed in path and file in name, join them + if not candidate.endswith(".cs") and n.endswith(".cs"): + v2 = strip_prefix(n) + candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1]) + if candidate.endswith(".cs"): + parts = candidate.split("/") + file_name = parts[-1] + dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" + base = file_name[:- + 3] if file_name.lower().endswith(".cs") else file_name + return base, dir_path + + # Fall back: remove extension from name if present and return given path + base_name = n[:-3] if n.lower().endswith(".cs") else n + return base_name, (p or "Assets") + + +def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: + if not isinstance(resp, dict): + return resp + data = resp.setdefault("data", {}) + data.setdefault("normalizedEdits", edits) + if routing: + data["routing"] = routing + return resp + + +def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, + normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": False, + "code": code, "message": message} + data: dict[str, Any] = {} + if expected: + data["expected"] = expected + if rewrite: + data["rewrite_suggestion"] = rewrite + if normalized is not None: + data["normalizedEdits"] = normalized + if routing: + data["routing"] = routing + if extra: + data.update(extra) + if data: + payload["data"] = data + return payload + +# Natural-language parsing removed; clients should send structured edits. + + +@mcp_for_unity_tool(name="script_apply_edits", description=( + """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. + Best practices: + - Prefer anchor_* ops for pattern-based insert/replace near stable markers + - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) + - Avoid whole-file regex deletes; validators will guard unbalanced braces + - For tail insertions, prefer anchor/regex_replace on final brace (class closing) + - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits + Canonical fields (use these exact keys): + - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace + - className: string (defaults to 'name' if omitted on method/class ops) + - methodName: string (required for replace_method, delete_method) + - replacement: string (required for replace_method, insert_method) + - position: start | end | after | before (insert_method only) + - afterMethodName / beforeMethodName: string (required when position='after'/'before') + - anchor: regex string (for anchor_* ops) + - text: string (for anchor_insert/anchor_replace) + Examples: + 1) Replace a method: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "replace_method", + "className": "SmartReach", + "methodName": "HasTarget", + "replacement": "public bool HasTarget(){ return currentTarget!=null; }" + } + ], + "options": {"validate": "standard", "refresh": "immediate"} + } + "2) Insert a method after another: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "insert_method", + "className": "SmartReach", + "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", + "position": "after", + "afterMethodName": "GetCurrentTarget" + } + ], + } + ]""" +)) +def script_apply_edits( + ctx: Context, + name: Annotated[str, "Name of the script to edit"], + path: Annotated[str, "Path to the script to edit under Assets/ directory"], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + options: Annotated[dict[str, Any], + "Options for the script edit"] | None = None, + script_type: Annotated[str, + "Type of the script to edit"] = "MonoBehaviour", + namespace: Annotated[str, + "Namespace of the script to edit"] | None = None, +) -> dict[str, Any]: + ctx.info(f"Processing script_apply_edits: {name}") + # Normalize locator first so downstream calls target the correct script file. + name, path = _normalize_script_locator(name, path) + # Normalize unsupported or aliased ops to known structured/text paths + + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: + # Unwrap single-key wrappers like {"replace_method": {...}} + for wrapper_key in ( + "replace_method", "insert_method", "delete_method", + "replace_class", "delete_class", + "anchor_insert", "anchor_replace", "anchor_delete", + ): + if wrapper_key in edit and isinstance(edit[wrapper_key], dict): + inner = dict(edit[wrapper_key]) + inner["op"] = wrapper_key + edit = inner + break + + e = dict(edit) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + if op: + e["op"] = op + + # Common field aliases + if "class_name" in e and "className" not in e: + e["className"] = e.pop("class_name") + if "class" in e and "className" not in e: + e["className"] = e.pop("class") + if "method_name" in e and "methodName" not in e: + e["methodName"] = e.pop("method_name") + # Some clients use a generic 'target' for method name + if "target" in e and "methodName" not in e: + e["methodName"] = e.pop("target") + if "method" in e and "methodName" not in e: + e["methodName"] = e.pop("method") + if "new_content" in e and "replacement" not in e: + e["replacement"] = e.pop("new_content") + if "newMethod" in e and "replacement" not in e: + e["replacement"] = e.pop("newMethod") + if "new_method" in e and "replacement" not in e: + e["replacement"] = e.pop("new_method") + if "content" in e and "replacement" not in e: + e["replacement"] = e.pop("content") + if "after" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after") + if "after_method" in e and "afterMethodName" not in e: + e["afterMethodName"] = e.pop("after_method") + if "before" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before") + if "before_method" in e and "beforeMethodName" not in e: + e["beforeMethodName"] = e.pop("before_method") + # anchor_method → before/after based on position (default after) + if "anchor_method" in e: + anchor = e.pop("anchor_method") + pos = (e.get("position") or "after").strip().lower() + if pos == "before" and "beforeMethodName" not in e: + e["beforeMethodName"] = anchor + elif "afterMethodName" not in e: + e["afterMethodName"] = anchor + if "anchorText" in e and "anchor" not in e: + e["anchor"] = e.pop("anchorText") + if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"): + e["anchor"] = e.pop("pattern") + if "newText" in e and "text" not in e: + e["text"] = e.pop("newText") + + # CI compatibility (T‑A/T‑E): + # Accept method-anchored anchor_insert and upgrade to insert_method + # Example incoming shape: + # {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."} + if ( + e.get("op") == "anchor_insert" + and not e.get("anchor") + and (e.get("afterMethodName") or e.get("beforeMethodName")) + ): + e["op"] = "insert_method" + if "replacement" not in e: + e["replacement"] = e.get("text", "") + + # LSP-like range edit -> replace_range + if "range" in e and isinstance(e["range"], dict): + rng = e.pop("range") + start = rng.get("start", {}) + end = rng.get("end", {}) + # Convert 0-based to 1-based line/col + e["op"] = "replace_range" + e["startLine"] = int(start.get("line", 0)) + 1 + e["startCol"] = int(start.get("character", 0)) + 1 + e["endLine"] = int(end.get("line", 0)) + 1 + e["endCol"] = int(end.get("character", 0)) + 1 + if "newText" in edit and "text" not in e: + e["text"] = edit.get("newText", "") + return e + + normalized_edits: list[dict[str, Any]] = [] + for raw in edits or []: + e = _unwrap_and_alias(raw) + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + + # Default className to script name if missing on structured method/class ops + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): + e["className"] = name + + # Map common aliases for text ops + if op in ("text_replace",): + e["op"] = "replace_range" + normalized_edits.append(e) + continue + if op in ("regex_delete",): + e["op"] = "regex_replace" + e.setdefault("text", "") + normalized_edits.append(e) + continue + if op == "regex_replace" and ("replacement" not in e): + if "text" in e: + e["replacement"] = e.get("text", "") + elif "insert" in e or "content" in e: + e["replacement"] = e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): + e["op"] = "anchor_delete" + normalized_edits.append(e) + continue + normalized_edits.append(e) + + edits = normalized_edits + normalized_for_echo = edits + + # Validate required fields and produce machine-parsable hints + def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: + return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) + + for e in edits or []: + op = e.get("op", "") + if op == "replace_method": + if not e.get("methodName"): + return error_with_hint( + "replace_method requires 'methodName'.", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].methodName": "HasTarget"} + ) + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "replace_method requires 'replacement' (inline or base64).", + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, + {"edits[0].replacement": "public bool X(){ return true; }"} + ) + elif op == "insert_method": + if not (e.get("replacement") or e.get("text")): + return error_with_hint( + "insert_method requires a non-empty 'replacement'.", + {"op": "insert_method", "required": ["className", "replacement"], "position": { + "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} + ) + pos = (e.get("position") or "").lower() + if pos == "after" and not e.get("afterMethodName"): + return error_with_hint( + "insert_method with position='after' requires 'afterMethodName'.", + {"op": "insert_method", "position": { + "after_requires": "afterMethodName"}}, + {"edits[0].afterMethodName": "GetCurrentTarget"} + ) + if pos == "before" and not e.get("beforeMethodName"): + return error_with_hint( + "insert_method with position='before' requires 'beforeMethodName'.", + {"op": "insert_method", "position": { + "before_requires": "beforeMethodName"}}, + {"edits[0].beforeMethodName": "GetCurrentTarget"} + ) + elif op == "delete_method": + if not e.get("methodName"): + return error_with_hint( + "delete_method requires 'methodName'.", + {"op": "delete_method", "required": [ + "className", "methodName"]}, + {"edits[0].methodName": "PrintSeries"} + ) + elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): + if not e.get("anchor"): + return error_with_hint( + f"{op} requires 'anchor' (regex).", + {"op": op, "required": ["anchor"]}, + {"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("} + ) + if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")): + return error_with_hint( + f"{op} requires 'text'.", + {"op": op, "required": ["anchor", "text"]}, + {"edits[0].text": "/* comment */\n"} + ) + + # Decide routing: structured vs text vs mixed + STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} + TEXT = {"prepend", "append", "replace_range", "regex_replace"} + ops_set = {(e.get("op") or "").lower() for e in edits or []} + all_struct = ops_set.issubset(STRUCT) + all_text = ops_set.issubset(TEXT) + mixed = not (all_struct or all_text) + + # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor. + if all_struct: + opts2 = dict(options or {}) + # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused + opts2.setdefault("refresh", "immediate") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": edits, + "options": opts2, + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") + + # 1) read from Unity + read_resp = send_command_with_retry("manage_script", { + "action": "read", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + }) + if not isinstance(read_resp, dict) or not read_resp.get("success"): + return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} + + data = read_resp.get("data") or read_resp.get( + "result", {}).get("data") or {} + contents = data.get("contents") + if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8") + if contents is None: + return {"success": False, "message": "No contents returned from Unity read."} + + # Optional preview/dry-run: apply locally and return diff without writing + preview = bool((options or {}).get("preview")) + + # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured + if mixed: + text_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in STRUCT] + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + for e in text_edits: + opx = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get( + "content") or e.get("replacement") or "" + if opx == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + try: + # Use improved anchor matching logic + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first") + idx = m.start() if position == "before" else m.end() + # Normalize insertion to avoid jammed methods + text_field_norm = text_field + if not text_field_norm.startswith("\n"): + text_field_norm = "\n" + text_field_norm + if not text_field_norm.endswith("\n"): + text_field_norm = text_field_norm + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + # do not mutate base_text when building atomic spans + elif opx == "replace_range": + if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + elif opx == "regex_replace": + pattern = e.get("pattern") or "" + try: + regex_obj = re.compile(pattern, re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0)) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") + m = regex_obj.search(base_text) + if not m: + continue + # Expand $1, $2... in replacement using this match + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl = _expand_dollars(text_field) + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + # do not mutate base_text when building atomic spans + elif opx in ("prepend", "append"): + if opx == "prepend": + sl, sc = 1, 1 + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + # prepend can be applied atomically without local mutation + else: + # Insert at true EOF position (handles both \n and \r\n correctly) + eof_idx = len(base_text) + sl, sc = line_col_from_index(eof_idx) + new_text = ("\n" if not base_text.endswith( + "\n") else "") + text_field + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + # do not mutate base_text when building atomic spans + else: + return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + if at_edits: + params_text: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} + } + resp_text = send_command_with_retry( + "manage_script", params_text) + if not (isinstance(resp_text, dict) and resp_text.get("success")): + return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") + # Optional sentinel reload removed (deprecated) + except Exception as e: + return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first") + + if struct_edits: + opts2 = dict(options or {}) + # Prefer debounced background refresh unless explicitly overridden + opts2.setdefault("refresh", "debounced") + params_struct: dict[str, Any] = { + "action": "edit", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": struct_edits, + "options": opts2 + } + resp_struct = send_command_with_retry( + "manage_script", params_struct) + if isinstance(resp_struct, dict) and resp_struct.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") + + return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first") + + # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition + # so header guards and validation run on the C# side. + # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). + text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( + "mode") or "").strip().lower() for e in (edits or [])} + structured_kinds = {"replace_class", "delete_class", + "replace_method", "delete_method", "insert_method", "anchor_insert"} + if not text_ops.issubset(structured_kinds): + # Convert to apply_text_edits payload + try: + base_text = contents + + def line_col_from_index(idx: int) -> tuple[int, int]: + # 1-based line/col against base buffer + line = base_text.count("\n", 0, idx) + 1 + last_nl = base_text.rfind("\n", 0, idx) + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 + return line, col + + at_edits: list[dict[str, Any]] = [] + import re as _re + for e in edits or []: + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + # aliasing for text field + text_field = e.get("text") or e.get( + "insert") or e.get("content") or "" + if op == "anchor_insert": + anchor = e.get("anchor") or "" + position = (e.get("position") or "after").lower() + # Use improved anchor matching logic with helpful errors, honoring ignore_case + try: + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") + if not m: + return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text") + idx = m.start() if position == "before" else m.end() + # Normalize insertion newlines + if text_field and not text_field.startswith("\n"): + text_field = "\n" + text_field + if text_field and not text_field.endswith("\n"): + text_field = text_field + "\n" + sl, sc = line_col_from_index(idx) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": sl, + "endCol": sc, + "newText": text_field or "" + }) + # Do not mutate base buffer when building an atomic batch + elif op == "replace_range": + # Directly forward if already in line/col form + if "startLine" in e: + at_edits.append({ + "startLine": int(e.get("startLine", 1)), + "startCol": int(e.get("startCol", 1)), + "endLine": int(e.get("endLine", 1)), + "endCol": int(e.get("endCol", 1)), + "newText": text_field + }) + else: + # If only indices provided, skip (we don't support index-based here) + return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text") + elif op == "regex_replace": + pattern = e.get("pattern") or "" + repl = text_field + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + # Early compile for clearer error messages + try: + regex_obj = re.compile(pattern, flags) + except Exception as ex: + return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") + # Use smart anchor matching for consistent behavior with anchor_insert + m = _find_best_anchor_match( + pattern, base_text, flags, prefer_last=True) + if not m: + continue + # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + + def _expand_dollars(rep: str, _m=m) -> str: + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + repl_expanded = _expand_dollars(repl) + # Let C# side handle validation using Unity's built-in compiler services + sl, sc = line_col_from_index(m.start()) + el, ec = line_col_from_index(m.end()) + at_edits.append({ + "startLine": sl, + "startCol": sc, + "endLine": el, + "endCol": ec, + "newText": repl_expanded + }) + # Do not mutate base buffer when building an atomic batch + else: + return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text") + + if not at_edits: + return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") + + sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() + params: dict[str, Any] = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": at_edits, + "precondition_sha256": sha, + "options": { + "refresh": (options or {}).get("refresh", "debounced"), + "validate": (options or {}).get("validate", "standard"), + "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential")) + } + } + resp = send_command_with_retry("manage_script", params) + if isinstance(resp, dict) and resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + resp if isinstance(resp, dict) else { + "success": False, "message": str(resp)}, + normalized_for_echo, + routing="text" + ) + except Exception as e: + return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text") + + # For regex_replace, honor preview consistently: if preview=true, always return diff without writing. + # If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply. + if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")): + try: + preview_text = _apply_edits_locally(contents, edits) + import difflib + diff = list(difflib.unified_diff(contents.splitlines( + ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + if len(diff) > 800: + diff = diff[:800] + ["... (diff truncated) ..."] + if preview: + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text") + except Exception as e: + return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text") + # 2) apply edits locally (only if not text-ops) + try: + new_contents = _apply_edits_locally(contents, edits) + except Exception as e: + return {"success": False, "message": f"Edit application failed: {e}"} + + # Short-circuit no-op edits to avoid false "applied" reports downstream + if new_contents == contents: + return _with_norm({ + "success": True, + "message": "No-op: contents unchanged", + "data": {"no_op": True, "evidence": {"reason": "identical_content"}} + }, normalized_for_echo, routing="text") + + if preview: + # Produce a compact unified diff limited to small context + import difflib + a = contents.splitlines() + b = new_contents.splitlines() + diff = list(difflib.unified_diff( + a, b, fromfile="before", tofile="after", n=3)) + # Limit diff size to keep responses small + if len(diff) > 2000: + diff = diff[:2000] + ["... (diff truncated) ..."] + return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}} + + # 3) update to Unity + # Default refresh/validate for natural usage on text path as well + options = dict(options or {}) + options.setdefault("validate", "standard") + options.setdefault("refresh", "debounced") + + # Compute the SHA of the current file contents for the precondition + old_lines = contents.splitlines(keepends=True) + end_line = len(old_lines) + 1 # 1-based exclusive end + sha = hashlib.sha256(contents.encode("utf-8")).hexdigest() + + # Apply a whole-file text edit rather than the deprecated 'update' action + params = { + "action": "apply_text_edits", + "name": name, + "path": path, + "namespace": namespace, + "scriptType": script_type, + "edits": [ + { + "startLine": 1, + "startCol": 1, + "endLine": end_line, + "endCol": 1, + "newText": new_contents, + } + ], + "precondition_sha256": sha, + "options": options or {"validate": "standard", "refresh": "debounced"}, + } + + write_resp = send_command_with_retry("manage_script", params) + if isinstance(write_resp, dict) and write_resp.get("success"): + pass # Optional sentinel reload removed (deprecated) + return _with_norm( + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, + normalized_for_echo, + routing="text", + ) diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 3ec3f985..b239aca5 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -2,7 +2,7 @@ "name": "com.coplaydev.unity-mcp", "version": "4.1.1", "displayName": "MCP for Unity", - "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", + "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", "documentationUrl": "https://github.com/CoplayDev/unity-mcp", "licensesUrl": "https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE", diff --git a/deploy-dev.bat b/deploy-dev.bat index ca9abea4..60a398bd 100644 --- a/deploy-dev.bat +++ b/deploy-dev.bat @@ -8,8 +8,8 @@ echo. :: Configuration set "SCRIPT_DIR=%~dp0" -set "BRIDGE_SOURCE=%SCRIPT_DIR%UnityMcpBridge" -set "SERVER_SOURCE=%SCRIPT_DIR%UnityMcpBridge\UnityMcpServer~\src" +set "BRIDGE_SOURCE=%SCRIPT_DIR%MCPForUnity" +set "SERVER_SOURCE=%SCRIPT_DIR%MCPForUnity\UnityMcpServer~\src" set "DEFAULT_BACKUP_DIR=%USERPROFILE%\Desktop\unity-mcp-backup" set "DEFAULT_SERVER_PATH=%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src" diff --git a/CursorHelp.md b/docs/CURSOR_HELP.md similarity index 100% rename from CursorHelp.md rename to docs/CURSOR_HELP.md diff --git a/docs/CUSTOM_TOOLS.md b/docs/CUSTOM_TOOLS.md new file mode 100644 index 00000000..26614a2b --- /dev/null +++ b/docs/CUSTOM_TOOLS.md @@ -0,0 +1,287 @@ +# Adding Custom Tools to Unity MCP + +Unity MCP now supports auto-discovery of custom tools using decorators (Python) and attributes (C#). This allows you to easily extend the MCP server with your own tools without modifying core files. + +Be sure to review the developer README first: + +| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) | +|---------------------------|------------------------------| + +## Python Side (MCP Server) + +### Creating a Custom Tool + +1. **Create a new Python file** in `MCPForUnity/UnityMcpServer~/src/tools/` (or any location that gets imported) + +2. **Use the `@mcp_for_unity_tool` decorator**: + +```python +from typing import Annotated, Any +from mcp.server.fastmcp import Context +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + +@mcp_for_unity_tool( + description="My custom tool that does something amazing" +) +def my_custom_tool( + ctx: Context, + param1: Annotated[str, "Description of param1"], + param2: Annotated[int, "Description of param2"] | None = None +) -> dict[str, Any]: + ctx.info(f"Processing my_custom_tool: {param1}") + + # Prepare parameters for Unity + params = { + "action": "do_something", + "param1": param1, + "param2": param2, + } + params = {k: v for k, v in params.items() if v is not None} + + # Send to Unity handler + response = send_command_with_retry("my_custom_tool", params) + return response if isinstance(response, dict) else {"success": False, "message": str(response)} +``` + +3. **The tool is automatically registered!** The decorator: + - Auto-generates the tool name from the function name (e.g., `my_custom_tool`) + - Registers the tool with FastMCP during module import + +4. **Rebuild the server** in the MCP for Unity window (in the Unity Editor) to apply the changes. + +### Decorator Options + +```python +@mcp_for_unity_tool( + name="custom_name", # Optional: the function name is used by default + description="Tool description", # Required: describe what the tool does +) +``` + +You can use all options available in FastMCP's `mcp.tool` function decorator: . + +**Note:** All tools should have the `description` field. It's not strictly required, however, that parameter is the best place to define a description so that most MCP clients can read it. See [issue #289](https://github.com/CoplayDev/unity-mcp/issues/289). + +### Auto-Discovery + +Tools are automatically discovered when: +- The Python file is in the `tools/` directory +- The file is imported during server startup +- The decorator `@mcp_for_unity_tool` is used + +## C# Side (Unity Editor) + +### Creating a Custom Tool Handler + +1. **Create a new C# file** anywhere in your Unity project (typically in `Editor/`) + +2. **Add the `[McpForUnityTool]` attribute** and implement `HandleCommand`: + +```csharp +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MyProject.Editor.CustomTools +{ + // The name argument is optional, it uses a snake_case version of the class name by default + [McpForUnityTool("my_custom_tool")] + public static class MyCustomTool + { + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString(); + string param1 = @params["param1"]?.ToString(); + int? param2 = @params["param2"]?.ToObject(); + + // Your custom logic here + if (string.IsNullOrEmpty(param1)) + { + return Response.Error("param1 is required"); + } + + // Do something amazing + DoSomethingAmazing(param1, param2); + + return Response.Success("Custom tool executed successfully!"); + } + + private static void DoSomethingAmazing(string param1, int? param2) + { + // Your implementation + } + } +} +``` + +3. **The tool is automatically registered!** Unity will discover it via reflection on startup. + +### Attribute Options + +```csharp +// Explicit command name +[McpForUnityTool("my_custom_tool")] +public static class MyCustomTool { } + +// Auto-generated from class name (MyCustomTool → my_custom_tool) +[McpForUnityTool] +public static class MyCustomTool { } +``` + +### Auto-Discovery + +Tools are automatically discovered when: +- The class has the `[McpForUnityTool]` attribute +- The class has a `public static HandleCommand(JObject)` method +- Unity loads the assembly containing the class + +## Complete Example: Custom Screenshot Tool + +### Python (`UnityMcpServer~/src/tools/screenshot_tool.py`) + +```python +from typing import Annotated, Any + +from mcp.server.fastmcp import Context + +from registry import mcp_for_unity_tool +from unity_connection import send_command_with_retry + + +@mcp_for_unity_tool( + description="Capture screenshots in Unity, saving them as PNGs" +) +def capture_screenshot( + ctx: Context, + filename: Annotated[str, "Screenshot filename without extension, e.g., screenshot_01"], +) -> dict[str, Any]: + ctx.info(f"Capturing screenshot: {filename}") + + params = { + "action": "capture", + "filename": filename, + } + params = {k: v for k, v in params.items() if v is not None} + + response = send_command_with_retry("capture_screenshot", params) + return response if isinstance(response, dict) else {"success": False, "message": str(response)} +``` + +### C# (`Editor/CaptureScreenshotTool.cs`) + +```csharp +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEngine; +using MCPForUnity.Editor.Tools; + +namespace MyProject.Editor.Tools +{ + [McpForUnityTool("capture_screenshot")] + public static class CaptureScreenshotTool + { + public static object HandleCommand(JObject @params) + { + string filename = @params["filename"]?.ToString(); + + if (string.IsNullOrEmpty(filename)) + { + return MCPForUnity.Editor.Helpers.Response.Error("filename is required"); + } + + try + { + string absolutePath = Path.Combine(Application.dataPath, "Screenshots", filename); + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)); + + // Find the main camera + Camera camera = Camera.main; + if (camera == null) + { + camera = Object.FindFirstObjectByType(); + } + + if (camera == null) + { + return MCPForUnity.Editor.Helpers.Response.Error("No camera found in the scene"); + } + + // Create a RenderTexture + RenderTexture rt = new RenderTexture(Screen.width, Screen.height, 24); + camera.targetTexture = rt; + + // Render the camera's view + camera.Render(); + + // Read pixels from the RenderTexture + RenderTexture.active = rt; + Texture2D screenshot = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false); + screenshot.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); + screenshot.Apply(); + + // Clean up + camera.targetTexture = null; + RenderTexture.active = null; + Object.DestroyImmediate(rt); + + // Save to file + byte[] bytes = screenshot.EncodeToPNG(); + File.WriteAllBytes(absolutePath, bytes); + Object.DestroyImmediate(screenshot); + + return MCPForUnity.Editor.Helpers.Response.Success($"Screenshot saved to {absolutePath}", new + { + path = absolutePath, + }); + } + catch (System.Exception ex) + { + return MCPForUnity.Editor.Helpers.Response.Error($"Failed to capture screenshot: {ex.Message}"); + } + } + } +} +``` + +## Best Practices + +### Python +- ✅ Use type hints with `Annotated` for parameter documentation +- ✅ Return `dict[str, Any]` with `{"success": bool, "message": str, "data": Any}` +- ✅ Use `ctx.info()` for logging +- ✅ Handle errors gracefully and return structured error responses +- ✅ Use `send_command_with_retry()` for Unity communication + +### C# +- ✅ Use the `Response.Success()` and `Response.Error()` helper methods +- ✅ Validate input parameters before processing +- ✅ Use `@params["key"]?.ToObject()` for safe type conversion +- ✅ Return structured responses with meaningful data +- ✅ Handle exceptions and return error responses + +## Debugging + +### Python +- Check server logs: `~/Library/Application Support/UnityMCP/Logs/unity_mcp_server.log` +- Look for: `"Registered X MCP tools"` message on startup +- Use `ctx.info()` for debugging messages + +### C# +- Check Unity Console for: `"MCP-FOR-UNITY: Auto-discovered X tools"` message +- Look for warnings about missing `HandleCommand` methods +- Use `Debug.Log()` in your handler for debugging + +## Troubleshooting + +**Tool not appearing:** +- Python: Ensure the file is in `tools/` directory and imports the decorator +- C#: Ensure the class has `[McpForUnityTool]` attribute and `HandleCommand` method + +**Name conflicts:** +- Use explicit names in decorators/attributes to avoid conflicts +- Check registered tools: `CommandRegistry.GetAllCommandNames()` in C# + +**Tool not being called:** +- Verify the command name matches between Python and C# +- Check that parameters are being passed correctly +- Look for errors in logs diff --git a/README-DEV-zh.md b/docs/README-DEV-zh.md similarity index 99% rename from README-DEV-zh.md rename to docs/README-DEV-zh.md index 1513cf95..f299a1b5 100644 --- a/README-DEV-zh.md +++ b/docs/README-DEV-zh.md @@ -31,7 +31,7 @@ python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity- **选项:** - **1** 上游主分支 (CoplayDev/unity-mcp) - **2** 远程当前分支 (origin + branch) -- **3** 本地工作区 (file: UnityMcpBridge) +- **3** 本地工作区 (file: MCPForUnity) 切换后,打开包管理器并刷新以重新解析包。 diff --git a/README-DEV.md b/docs/README-DEV.md similarity index 98% rename from README-DEV.md rename to docs/README-DEV.md index ddba6011..d1ef4e46 100644 --- a/README-DEV.md +++ b/docs/README-DEV.md @@ -21,7 +21,7 @@ Quick deployment and testing tools for MCP for Unity core changes. ## Switching MCP package sources quickly -Run this from the unity-mcp repo, not your game's roote directory. Use `mcp_source.py` to quickly switch between different MCP for Unity package sources: +Run this from the unity-mcp repo, not your game's root directory. Use `mcp_source.py` to quickly switch between different MCP for Unity package sources: **Usage:** ```bash @@ -31,7 +31,7 @@ python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity- **Options:** - **1** Upstream main (CoplayDev/unity-mcp) - **2** Remote current branch (origin + branch) -- **3** Local workspace (file: UnityMcpBridge) +- **3** Local workspace (file: MCPForUnity) After switching, open Package Manager and Refresh to re-resolve packages. diff --git a/TELEMETRY.md b/docs/TELEMETRY.md similarity index 99% rename from TELEMETRY.md rename to docs/TELEMETRY.md index f5105d6c..f424ed46 100644 --- a/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -107,7 +107,7 @@ Files created: ### Testing Telemetry ```bash -cd UnityMcpBridge/UnityMcpServer~/src +cd MCPForUnity/UnityMcpServer~/src python test_telemetry.py ``` diff --git a/docs/screenshots/v5_01_uninstall.png b/docs/screenshots/v5_01_uninstall.png new file mode 100644 index 00000000..6e726781 Binary files /dev/null and b/docs/screenshots/v5_01_uninstall.png differ diff --git a/docs/screenshots/v5_02_install.png b/docs/screenshots/v5_02_install.png new file mode 100644 index 00000000..8cf0a9fd Binary files /dev/null and b/docs/screenshots/v5_02_install.png differ diff --git a/docs/screenshots/v5_03_open_mcp_window.png b/docs/screenshots/v5_03_open_mcp_window.png new file mode 100644 index 00000000..1ea0d6f5 Binary files /dev/null and b/docs/screenshots/v5_03_open_mcp_window.png differ diff --git a/docs/screenshots/v5_04_rebuild_mcp_server.png b/docs/screenshots/v5_04_rebuild_mcp_server.png new file mode 100644 index 00000000..260f24ef Binary files /dev/null and b/docs/screenshots/v5_04_rebuild_mcp_server.png differ diff --git a/docs/screenshots/v5_05_rebuild_success.png b/docs/screenshots/v5_05_rebuild_success.png new file mode 100644 index 00000000..b9293ae9 Binary files /dev/null and b/docs/screenshots/v5_05_rebuild_success.png differ diff --git a/docs/v5_MIGRATION.md b/docs/v5_MIGRATION.md new file mode 100644 index 00000000..91aa87e9 --- /dev/null +++ b/docs/v5_MIGRATION.md @@ -0,0 +1,54 @@ +# MCP for Unity v5 Migration Guide + +This guide will help you migrate from the legacy UnityMcpBridge installation to the new MCPForUnity package structure in version 5. + +## Overview + +Version 5 introduces a new package structure. The package is now installed from the `MCPForUnity` folder instead of the legacy `UnityMcpBridge` folder. + +## Migration Steps + +### Step 1: Uninstall the Current Package + +1. Open the Unity Package Manager (**Window > Package Manager**) +2. Select **Packages: In Project** from the dropdown +3. Find **MCP for Unity** in the list +4. Click the **Remove** button to uninstall the legacy package + +![Uninstalling the legacy package](screenshots/v5_01_uninstall.png) + +### Step 2: Install from the New Path + +1. In the Package Manager, click the **+** button in the top-left corner +2. Select **Add package from disk...** +3. Navigate to the `MCPForUnity` folder (NOT the old `UnityMcpBridge` folder) +4. Select the `package.json` file inside the `MCPForUnity` folder +5. Click **Open** to install the package + +![Installing from the new MCPForUnity path](screenshots/v5_02_install.png) + +### Step 3: Rebuild MCP Server + +After installing the new package, you need to rebuild the MCP server: + +1. In Unity, go to **Window > MCP for Unity > Open MCP Window** +![Opening the MCP window](screenshots/v5_03_open_mcp_window.png) +2. Click the **Rebuild MCP Server** button +![Rebuilding the MCP server](screenshots/v5_04_rebuild_mcp_server.png) +3. You should see a success message confirming the rebuild +![Rebuild success](screenshots/v5_05_rebuild_success.png) + +## Verification + +After completing these steps, verify the migration was successful: + +- Check that the package appears in the Package Manager as **MCP for Unity** +- Confirm the package location shows the new `MCPForUnity` path +- Test basic MCP functionality to ensure everything works correctly + +## Troubleshooting + +- Check the Unity Console for specific error messages +- Ensure Python dependencies are properly installed +- Try pressing the rebuild button again +- Try restarting Unity and repeating the installation steps diff --git a/mcp_source.py b/mcp_source.py index 7d5a48a3..4b1390ea 100755 --- a/mcp_source.py +++ b/mcp_source.py @@ -9,22 +9,20 @@ Choices: 1) Upstream main (CoplayDev/unity-mcp) 2) Your remote current branch (derived from `origin` and current branch) - 3) Local repo workspace (file: URL to UnityMcpBridge in your checkout) + 3) Local repo workspace (file: URL to MCPForUnity in your checkout) """ from __future__ import annotations import argparse import json -import os import pathlib -import re import subprocess import sys from typing import Optional PKG_NAME = "com.coplaydev.unity-mcp" -BRIDGE_SUBPATH = "UnityMcpBridge" +BRIDGE_SUBPATH = "MCPForUnity" def run_git(repo: pathlib.Path, *args: str) -> str: @@ -32,7 +30,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 +76,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: @@ -92,7 +92,7 @@ def write_json(path: pathlib.Path, data: dict) -> None: def build_options(repo_root: pathlib.Path, branch: str, origin_https: str): - upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge" + upstream = "git+https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity" # Ensure origin is https origin = origin_https # If origin is a local file path or non-https, try to coerce to https github if possible @@ -103,16 +103,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 +158,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/prune_tool_results.py b/prune_tool_results.py index b5a53d30..a3c5d7a4 100755 --- a/prune_tool_results.py +++ b/prune_tool_results.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -import sys, json, re +import sys, json def summarize(txt): try: diff --git a/restore-dev.bat b/restore-dev.bat index 51ca2286..6f68be0b 100644 --- a/restore-dev.bat +++ b/restore-dev.bat @@ -5,7 +5,7 @@ echo =============================================== echo MCP for Unity Development Restore Script echo =============================================== echo. -echo Note: The Python server is bundled under UnityMcpBridge\UnityMcpServer~ in the package. +echo Note: The Python server is bundled under MCPForUnity\UnityMcpServer~ in the package. echo This script restores your installed server path from backups, not the repo copy. echo. 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..bf4e9b79 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -5,14 +5,19 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") -class _Dummy: pass + + +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..64b4843c 100644 --- a/tests/test_edit_strict_and_warnings.py +++ b/tests/test_edit_strict_and_warnings.py @@ -5,14 +5,19 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp mcp_pkg = types.ModuleType("mcp") server_pkg = types.ModuleType("mcp.server") fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") -class _Dummy: pass + + +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..92216f60 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 @@ -6,10 +7,9 @@ import pytest ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "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..65b59b01 100644 --- a/tests/test_get_sha.py +++ b/tests/test_get_sha.py @@ -5,7 +5,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp to satisfy imports without full dependency @@ -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..cf3ced1f 100644 --- a/tests/test_improved_anchor_matching.py +++ b/tests/test_improved_anchor_matching.py @@ -9,7 +9,7 @@ # add server src to path and load modules ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp @@ -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..c69e1af3 100644 --- a/tests/test_logging_stdout.py +++ b/tests/test_logging_stdout.py @@ -7,7 +7,7 @@ # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] candidates = [ - ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) @@ -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..3d693baa 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,11 +6,10 @@ import pytest - # Locate server src dynamically to avoid hardcoded layout assumptions (same as other tests) ROOT = Path(__file__).resolve().parents[1] candidates = [ - ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) @@ -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..dab8f904 100644 --- a/tests/test_read_console_truncate.py +++ b/tests/test_read_console_truncate.py @@ -4,7 +4,7 @@ import types ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp @@ -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..10ecf33f 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 @@ -5,7 +6,7 @@ import pytest ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # Stub mcp.server.fastmcp to satisfy imports without full package @@ -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..94243e34 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 @@ -9,7 +10,7 @@ # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] candidates = [ - ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) @@ -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..f6e3c8a1 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -7,7 +7,7 @@ # add server src to path and load modules without triggering package imports ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp to satisfy imports without full dependency @@ -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..c896860d 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( + "MCPForUnity.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( + "MCPForUnity.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( + "MCPForUnity.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( + "MCPForUnity.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..a0b54529 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -8,7 +8,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # Stub mcp.server.fastmcp to satisfy imports without the full dependency @@ -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..18ece981 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( + "MCPForUnity.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..765a05a4 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 @@ -12,7 +13,7 @@ # locate server src dynamically to avoid hardcoded layout assumptions ROOT = Path(__file__).resolve().parents[1] candidates = [ - ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src", + ROOT / "MCPForUnity" / "UnityMcpServer~" / "src", ROOT / "UnityMcpServer~" / "src", ] SRC = next((p for p in candidates if p.exists()), None) @@ -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..971b52b7 100644 --- a/tests/test_validate_script_summary.py +++ b/tests/test_validate_script_summary.py @@ -4,7 +4,7 @@ import types ROOT = pathlib.Path(__file__).resolve().parents[1] -SRC = ROOT / "UnityMcpBridge" / "UnityMcpServer~" / "src" +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) # stub mcp.server.fastmcp similar to test_get_sha @@ -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 - -