chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# LLM Prompt for Updating Documentation
Copy and paste this prompt into your LLM when you need to update documentation after adding/removing/modifying MCP tools or resources.
## Example Usage
After adding a new tool called "manage_new_feature" and a new resource called "feature_resource", you would:
1. Copy the prompt in the section below
2. Paste it into your LLM
3. The LLM will analyze the codebase and update all documentation files
4. Review the changes and run the check script to verify
This ensures all documentation stays in sync across the repository.
---
## Prompt
I've just made changes to MCP tools or resources in this Unity MCP repository. Please update all documentation files to keep them in sync.
Here's what you need to do:
1. **Check the current tools and resources** by examining:
- `Server/src/services/tools/` - Python tool implementations (look for @mcp_for_unity_tool decorators)
- `Server/src/services/resources/` - Python resource implementations (look for @mcp_for_unity_resource decorators)
2. **Update these files**:
a) **manifest.json** (root directory)
- Update the "tools" array (lines 27-57)
- Each tool needs: {"name": "tool_name", "description": "Brief description"}
- Keep tools in alphabetical order
- Note: Resources are not listed in manifest.json, only tools
b) **README.md** (root directory)
- Update "Available Tools" section (around line 78-79)
- Format: `tool1``tool2``tool3`
- Keep the same order as manifest.json
c) **README.md** - Resources section
- Update "Available Resources" section (around line 81-82)
- Format: `resource1``resource2``resource3`
- Resources come from Server/src/services/resources/ files
- Keep resources in alphabetical order
d) **docs/i18n/README-zh.md**
- Find and update the "可用工具" (Available Tools) section
- Find and update the "可用资源" (Available Resources) section
- Keep tool/resource names in English, but you can translate descriptions if helpful
e) **README.md** — "Recent Updates" section
- Add a new entry at the top of the list for the current version
- Format: `* **vX.Y.Z (beta)** — Brief summary of what changed`
- Keep only 4 entries visible; move the oldest to the "Older releases" nested details block
- Remove `(beta)` from the previous entry that was beta
- Update `manifest.json` version field to match
f) **docs/i18n/README-zh.md** — "最近更新" section
- Mirror the same changes as the English "Recent Updates" section
- Translate the summary text to Chinese
- Same 4-entry rotation rule applies
g) **unity-mcp-skill** - Skill Update
- Detect if this feature needs extra care via Skills
- If so, update the .md files based on the updates
3. **Important formatting rules**:
- Use backticks around tool/resource names
- Separate items with • (bullet point)
- Keep lists on single lines when possible
- Maintain alphabetical ordering
- Tools and resources are listed separately in documentation
4. **After updating**, run this check to verify:
```bash
python3 tools/check_docs_sync.py
```
It should show "All documentation is synchronized!"
Please show me the exact changes you're making to each file, and explain any discrepancies you find.
---
+239
View File
@@ -0,0 +1,239 @@
#requires -Version 5
<#
.SYNOPSIS
Local parity check for the CI Unity-version matrix (Windows companion to check-unity-versions.sh).
.DESCRIPTION
Reads tools\unity-versions.json (the shared source of truth used by .github\workflows\unity-tests.yml)
and runs a compile-only batchmode pass on each Unity version installed via Unity Hub.
-Docker switches to running inside GameCI containers (unityci/editor:ubuntu-<id>-base-<tag>) instead
of looking for local Unity Hub installs. Requires Docker Desktop running and $env:UNITY_LICENSE set
to the contents of a Unity_lic.ulf file.
Exits non-zero if any *checked* version fails. Versions skipped (not installed locally / no image)
do not cause failure on their own.
.PARAMETER Full
Run the full EditMode test suite per version instead of compile-only. Matches CI behavior; slower.
.PARAMETER Only
Filter to versions whose id starts with this prefix (e.g. -Only 6000.0).
.PARAMETER Docker
Run each version inside a GameCI Docker container instead of a local Unity Hub install.
.PARAMETER DockerImageTag
Override the GameCI image tag suffix (default: 'base-3'). Pin to e.g. 'base-3.2.2' for reproducibility.
.PARAMETER PrePush
Hint mode used by the pre-push hook; changes the failure message to mention --no-verify.
.EXAMPLE
pwsh .\tools\check-unity-versions.ps1
pwsh .\tools\check-unity-versions.ps1 -Full
pwsh .\tools\check-unity-versions.ps1 -Only 6000.0
pwsh .\tools\check-unity-versions.ps1 -Docker
#>
[CmdletBinding()]
param(
[switch]$Full,
[string]$Only = "",
[switch]$Docker,
[string]$DockerImageTag = "base-3",
[switch]$PrePush
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$VersionsJson = Join-Path $RepoRoot "tools\unity-versions.json"
$ProjectPath = Join-Path $RepoRoot "TestProjects\UnityMCPTests"
$LogDir = Join-Path $RepoRoot "tools\.unity-check-logs"
if (-not (Test-Path $VersionsJson)) { throw "Missing: $VersionsJson" }
if (-not (Test-Path $ProjectPath)) { throw "Missing project: $ProjectPath" }
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
# ---- mode setup --------------------------------------------------------------
if ($Docker) {
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Write-Error "-Docker requires Docker Desktop (https://docs.docker.com/get-docker/)"
exit 2
}
& docker info *> $null
if ($LASTEXITCODE -ne 0) {
Write-Error "Docker daemon not reachable. Start Docker Desktop and retry."
exit 2
}
if (-not $env:UNITY_LICENSE) {
Write-Host "error: -Docker requires a Unity license. Set `$env:UNITY_LICENSE to the contents of a Unity_lic.ulf file." -ForegroundColor Red
Write-Host ""
Write-Host "One-time setup (free Personal license):"
Write-Host " 1. Generate a request file inside a GameCI container:"
Write-Host " docker run --rm -v `"`${PWD}:/work`" unityci/editor:ubuntu-2021.3.45f2-base-3 ``"
Write-Host " /opt/unity/Editor/Unity -batchmode -nographics -quit -createManualActivationFile ``"
Write-Host " -logFile /dev/stdout"
Write-Host " This writes Unity_v<version>.alf to your current directory."
Write-Host " 2. Upload that .alf at https://license.unity3d.com/manual -> Personal -> save the .ulf it returns."
Write-Host " 3. Persist the license in your PowerShell profile:"
Write-Host " `$env:UNITY_LICENSE = Get-Content C:\path\to\Unity_v<version>.ulf -Raw"
Write-Host " 4. Re-run this script."
Write-Host ""
Write-Host "(The same UNITY_LICENSE secret is what the GitHub Actions workflow uses; one .ulf works across all"
Write-Host "matrix versions in practice -- Unity Personal activations are tied to the machine, not the editor version.)"
exit 2
}
} else {
# Unity Hub installs editors under one of these roots on Windows.
$HubRoots = @()
foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
if ($base) {
$candidate = Join-Path $base "Unity\Hub\Editor"
if (Test-Path $candidate) { $HubRoots += $candidate }
}
}
if ($HubRoots.Count -eq 0) {
Write-Warning "Unity Hub editor root not found under Program Files. Install at least one editor or use -Docker."
}
}
function Get-UnityBin([string]$Version) {
foreach ($root in $HubRoots) {
$candidate = Join-Path $root "$Version\Editor\Unity.exe"
if (Test-Path $candidate) { return $candidate }
}
return $null
}
$Manifest = Get-Content $VersionsJson -Raw | ConvertFrom-Json
$Versions = $Manifest.versions | ForEach-Object { $_.id }
if ($Only) {
$Versions = $Versions | Where-Object { $_.StartsWith($Only) }
if ($Versions.Count -eq 0) {
Write-Error "No versions matched -Only '$Only'"
exit 2
}
}
$modeLabel = if ($Full) { "full EditMode test run" } else { "compile-only" }
$runnerLabel = if ($Docker) { "GameCI Docker ($DockerImageTag)" } else { "local Unity Hub" }
Write-Host "Unity-version check ($modeLabel, $runnerLabel) -- $($Versions.Count) version(s) requested"
Write-Host " Project: $ProjectPath"
Write-Host " Logs: $LogDir"
Write-Host ""
function Invoke-LocalUnity([string]$Version, [string]$LogFile) {
$unityBin = Get-UnityBin $Version
if (-not $unityBin) {
Write-Host " [SKIP] $Version -- not installed under any Unity Hub root" -ForegroundColor Yellow
return 2 # skip sentinel
}
Write-Host -NoNewline " [ .. ] $Version -- running...`r"
# -quit on both paths so Unity batchmode always exits; without it -runTests can hang on test framework shutdown.
if ($Full) {
$unityArgs = @("-batchmode", "-quit", "-nographics", "-projectPath", $ProjectPath, "-runTests", "-testPlatform", "editmode", "-logFile", $LogFile)
} else {
$unityArgs = @("-batchmode", "-quit", "-nographics", "-projectPath", $ProjectPath, "-logFile", $LogFile)
}
$proc = Start-Process -FilePath $unityBin -ArgumentList $unityArgs -NoNewWindow -PassThru -Wait
if ($proc.ExitCode -eq 0) { return 0 } else { return 1 }
}
function Invoke-DockerUnity([string]$Version, [string]$LogFile) {
$image = "unityci/editor:ubuntu-$Version-$DockerImageTag"
Write-Host -NoNewline " [ .. ] $Version -- pulling $image ...`r"
& docker pull $image *>> $LogFile
if ($LASTEXITCODE -ne 0) {
Write-Host " [FAIL] $Version -- image pull failed ($image); see $LogFile" -ForegroundColor Red
Write-Host " Pull errors:"
Get-Content $LogFile -Tail 5 | ForEach-Object { Write-Host " $_" }
return 1
}
Write-Host -NoNewline " [ .. ] $Version -- running in container...`r"
# -quit on both paths (see Invoke-LocalUnity note).
$unityExtra = if ($Full) { "-quit -runTests -testPlatform editmode" } else { "-quit" }
# Convert Windows path to Docker-friendly format for -v.
$projectMount = $ProjectPath.Replace('\', '/')
$script = @"
set -e
mkdir -p /root/.local/share/unity3d/Unity
printf '%s' "`$UNITY_LICENSE" > /root/.local/share/unity3d/Unity/Unity_lic.ulf
/opt/unity/Editor/Unity -batchmode -nographics -projectPath /project $unityExtra -logFile /dev/stdout
"@
& docker run --rm `
--platform linux/amd64 `
-e UNITY_LICENSE `
-v "${projectMount}:/project" `
--entrypoint /bin/bash `
$image `
-c $script *>> $LogFile
if ($LASTEXITCODE -eq 0) { return 0 } else { return 1 }
}
$pass = 0; $fail = 0; $skip = 0
foreach ($version in $Versions) {
$logFile = Join-Path $LogDir "$version.log"
"" | Set-Content -Path $logFile # truncate stale
if ($Docker) {
$rc = Invoke-DockerUnity $version $logFile
} else {
$rc = Invoke-LocalUnity $version $logFile
}
switch ($rc) {
0 { Write-Host " [PASS] $version " -ForegroundColor Green; $pass++ }
2 { $skip++ }
default {
Write-Host " [FAIL] $version -- see $logFile" -ForegroundColor Red
$compileErrors = Select-String -Path $logFile -Pattern "error CS\d+" -ErrorAction SilentlyContinue
if ($compileErrors) {
Write-Host " Compile errors:"
$compileErrors | Select-Object -First 10 | ForEach-Object { Write-Host " $($_.Line)" }
} else {
Write-Host " Last 20 lines of log:"
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" }
}
$fail++
}
}
}
Write-Host ""
Write-Host "Summary: $pass passed, $fail failed, $skip skipped (of $($Versions.Count) configured)"
if ($fail -gt 0) {
if ($PrePush) {
Write-Host ""
Write-Host "Pre-push check failed. To push anyway (skipping this hook): git push --no-verify"
}
exit 1
}
if ($pass -eq 0 -and $skip -gt 0) {
Write-Host ""
if ($Docker) {
Write-Host "Note: no versions ran. Check image pull errors above."
} else {
Write-Host "Note: no versions from tools/unity-versions.json are installed on this machine."
Write-Host "Either install via Unity Hub or use -Docker (see Get-Help for license setup)."
}
}
exit 0
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# Local parity check for the CI Unity-version matrix.
#
# Usage:
# tools/check-unity-versions.sh # compile-only, all installed versions from tools/unity-versions.json
# tools/check-unity-versions.sh --full # full EditMode test run (matches CI behavior)
# tools/check-unity-versions.sh --only 6000.0 # check only versions whose id starts with the given prefix
# tools/check-unity-versions.sh --docker # run inside GameCI containers (no local Unity Hub install needed)
# tools/check-unity-versions.sh --pre-push # hint mode used by the pre-push hook (changes failure message)
#
# Modes:
# - Default (local): looks for Unity editors under Unity Hub. Versions not installed are skipped.
# - --docker: runs each version inside unityci/editor:ubuntu-<id>-base-<tag>. Requires UNITY_LICENSE env
# (contents of a .ulf file). On macOS arm64, expect ~5-10× slowdown from amd64 emulation.
#
# Exits non-zero if any *checked* version fails. Versions skipped (not installed locally / image not pulled
# in offline mode) do not cause failure on their own.
#
# Linked to CI: both this script and .github/workflows/unity-tests.yml read tools/unity-versions.json.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERSIONS_JSON="${REPO_ROOT}/tools/unity-versions.json"
PROJECT_PATH="${REPO_ROOT}/TestProjects/UnityMCPTests"
LOG_DIR="${REPO_ROOT}/tools/.unity-check-logs"
# Default GameCI image tag suffix. GameCI publishes both sliding (base-3) and pinned (base-3.1.0) tags;
# we default to the major-major (base-3) sliding tag and let users pin via --docker-image-tag.
DOCKER_IMAGE_TAG="base-3"
FULL=0
ONLY=""
PRE_PUSH=0
USE_DOCKER=0
require_value() {
# Validate that a flag taking a value got one (not another flag, not nothing).
local flag="$1" value="${2:-}"
if [[ -z "$value" || "$value" == --* ]]; then
echo "error: $flag requires a value" >&2
exit 2
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--full|--with-tests) FULL=1 ;;
--only) require_value "$1" "${2:-}"; ONLY="$2"; shift ;;
--docker) USE_DOCKER=1 ;;
--docker-image-tag) require_value "$1" "${2:-}"; DOCKER_IMAGE_TAG="$2"; shift ;;
--pre-push) PRE_PUSH=1 ;;
-h|--help)
sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
shift
done
if ! command -v jq >/dev/null 2>&1; then
echo "error: 'jq' is required (brew install jq / apt-get install jq)" >&2
exit 2
fi
if [[ ! -f "$VERSIONS_JSON" ]]; then
echo "error: $VERSIONS_JSON missing" >&2
exit 2
fi
if [[ ! -d "$PROJECT_PATH" ]]; then
echo "error: project path not found: $PROJECT_PATH" >&2
exit 2
fi
mkdir -p "$LOG_DIR"
# ---- mode setup ---------------------------------------------------------------------
if [[ $USE_DOCKER -eq 1 ]]; then
if ! command -v docker >/dev/null 2>&1; then
echo "error: --docker requires Docker (https://docs.docker.com/get-docker/)" >&2
exit 2
fi
if ! docker info >/dev/null 2>&1; then
echo "error: Docker daemon not reachable. Start Docker Desktop / dockerd and retry." >&2
exit 2
fi
if [[ -z "${UNITY_LICENSE:-}" ]]; then
cat >&2 <<'EOF'
error: --docker requires a Unity license. Set UNITY_LICENSE to the contents of a Unity_lic.ulf file.
One-time setup (free Personal license):
1. Generate a request file inside a GameCI container:
docker run --rm -v "$PWD":/work unityci/editor:ubuntu-2021.3.45f2-base-3 \
/opt/unity/Editor/Unity -batchmode -nographics -quit -createManualActivationFile \
-logFile /dev/stdout
This writes Unity_v<version>.alf to your current directory.
2. Upload that .alf at https://license.unity3d.com/manual → choose Personal → save the .ulf it returns.
3. Export the .ulf contents in your shell (add to ~/.zshrc or similar to persist):
export UNITY_LICENSE="$(cat /path/to/Unity_v<version>.ulf)"
4. Re-run this script.
(The same UNITY_LICENSE secret is what the GitHub Actions workflow uses; one .ulf works across all matrix
versions in practice — Unity Personal activations are tied to the machine, not the editor version.)
EOF
exit 2
fi
if [[ "$(uname -m)" == "arm64" || "$(uname -m)" == "aarch64" ]]; then
if [[ "$(uname -s)" == "Darwin" ]]; then
echo "note: arm64 Mac — GameCI images run via amd64 emulation; expect ~5-10× slowdown."
fi
fi
else
case "$(uname -s)" in
Darwin) HUB_ROOT="/Applications/Unity/Hub/Editor" ; UNITY_RELPATH="Unity.app/Contents/MacOS/Unity" ;;
Linux) HUB_ROOT="${HOME}/Unity/Hub/Editor" ; UNITY_RELPATH="Editor/Unity" ;;
*) echo "error: unsupported OS '$(uname -s)' — use check-unity-versions.ps1 on Windows, or --docker on any platform" >&2; exit 2 ;;
esac
fi
# Pretty output even without colors set up.
if [[ -t 1 ]]; then
C_OK=$'\033[32m'; C_FAIL=$'\033[31m'; C_SKIP=$'\033[33m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_SKIP=""; C_DIM=""; C_RST=""
fi
VERSIONS=()
while IFS= read -r line; do
VERSIONS+=("$line")
done < <(jq -r '.versions[].id' "$VERSIONS_JSON")
if [[ -n "$ONLY" ]]; then
filtered=()
for v in "${VERSIONS[@]}"; do
[[ "$v" == "$ONLY"* ]] && filtered+=("$v")
done
if [[ ${#filtered[@]} -eq 0 ]]; then
echo "No versions matched --only '$ONLY'" >&2
exit 2
fi
VERSIONS=("${filtered[@]}")
fi
mode_label="compile-only"
[[ $FULL -eq 1 ]] && mode_label="full EditMode test run"
runner_label="local Unity Hub"
[[ $USE_DOCKER -eq 1 ]] && runner_label="GameCI Docker (${DOCKER_IMAGE_TAG})"
echo "Unity-version check (${mode_label}, ${runner_label}) — ${#VERSIONS[@]} version(s) requested"
echo " Project: $PROJECT_PATH"
echo " Logs: $LOG_DIR"
echo
fail_count=0
pass_count=0
skip_count=0
# ---- per-version runners ------------------------------------------------------------
run_local() {
local version="$1" log_file="$2"
local unity_bin="${HUB_ROOT}/${version}/${UNITY_RELPATH}"
if [[ ! -x "$unity_bin" ]]; then
echo " ${C_SKIP}[SKIP]${C_RST} ${version} — not installed (expected at ${C_DIM}${unity_bin}${C_RST})"
return 2 # skip
fi
printf " [ .. ] %s — running...\r" "$version"
# -quit on both paths so Unity batchmode always exits — without it -runTests can hang waiting
# on test framework shutdown on some Unity versions.
local args
if [[ $FULL -eq 1 ]]; then
args=(-batchmode -quit -nographics -projectPath "$PROJECT_PATH" -runTests -testPlatform editmode -logFile "$log_file")
else
args=(-batchmode -quit -nographics -projectPath "$PROJECT_PATH" -logFile "$log_file")
fi
if "$unity_bin" "${args[@]}" >/dev/null 2>&1; then
return 0
else
return 1
fi
}
run_docker() {
local version="$1" log_file="$2"
local image="unityci/editor:ubuntu-${version}-${DOCKER_IMAGE_TAG}"
printf " [ .. ] %s — pulling %s ...\r" "$version" "$image"
if ! docker pull "$image" >>"$log_file" 2>&1; then
echo " ${C_FAIL}[FAIL]${C_RST} ${version} — image pull failed (${C_DIM}${image}${C_RST}); see ${log_file}"
echo " Pull errors:"
tail -5 "$log_file" | sed 's/^/ /'
return 1
fi
printf " [ .. ] %s — running in container...\r" "$version"
# GameCI images run as root and expect the .ulf at /root/.local/share/unity3d/Unity/Unity_lic.ulf.
# Write the license from env on container start, then run Unity with -logFile /dev/stdout so
# everything (mkdir output, Unity compile log, errors) streams through docker's stdout into our
# host log_file via a single `>> "$log_file"` redirection. No bind-mount race.
# -quit on both paths (see run_local note).
local unity_extra
if [[ $FULL -eq 1 ]]; then
unity_extra="-quit -runTests -testPlatform editmode"
else
unity_extra="-quit"
fi
if docker run --rm \
--platform linux/amd64 \
-e UNITY_LICENSE \
-v "${PROJECT_PATH}:/project" \
--entrypoint /bin/bash \
"$image" \
-c 'set -e
mkdir -p /root/.local/share/unity3d/Unity
printf "%s" "$UNITY_LICENSE" > /root/.local/share/unity3d/Unity/Unity_lic.ulf
/opt/unity/Editor/Unity -batchmode -nographics -projectPath /project '"$unity_extra"' -logFile /dev/stdout' \
>>"$log_file" 2>&1; then
return 0
else
return 1
fi
}
# ---- main loop ----------------------------------------------------------------------
for version in "${VERSIONS[@]}"; do
log_file="${LOG_DIR}/${version}.log"
: >"$log_file" # truncate stale log
if [[ $USE_DOCKER -eq 1 ]]; then
run_docker "$version" "$log_file" && rc=0 || rc=$?
else
run_local "$version" "$log_file" && rc=0 || rc=$?
fi
case "$rc" in
0)
echo " ${C_OK}[PASS]${C_RST} ${version} "
pass_count=$((pass_count + 1))
;;
2)
skip_count=$((skip_count + 1))
;;
*)
echo " ${C_FAIL}[FAIL]${C_RST} ${version} — see ${C_DIM}${log_file}${C_RST}"
if grep -q "error CS" "$log_file" 2>/dev/null; then
echo " Compile errors:"
grep -E "error CS[0-9]+" "$log_file" | head -10 | sed 's/^/ /'
else
echo " Last 20 lines of log:"
tail -20 "$log_file" | sed 's/^/ /'
fi
fail_count=$((fail_count + 1))
;;
esac
done
echo
echo "Summary: ${pass_count} passed, ${fail_count} failed, ${skip_count} skipped (of ${#VERSIONS[@]} configured)"
if [[ $fail_count -gt 0 ]]; then
if [[ $PRE_PUSH -eq 1 ]]; then
echo
echo "Pre-push check failed. To push anyway (skipping this hook): git push --no-verify"
fi
exit 1
fi
if [[ $pass_count -eq 0 && $skip_count -gt 0 ]]; then
echo
if [[ $USE_DOCKER -eq 1 ]]; then
echo "Note: no versions ran. Check image pull errors above."
else
echo "Note: no versions from tools/unity-versions.json are installed on this machine."
echo "Either install via Unity Hub or use --docker (see --help for license setup)."
fi
fi
exit 0
+54
View File
@@ -0,0 +1,54 @@
# Publish a Docker image (manual).
#
# Requirements:
# - Docker installed with buildx support (e.g. Docker Desktop).
# - Authenticated to the target registry (e.g. run: docker login).
# - Run from the `tools/` directory (this script uses build context `.` and Dockerfile `../Server/Dockerfile`).
#
# Usage:
# ./docker_publish.sh <image> <version>
# IMAGE=<image> ./docker_publish.sh <version>
#
# Examples:
# ./docker_publish.sh msanatan/mcp-for-unity-server 9.3.1
# IMAGE=msanatan/mcp-for-unity-server ./docker_publish.sh v9.3.1
#
# Tags pushed:
# - vX.Y.Z
# - vX.Y
# - vX
set -euo pipefail
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $(basename "$0") <image> <version>" >&2
echo " $(basename "$0") <version> # if IMAGE env var is set" >&2
echo "Example: $(basename "$0") youruser/mcp-for-unity-server 1.2.3" >&2
exit 2
fi
if [[ "${2:-}" != "" ]]; then
IMAGE="$1"
VERSION_RAW="$2"
else
if [[ "${IMAGE:-}" == "" ]]; then
echo "Error: IMAGE env var is required when calling with a single arg." >&2
echo "Usage: $(basename "$0") <image> <version>" >&2
exit 2
fi
VERSION_RAW="$1"
fi
VERSION="${VERSION_RAW#v}"
MAJOR="${VERSION%%.*}"
MINOR="${VERSION%.*}" # leaves X.Y
# (works for X.Y.Z)
docker buildx build \
--platform linux/amd64 \
-f ../Server/Dockerfile \
-t "$IMAGE:v$VERSION" \
-t "$IMAGE:v$MINOR" \
-t "$IMAGE:v$MAJOR" \
--push \
.
+628
View File
@@ -0,0 +1,628 @@
#!/usr/bin/env python3
"""
Generate Docusaurus reference pages for MCP for Unity tools and resources.
Single source of truth: the Python `@mcp_for_unity_tool` and
`@mcp_for_unity_resource` registries under Server/src/services/. The C#
attributes carry only Name/Group/Description; the Python decorator owns the
richest typing (Annotated[...] parameter docs) and is what the MCP client
actually sees over the wire.
Outputs:
website/docs/reference/tools/<group>/<tool-name>.md — one per tool
website/docs/reference/tools/<group>/index.md — group landing
website/docs/reference/tools/index.md — catalog landing
website/docs/reference/resources/index.md — resources catalog
Modes:
--write (default) regenerate files in place
--check re-emit to a temp dir and diff against committed files;
exits non-zero on drift (used by CI / pre-commit hook)
Hand-authored example blocks between <!-- examples:start --> and
<!-- examples:end --> are preserved across regeneration.
Run requirements: the Server/ Python dependencies must be importable, since
we load every tool module to trigger decorator registration. In CI:
cd Server && uv sync && cd .. && uv --project Server run python tools/generate_docs_reference.py --check
"""
from __future__ import annotations
import argparse
import filecmp
import importlib
import inspect
import json
import re
import shutil
import sys
import tempfile
import textwrap
import typing
from dataclasses import dataclass
from pathlib import Path
from types import GenericAlias
from typing import Annotated, Any, Literal, Union, get_args, get_origin
REPO_ROOT = Path(__file__).resolve().parent.parent
SERVER_SRC = REPO_ROOT / "Server" / "src"
WEBSITE_DOCS = REPO_ROOT / "website" / "docs"
TOOLS_OUT = WEBSITE_DOCS / "reference" / "tools"
RESOURCES_OUT = WEBSITE_DOCS / "reference" / "resources"
GENERATED_BANNER = (
"> **Auto-generated** from the Python tool registry. Do not hand-edit "
"outside `<!-- examples:start --><!-- examples:end -->` blocks — the "
"generator (`tools/generate_docs_reference.py`) will overwrite them."
)
EXAMPLES_OPEN = "<!-- examples:start -->"
EXAMPLES_CLOSE = "<!-- examples:end -->"
EXAMPLES_PLACEHOLDER = (
f"{EXAMPLES_OPEN}\n"
"*No examples yet. Add usage examples here — they will be preserved across regenerations.*\n"
f"{EXAMPLES_CLOSE}\n"
)
# ---------------------------------------------------------------------------
# Registry loading
# ---------------------------------------------------------------------------
def _ensure_server_on_path() -> None:
if str(SERVER_SRC) not in sys.path:
sys.path.insert(0, str(SERVER_SRC))
def load_registries() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Import every tool/resource module so the decorators fire, then return
the populated registries."""
_ensure_server_on_path()
from services.registry import ( # noqa: WPS433 (deferred import by design)
get_registered_tools,
get_registered_resources,
clear_tool_registry,
clear_resource_registry,
)
from utils.module_discovery import discover_modules
clear_tool_registry()
clear_resource_registry()
tools_pkg = importlib.import_module("services.tools")
resources_pkg = importlib.import_module("services.resources")
# Walk both directories and import every module — the @decorator
# side-effects populate the registries.
list(discover_modules(Path(tools_pkg.__file__).parent, tools_pkg.__name__))
list(discover_modules(Path(resources_pkg.__file__).parent, resources_pkg.__name__))
return get_registered_tools(), get_registered_resources()
# ---------------------------------------------------------------------------
# Parameter introspection
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ParamDoc:
name: str
type_str: str
required: bool
description: str | None
default: str | None
def _render_type(annotation: Any) -> str:
"""Render a typing annotation as a short Markdown-safe string."""
if annotation is inspect.Parameter.empty or annotation is None:
return "any"
origin = get_origin(annotation)
if origin is Annotated:
return _render_type(get_args(annotation)[0])
if origin in (Union, getattr(typing, "UnionType", Union)):
parts = [_render_type(a) for a in get_args(annotation) if a is not type(None)]
has_none = type(None) in get_args(annotation) or any(
part.endswith(" | None") or part == "None" for part in parts
)
# Strip any inner "| None" — we'll add a single one at the end if needed.
parts = [p[: -len(" | None")] if p.endswith(" | None") else p for p in parts]
rendered = " | ".join(p for p in parts if p and p != "None")
return rendered + (" | None" if has_none else "")
if origin is Literal:
literals = ", ".join(repr(a) for a in get_args(annotation))
return f"Literal[{literals}]"
if origin is list or annotation is list:
args = get_args(annotation)
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
return f"list[{inner}]"
if origin is dict or annotation is dict:
args = get_args(annotation)
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
return f"dict[{inner}]"
if origin is tuple or annotation is tuple:
args = get_args(annotation)
inner = ", ".join(_render_type(a) for a in args) if args else "Any"
return f"tuple[{inner}]"
if isinstance(annotation, type):
return annotation.__name__
if isinstance(annotation, GenericAlias): # e.g. list[str] without origin
return str(annotation)
return str(annotation).replace("typing.", "")
def _annotation_description(annotation: Any) -> str | None:
"""Pull the human-readable string from an Annotated[...] parameter."""
def _walk(a: Any) -> str | None:
origin = get_origin(a)
if origin is Annotated:
for meta in get_args(a)[1:]:
if isinstance(meta, str):
return meta
# Recurse into the underlying type — e.g. Annotated[str, "..."] | None
return _walk(get_args(a)[0])
if origin in (Union, getattr(typing, "UnionType", Union)):
for arg in get_args(a):
desc = _walk(arg)
if desc:
return desc
return None
return _walk(annotation)
def _is_required(param: inspect.Parameter) -> bool:
return param.default is inspect.Parameter.empty
def _render_default(default: Any) -> str | None:
if default is inspect.Parameter.empty:
return None
if default is None:
return "None"
return repr(default)
def introspect_params(func: Any) -> list[ParamDoc]:
sig = inspect.signature(func)
try:
hints = typing.get_type_hints(func, include_extras=True)
except Exception:
hints = {}
out: list[ParamDoc] = []
for name, param in sig.parameters.items():
if name in {"self", "cls", "ctx"}:
continue
annotation = hints.get(name, param.annotation)
out.append(
ParamDoc(
name=name,
type_str=_render_type(annotation),
required=_is_required(param),
description=_annotation_description(annotation),
default=_render_default(param.default),
)
)
return out
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z])|\n\s*\n")
def _first_sentence(description: str) -> str:
"""Return the first sentence of a tool description, suitable for
frontmatter / catalog blurbs.
The earlier implementation used `description.split(".")[0]` which
cut the string at the first period — including periods inside
abbreviations and parenthesized lists (e.g. `etc.) in Unity`),
producing truncated frontmatter like `"...modify, delete, etc"`.
Split on a real sentence boundary instead: a `.`, `!`, or `?`
followed by whitespace + a capital letter (or a paragraph break).
Fall back to the entire string if no boundary is found, then
smart-truncate to keep frontmatter compact.
"""
text = (description or "").strip().replace('"', "'")
if not text:
return ""
first = _SENTENCE_BOUNDARY.split(text, maxsplit=1)[0].strip()
# Cap absurdly long single-sentence descriptions
if len(first) > 240:
first = first[:237].rstrip() + ""
return first
def _escape_table_cell(s: str) -> str:
return s.replace("|", "\\|").replace("\n", " ")
def _read_existing_examples(path: Path) -> str:
"""Return the existing examples block from a generated file, if any.
Requires the start/end markers to sit on their own line so we don't
match the markers that appear inside the generator's own warning
banner (which references the literal marker strings)."""
if not path.exists():
return EXAMPLES_PLACEHOLDER
text = path.read_text(encoding="utf-8")
match = re.search(
rf"^{re.escape(EXAMPLES_OPEN)}\s*\n(.*?)^{re.escape(EXAMPLES_CLOSE)}\s*$",
text,
re.DOTALL | re.MULTILINE,
)
if not match:
return EXAMPLES_PLACEHOLDER
captured = match.group(1)
if not captured.strip():
return EXAMPLES_PLACEHOLDER
return f"{EXAMPLES_OPEN}\n{captured.strip()}\n{EXAMPLES_CLOSE}\n"
def render_tool_page(tool: dict[str, Any], existing_examples: str) -> str:
name = tool["name"]
description = (tool.get("description") or "").strip()
group = tool.get("group") or "core"
func = tool["func"]
module = getattr(func, "__module__", "")
params = introspect_params(func)
# Sidebar/title metadata
desc_for_meta = _first_sentence(description) or name
front_matter = textwrap.dedent(
f"""\
---
title: {name}
sidebar_label: {name}
description: "{desc_for_meta}"
---
"""
)
if params:
rows = ["| Name | Type | Required | Description |", "|------|------|----------|-------------|"]
for p in params:
req = "yes" if p.required else ""
desc = _escape_table_cell(p.description or "")
type_cell = _escape_table_cell(f"`{p.type_str}`")
rows.append(f"| `{p.name}` | {type_cell} | {req} | {desc} |")
params_section = "\n".join(rows)
else:
params_section = "_No parameters._"
return (
f"{front_matter}\n"
f"# `{name}`\n\n"
f"{GENERATED_BANNER}\n\n"
f"**Group:** `{group}` &nbsp;·&nbsp; "
f"**Module:** `{module}`\n\n"
f"## Description\n\n"
f"{description or '_No description provided._'}\n\n"
f"## Parameters\n\n"
f"{params_section}\n\n"
f"## Returns\n\n"
f"A `dict` containing the Unity response. The exact shape depends on the action.\n\n"
f"## Examples\n\n"
f"{existing_examples}\n"
)
def render_group_index(group: str, tools: list[dict[str, Any]], group_blurb: str) -> str:
front_matter = textwrap.dedent(
f"""\
---
title: "{group} tools"
sidebar_label: "{group}"
description: "MCP for Unity tools in the {group} group."
---
"""
)
bullets = []
for tool in sorted(tools, key=lambda t: t["name"]):
n = tool["name"]
d = _first_sentence(tool.get("description") or "")
bullets.append(f"- **[`{n}`](./{n}.md)** — {d}")
body = "\n".join(bullets) if bullets else "_No tools in this group._"
return (
f"{front_matter}\n"
f"# `{group}` tools\n\n"
f"{group_blurb}\n\n"
f"{body}\n"
)
def render_catalog_index(tools_by_group: dict[str, list[dict[str, Any]]],
group_blurbs: dict[str, str]) -> str:
front_matter = textwrap.dedent(
"""\
---
title: Tool reference
sidebar_label: Tools
sidebar_class_name: sidebar-hidden
slug: /reference/tools
description: Auto-generated catalog of every MCP for Unity tool, grouped by domain.
---
"""
)
sections = [
"# Tool reference\n",
GENERATED_BANNER + "\n",
"Every tool MCP for Unity exposes, generated directly from the Python "
"`@mcp_for_unity_tool` registry under `Server/src/services/tools/`.\n",
]
for group in sorted(tools_by_group):
tools = tools_by_group[group]
sections.append(f"## `{group}` &nbsp; ({len(tools)} tool{'s' if len(tools) != 1 else ''})")
sections.append(group_blurbs.get(group, ""))
for tool in sorted(tools, key=lambda t: t["name"]):
n = tool["name"]
d = _first_sentence(tool.get("description") or "")
sections.append(f"- **[`{n}`](./{group}/{n}.md)** — {d}")
sections.append("")
return front_matter + "\n" + "\n".join(sections) + "\n"
def render_resources_catalog(resources: list[dict[str, Any]]) -> str:
front_matter = textwrap.dedent(
"""\
---
title: Resource reference
sidebar_label: Resources
slug: /reference/resources
description: Auto-generated catalog of every MCP for Unity resource.
---
"""
)
head = (
"# Resource reference\n\n"
f"{GENERATED_BANNER}\n\n"
"Resources are read-only state surfaces exposed to MCP clients. "
"Tools mutate; resources observe.\n\n"
)
items = []
for res in sorted(resources, key=lambda r: r["name"]):
name = res["name"]
uri = res.get("uri", "")
desc = (res.get("description") or "").strip() or "_No description._"
func = res["func"]
params = introspect_params(func)
if params:
param_lines = ["", "**Parameters:**", ""]
for p in params:
req = "required" if p.required else "optional"
d = p.description or ""
param_lines.append(f"- `{p.name}` (`{p.type_str}`, {req}) — {d}")
param_block = "\n".join(param_lines)
else:
param_block = ""
items.append(
f"## `{name}`\n\n"
f"**URI:** `{uri}`\n\n"
f"{desc}\n"
f"{param_block}\n"
)
return front_matter + "\n" + head + "\n".join(items) + "\n"
# ---------------------------------------------------------------------------
# File-writing
# ---------------------------------------------------------------------------
GROUP_BLURBS_FALLBACK = {
"core": "Essential scene, script, asset, and editor tools — always on by default.",
"docs": "Unity API reflection and documentation lookup.",
"vfx": "Visual effects — VFX Graph, shaders, procedural textures.",
"animation": "Animator control and AnimationClip creation.",
"ui": "UI Toolkit — UXML, USS, UIDocument.",
"scripting_ext": "ScriptableObject management.",
"testing": "Test runner and async test jobs.",
"probuilder": "ProBuilder 3D modeling — requires `com.unity.probuilder`.",
"profiling": "Unity Profiler session control, counters, memory snapshots, Frame Debugger.",
}
def _resolve_group_blurbs() -> dict[str, str]:
"""Pull live blurbs from the registry, falling back to the local copy."""
try:
from services.registry import TOOL_GROUPS # type: ignore
return {g: blurb for g, blurb in TOOL_GROUPS.items()}
except Exception:
return GROUP_BLURBS_FALLBACK
def _write(path: Path, content: str) -> bool:
"""Write only if content differs. Return True if a write happened."""
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and path.read_text(encoding="utf-8") == content:
return False
path.write_text(content, encoding="utf-8")
return True
def generate(
tools_root: Path = TOOLS_OUT,
resources_root: Path = RESOURCES_OUT,
examples_source: Path | None = None,
) -> dict[str, int]:
"""Generate reference pages.
When `examples_source` is provided, hand-authored examples blocks are
read from there instead of from `tools_root`. `--check` uses this to
write into a tempdir while still preserving examples from the committed
canonical location.
"""
tools, resources = load_registries()
group_blurbs = _resolve_group_blurbs()
# Group tools.
tools_by_group: dict[str, list[dict[str, Any]]] = {}
for t in tools:
g = t.get("group") or "core"
tools_by_group.setdefault(g, []).append(t)
stats = {"tools": 0, "groups": 0, "resources": 0, "writes": 0}
examples_root = examples_source if examples_source is not None else tools_root
# Per-tool pages + per-group landing + Docusaurus category metadata.
for group, group_tools in sorted(tools_by_group.items()):
group_dir = tools_root / group
examples_dir = examples_root / group
for tool in sorted(group_tools, key=lambda t: t["name"]):
page_path = group_dir / f"{tool['name']}.md"
examples_path = examples_dir / f"{tool['name']}.md"
existing_examples = _read_existing_examples(examples_path)
page_md = render_tool_page(tool, existing_examples)
if _write(page_path, page_md):
stats["writes"] += 1
stats["tools"] += 1
index_md = render_group_index(group, group_tools, group_blurbs.get(group, ""))
if _write(group_dir / "index.md", index_md):
stats["writes"] += 1
# _category_.json tells the autogenerated sidebar to wrap this
# directory in a collapsible category. Without it, the group's
# tool pages render flat as siblings of the group index.
category_json = json.dumps(
{
"label": group,
"link": {"type": "doc", "id": f"reference/tools/{group}/index"},
"collapsed": True,
},
indent=2,
) + "\n"
if _write(group_dir / "_category_.json", category_json):
stats["writes"] += 1
stats["groups"] += 1
# Top-level catalog index. The sidebar's "Tools" parent category in
# sidebars.js is what links to this doc — no root `_category_.json`
# here, because that would compete with the parent's explicit link
# and end up listing the catalog as a duplicate "Tools" child.
catalog_md = render_catalog_index(tools_by_group, group_blurbs)
if _write(tools_root / "index.md", catalog_md):
stats["writes"] += 1
root_cat = tools_root / "_category_.json"
if root_cat.exists():
root_cat.unlink()
# Resources catalog (single page).
resources_md = render_resources_catalog(resources)
if _write(resources_root / "index.md", resources_md):
stats["writes"] += 1
stats["resources"] = len(resources)
return stats
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _copytree_into(src: Path, dst: Path) -> None:
if src.exists():
shutil.copytree(src, dst, dirs_exist_ok=True)
def _diff_trees(a: Path, b: Path) -> list[str]:
diffs: list[str] = []
def _walk(rel: Path) -> None:
cmp = filecmp.dircmp(a / rel, b / rel)
for name in cmp.left_only:
diffs.append(f"committed-only: {rel / name}")
for name in cmp.right_only:
diffs.append(f"generated-only: {rel / name}")
for name in cmp.diff_files:
diffs.append(f"differs: {rel / name}")
for name in cmp.common_dirs:
_walk(rel / name)
if a.exists() and b.exists():
_walk(Path("."))
elif b.exists():
diffs.append(f"committed missing entirely: {a}")
return diffs
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--check", action="store_true",
help="Generate into a temp dir and diff against the committed reference. Non-zero exit on drift.")
args = parser.parse_args(argv)
if args.check:
# Use a persistent dir under /tmp if MCP4U_KEEP_CHECK is set, so the
# user can diff committed vs generated by hand.
import os
keep = bool(os.environ.get("MCP4U_KEEP_CHECK"))
ctx = tempfile.TemporaryDirectory(prefix="mcp4u-docs-check-") if not keep else None
tmp = ctx.__enter__() if ctx else tempfile.mkdtemp(prefix="mcp4u-docs-check-keep-")
try:
tmp_root = Path(tmp)
tmp_tools = tmp_root / "tools"
tmp_resources = tmp_root / "resources"
# Read existing examples from the committed location so
# preservation is honored in --check too.
generate(tmp_tools, tmp_resources, examples_source=TOOLS_OUT)
if keep:
print(f"[--check] generated tree retained at {tmp_root}")
diffs = []
diffs.extend(_diff_trees(TOOLS_OUT, tmp_tools))
diffs.extend(_diff_trees(RESOURCES_OUT, tmp_resources))
if diffs:
print("Generated reference is stale. Run:")
print(" python tools/generate_docs_reference.py")
print("then commit the changes. Details:")
for d in diffs:
print(f" - {d}")
return 1
print("Generated docs reference is up-to-date.")
return 0
finally:
if ctx:
ctx.__exit__(None, None, None)
stats = generate()
print(
f"Generated {stats['tools']} tool pages across {stats['groups']} groups "
f"({stats['writes']} file(s) written) + {stats['resources']} resource entries."
)
return 0
if __name__ == "__main__":
sys.exit(main())
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Generate MCPB bundle for Unity MCP.
This script creates a Model Context Protocol Bundle (.mcpb) file
for distribution as a GitHub release artifact.
Usage:
python3 tools/generate_mcpb.py VERSION [--output FILE] [--icon PATH]
Examples:
python3 tools/generate_mcpb.py 9.0.8
python3 tools/generate_mcpb.py 9.0.8 --output unity-mcp-9.0.8.mcpb
python3 tools/generate_mcpb.py 9.0.8 --icon docs/images/coplay-logo.png
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ICON = REPO_ROOT / "docs" / "images" / "coplay-logo.png"
MANIFEST_TEMPLATE = REPO_ROOT / "manifest.json"
def create_manifest(version: str, icon_filename: str) -> dict:
"""Create manifest.json content with the specified version."""
if not MANIFEST_TEMPLATE.exists():
raise FileNotFoundError(f"Manifest template not found: {MANIFEST_TEMPLATE}")
manifest = json.loads(MANIFEST_TEMPLATE.read_text(encoding="utf-8"))
manifest["version"] = version
manifest["icon"] = icon_filename
return manifest
def generate_mcpb(
version: str,
output_path: Path,
icon_path: Path,
) -> Path:
"""Generate MCPB bundle file.
Args:
version: Semantic version string (e.g., "9.0.8")
output_path: Output path for the .mcpb file
icon_path: Path to the icon file
Returns:
Path to the generated .mcpb file
"""
if not icon_path.exists():
raise FileNotFoundError(f"Icon not found: {icon_path}")
with tempfile.TemporaryDirectory() as tmpdir:
build_dir = Path(tmpdir) / "mcpb-build"
build_dir.mkdir()
# Copy icon
icon_filename = icon_path.name
shutil.copy2(icon_path, build_dir / icon_filename)
# Create manifest with version
manifest = create_manifest(version, icon_filename)
manifest_path = build_dir / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
# Copy LICENSE and README if they exist
for filename in ["LICENSE", "README.md"]:
src = REPO_ROOT / filename
if src.exists():
shutil.copy2(src, build_dir / filename)
# Pack using mcpb CLI
# Syntax: mcpb pack [directory] [output]
try:
result = subprocess.run(
["npx", "@anthropic-ai/mcpb", "pack", ".", str(output_path.absolute())],
cwd=build_dir,
capture_output=True,
text=True,
check=True,
)
print(result.stdout)
except subprocess.CalledProcessError as e:
print(f"MCPB pack failed:\n{e.stderr}", file=sys.stderr)
raise
except FileNotFoundError:
print(
"Error: npx not found. Please install Node.js and npm.",
file=sys.stderr,
)
raise
if not output_path.exists():
raise RuntimeError(f"MCPB file was not created: {output_path}")
print(f"Generated: {output_path} ({output_path.stat().st_size:,} bytes)")
return output_path
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate MCPB bundle for Unity MCP",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"version",
help="Version string for the bundle (e.g., 9.0.8)",
)
parser.add_argument(
"--output",
"-o",
type=Path,
help="Output path for the .mcpb file (default: unity-mcp-VERSION.mcpb)",
)
parser.add_argument(
"--icon",
type=Path,
default=DEFAULT_ICON,
help=f"Path to icon file (default: {DEFAULT_ICON.relative_to(REPO_ROOT)})",
)
args = parser.parse_args()
# Default output name
if args.output is None:
args.output = Path(f"unity-mcp-{args.version}.mcpb")
try:
generate_mcpb(args.version, args.output, args.icon)
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Pre-commit hook: regenerate the Docusaurus tool/resource reference when
# any tool/resource module is part of the commit. Installed by
# tools/install-hooks.sh; opt-in (devs without the hook see no behavior change).
# To bypass for a single commit: git commit --no-verify
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
GENERATOR="${REPO_ROOT}/tools/generate_docs_reference.py"
if [[ ! -f "$GENERATOR" ]]; then
echo "pre-commit: $GENERATOR missing; skipping docs regeneration." >&2
exit 0
fi
relevant_paths='^Server/src/services/(tools|resources|registry)/'
# Look at the staged change set only — exclude pure deletions.
staged=$(git diff --cached --name-only --diff-filter=ACMR)
if ! grep -qE "$relevant_paths" <<<"$staged"; then
exit 0
fi
echo "pre-commit: tool/resource module changes detected — regenerating /website/docs/reference/"
# Prefer uv if available (matches how the Server's deps are pinned). Fall back
# to system python — the generator handles its own sys.path setup.
if command -v uv >/dev/null 2>&1 && [[ -f "${REPO_ROOT}/Server/pyproject.toml" ]]; then
(cd "${REPO_ROOT}/Server" && uv run python "$GENERATOR")
else
python3 "$GENERATOR"
fi
# Stage any reference files that changed so the commit captures them.
if ! git diff --quiet -- "website/docs/reference"; then
echo "pre-commit: staging regenerated reference pages."
git add website/docs/reference
fi
exit 0
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Pre-push hook: runs the local Unity-version parity check in compile-only mode.
# Installed by tools/install-hooks.sh; opt-in (devs without the hook see no behavior change).
# To bypass for a single push: git push --no-verify
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
SCRIPT="${REPO_ROOT}/tools/check-unity-versions.sh"
if [[ ! -x "$SCRIPT" ]]; then
echo "pre-push: $SCRIPT missing or not executable; skipping Unity-version check." >&2
exit 0
fi
# Skip if the push touches nothing relevant. We approximate "relevant" with the same path filters
# the CI workflow uses, so the hook fires for the same scope of changes.
relevant_paths='^(MCPForUnity/(Editor|Runtime)/|TestProjects/UnityMCPTests/|tools/unity-versions\.json$|\.github/workflows/unity-tests\.yml$)'
# Git's empty-tree SHA — fallback that always exists. Used when we can't find a sensible merge
# base (shallow clone, fresh fork with no origin/beta or origin/main, etc.) so the hook still
# runs the check instead of silently skipping it.
EMPTY_TREE=4b825dc642cb6eb9a060e54bf8d69288fbee4904
# stdin format from git: <local-ref> <local-sha> <remote-ref> <remote-sha> (one line per ref being pushed)
relevant=0
while read -r local_ref local_sha remote_ref remote_sha; do
[[ -z "$local_sha" || "$local_sha" == "0000000000000000000000000000000000000000" ]] && continue
if [[ "$remote_sha" == "0000000000000000000000000000000000000000" ]]; then
# New branch on remote — diff against the default branch, or the empty tree as a guaranteed-valid fallback.
base=$(git merge-base "$local_sha" origin/beta 2>/dev/null \
|| git merge-base "$local_sha" origin/main 2>/dev/null \
|| echo "$EMPTY_TREE")
else
base="$remote_sha"
fi
if git diff --name-only "$base..$local_sha" 2>/dev/null | grep -qE "$relevant_paths"; then
relevant=1
break
fi
done
if [[ $relevant -eq 0 ]]; then
echo "pre-push: no Unity-relevant changes detected; skipping check."
exit 0
fi
exec "$SCRIPT" --pre-push
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Installs the repo's git hooks into .git/hooks/.
#
# Usage:
# tools/install-hooks.sh # install missing hooks (preserves existing ones)
# tools/install-hooks.sh --force # overwrite any existing hooks
# tools/install-hooks.sh --uninstall # remove hooks that match the ones we installed
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_DIR="${REPO_ROOT}/tools/hooks"
DST_DIR="${REPO_ROOT}/.git/hooks"
if [[ ! -d "$DST_DIR" ]]; then
echo "error: $DST_DIR not found — is this a git checkout?" >&2
exit 2
fi
if [[ ! -d "$SRC_DIR" ]]; then
echo "error: $SRC_DIR missing" >&2
exit 2
fi
FORCE=0
UNINSTALL=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help)
sed -n '2,7p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
esac
done
installed=0
skipped=0
removed=0
for src in "$SRC_DIR"/*; do
[[ -e "$src" ]] || continue
name="$(basename "$src")"
dst="$DST_DIR/$name"
if [[ $UNINSTALL -eq 1 ]]; then
if [[ -e "$dst" ]] && cmp -s "$src" "$dst"; then
rm -f "$dst"
echo " removed $dst"
removed=$((removed + 1))
else
echo " skipped $dst (not our hook, or absent)"
fi
continue
fi
if [[ -e "$dst" && $FORCE -ne 1 ]]; then
if cmp -s "$src" "$dst"; then
echo " ok $dst (already up to date)"
skipped=$((skipped + 1))
else
echo " skipped $dst (exists and differs — use --force to overwrite)"
skipped=$((skipped + 1))
fi
continue
fi
cp "$src" "$dst"
chmod +x "$dst"
echo " installed $dst"
installed=$((installed + 1))
done
if [[ $UNINSTALL -eq 1 ]]; then
echo
echo "Summary: $removed hook(s) removed"
else
echo
echo "Summary: $installed installed, $skipped skipped"
if [[ $installed -gt 0 ]]; then
echo
echo "Hooks are active. To bypass for a single push: git push --no-verify"
fi
fi
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Prepare MCPForUnity for Asset Store upload.
Usage:
python tools/prepare_unity_asset_store_release.py \
--remote-url https://your.remote.endpoint/ \
--asset-project /path/to/AssetStoreUploads \
--backup
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import shutil
import tempfile
from pathlib import Path
REPO_ROOT_DEFAULT = Path(__file__).resolve(
).parents[1] # adjust if you place elsewhere
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def write_text(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
def replace_once(path: Path, pattern: str, repl: str) -> None:
"""
Regex replace exactly once, else raise.
"""
original = read_text(path)
new, n = re.subn(pattern, repl, original, flags=re.MULTILINE)
if n != 1:
raise RuntimeError(
f"{path}: expected 1 replacement for pattern, got {n}")
if new != original:
write_text(path, new)
def remove_line_exact(path: Path, line: str) -> None:
original = read_text(path)
lines = original.splitlines(keepends=True)
removed = 0
kept: list[str] = []
for l in lines:
if l.strip() == line:
removed += 1
continue
kept.append(l)
if removed != 1:
raise RuntimeError(
f"{path}: expected to remove exactly 1 line '{line}', removed {removed}")
write_text(path, "".join(kept))
def backup_dir(src: Path, backup_root: Path) -> Path:
ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_root / f"{src.name}.backup.{ts}"
shutil.copytree(src, backup_path)
return backup_path
def main() -> int:
parser = argparse.ArgumentParser(
description="Prepare MCPForUnity for Asset Store upload.")
parser.add_argument(
"--repo-root",
default=str(REPO_ROOT_DEFAULT),
help="Path to unity-mcp repo root (default: inferred from script location).",
)
parser.add_argument(
"--asset-project",
default=None,
help="Path to the Unity project used for Asset Store uploads.",
)
parser.add_argument(
"--remote-url",
required=True,
help="Remote MCP HTTP base URL to set as default for Asset Store builds.",
)
parser.add_argument(
"--backup",
action="store_true",
help="Backup existing Assets/MCPForUnity before replacing.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Only validate that operations would succeed; do not write/copy/delete.",
)
args = parser.parse_args()
repo_root = Path(args.repo_root).expanduser().resolve()
asset_project = Path(args.asset_project).expanduser().resolve(
) if args.asset_project else (repo_root / "TestProjects" / "AssetStoreUploads")
remote_url = args.remote_url.strip()
if not remote_url:
raise RuntimeError("--remote-url must be a non-empty URL")
source_mcp = repo_root / "MCPForUnity"
if not source_mcp.is_dir():
raise RuntimeError(
f"Source MCPForUnity folder not found: {source_mcp}")
assets_dir = asset_project / "Assets"
if not assets_dir.is_dir():
raise RuntimeError(f"Assets folder not found: {assets_dir}")
dest_mcp = assets_dir / "MCPForUnity"
if args.dry_run:
print("[dry-run] Validated paths. No changes applied.")
print("[dry-run] Would stage a temporary copy of MCPForUnity and apply Asset Store edits there.")
print(
f"[dry-run] Would replace:\n- {dest_mcp}\n with\n- {source_mcp}")
return 0
# 1) Stage a temporary copy of MCPForUnity and apply Asset Store-specific edits there.
with tempfile.TemporaryDirectory(prefix="mcpforunity_assetstore_") as tmpdir:
staged_mcp = Path(tmpdir) / "MCPForUnity"
shutil.copytree(source_mcp, staged_mcp)
setup_service = staged_mcp / "Editor" / "Setup" / "SetupWindowService.cs"
menu_file = staged_mcp / "Editor" / "MenuItems" / "MCPForUnityMenu.cs"
http_util = staged_mcp / "Editor" / "Helpers" / "HttpEndpointUtility.cs"
connection_section = staged_mcp / "Editor" / "Windows" / \
"Components" / "Connection" / "McpConnectionSection.cs"
for f in (setup_service, menu_file, http_util, connection_section):
if not f.is_file():
raise RuntimeError(f"Expected file not found: {f}")
# Remove auto-popup setup window for Asset Store packaging
remove_line_exact(setup_service, "[InitializeOnLoad]")
# Set default remote base URL to the hosted endpoint
replace_once(
http_util,
r'private const string DefaultRemoteBaseUrl = "";',
f'private const string DefaultRemoteBaseUrl = "{remote_url}";',
)
# Default transport to HTTP Remote and persist inferred scope when missing
replace_once(
connection_section,
r'transportDropdown\.Init\(TransportProtocol\.HTTPLocal\);',
'transportDropdown.Init(TransportProtocol.HTTPRemote);',
)
replace_once(
connection_section,
r'scope = MCPServiceLocator\.Server\.IsLocalUrl\(\) \? "local" : "remote";',
'scope = "remote";',
)
# 2) Replace Assets/MCPForUnity in the target project
if dest_mcp.exists():
if args.backup:
backup_root = asset_project / "AssetStoreBackups"
backup_root.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir(dest_mcp, backup_root)
print(f"Backed up existing folder to: {backup_path}")
shutil.rmtree(dest_mcp)
shutil.copytree(staged_mcp, dest_mcp)
print("Done.")
print(f"- Source (unchanged): {source_mcp}")
print(f"- Updated Asset Store project folder: {dest_mcp}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Build and upload the Python package to PyPI (manual).
#
# Requirements:
# - Python 3 available on PATH.
# - `uv` installed (used to build the sdist and wheel).
# - `twine` installed (used to upload to PyPI / TestPyPI).
# - Credentials provided via environment variables:
# - Preferred: PYPI_TOKEN (a PyPI API token)
# - Or: TWINE_USERNAME and TWINE_PASSWORD
#
# Usage:
# export PYPI_TOKEN="pypi-..."
# ./tools/pypi_publish.sh
#
# TestPyPI:
# ./tools/pypi_publish.sh --test
#
# Notes:
# - PyPI does not allow overwriting an existing version; bump the version in Server/pyproject.toml first.
# - This script clears Server/dist/*.whl and Server/dist/*.tar.gz before building.
# - Only artifacts matching the current version in Server/pyproject.toml are uploaded.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPOSITORY="pypi"
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $(basename "$0") [--test]" >&2
echo "Environment:" >&2
echo " PYPI_TOKEN (preferred) or TWINE_USERNAME/TWINE_PASSWORD" >&2
exit 2
fi
if [[ "${1:-}" == "--test" ]]; then
REPOSITORY="testpypi"
fi
if [[ "${PYPI_TOKEN:-}" != "" && "${TWINE_PASSWORD:-}" == "" ]]; then
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="$PYPI_TOKEN"
fi
if [[ "${TWINE_USERNAME:-}" == "" || "${TWINE_PASSWORD:-}" == "" ]]; then
echo "Error: missing credentials. Set PYPI_TOKEN or TWINE_USERNAME and TWINE_PASSWORD." >&2
exit 2
fi
if ! command -v uv >/dev/null 2>&1; then
echo "Error: uv is not installed. Install it and retry." >&2
exit 2
fi
python3 -m twine --version >/dev/null 2>&1 || {
echo "Error: twine is not installed. Install it (e.g. python3 -m pip install --upgrade twine) and retry." >&2
exit 2
}
(
cd "$ROOT_DIR/Server"
mkdir -p dist
rm -f dist/*.whl dist/*.tar.gz
uv build
)
DIST_DIR="$ROOT_DIR/Server/dist"
if [[ ! -d "$DIST_DIR" ]]; then
echo "Error: dist dir not found: $DIST_DIR" >&2
exit 2
fi
shopt -s nullglob
VERSION="$(python3 -c 'import tomllib, pathlib; p = pathlib.Path("'"$ROOT_DIR"'/Server/pyproject.toml"); print(tomllib.loads(p.read_text(encoding="utf-8"))["project"]["version"])')"
FILES=("$DIST_DIR"/mcpforunityserver-"$VERSION"*)
shopt -u nullglob
if (( ${#FILES[@]} == 0 )); then
echo "Error: no files found in $DIST_DIR" >&2
exit 2
fi
python3 -m twine upload --repository "$REPOSITORY" "${FILES[@]}"
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""
Stress test for EditorStateCache.GetSnapshot() to reproduce GC allocation spikes.
This script rapidly polls the editor state to simulate an MCP client that frequently
checks Unity's readiness state. Run this while profiling in Unity to see if it
causes the GC spikes reported in GitHub issue #577.
Usage:
python tools/stress_editor_state.py --duration 30 --interval 0.05
While this runs, open Unity Profiler and look for:
- EditorStateCache.OnUpdate
- EditorStateCache.GetSnapshot
- GC.Alloc spikes
"""
import asyncio
import argparse
import json
import os
import struct
import time
from pathlib import Path
import sys
TIMEOUT = 5.0
def find_status_files() -> list[Path]:
home = Path.home()
status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
if not status_dir.exists():
return []
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
def discover_port(project_path: str | None) -> int:
default_port = 6400
files = find_status_files()
for f in files:
try:
data = json.loads(f.read_text())
port = int(data.get("unity_port", 0) or 0)
if 0 < port < 65536:
return port
except Exception:
pass
return default_port
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = await reader.read(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed while reading")
buf += chunk
return buf
async def read_frame(reader: asyncio.StreamReader) -> bytes:
header = await read_exact(reader, 8)
(length,) = struct.unpack(">Q", header)
if length <= 0 or length > (64 * 1024 * 1024):
raise ValueError(f"Invalid frame length: {length}")
return await read_exact(reader, length)
async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None:
header = struct.pack(">Q", len(payload))
writer.write(header)
writer.write(payload)
await asyncio.wait_for(writer.drain(), timeout=TIMEOUT)
async def do_handshake(reader: asyncio.StreamReader) -> None:
line = await reader.readline()
if not line or b"WELCOME UNITY-MCP" not in line:
raise ConnectionError(f"Unexpected handshake from server: {line!r}")
def make_get_editor_state_frame() -> bytes:
payload = {"type": "get_editor_state", "params": {}}
return json.dumps(payload).encode("utf-8")
async def stress_loop(host: str, port: int, duration: float, interval: float, verbose: bool):
stop_time = time.time() + duration
stats = {"requests": 0, "errors": 0, "reconnects": 0}
print(f"Starting editor state stress test...")
print(f" Target: {host}:{port}")
print(f" Duration: {duration}s")
print(f" Interval: {interval}s ({1/interval:.1f} requests/sec)")
print(f" Press Ctrl+C to stop early")
print()
writer = None
reader = None
try:
while time.time() < stop_time:
try:
# Connect if needed
if writer is None:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=TIMEOUT
)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
if verbose:
print(f"[{time.time():.2f}] Connected")
# Send get_editor_state request
await write_frame(writer, make_get_editor_state_frame())
response = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
stats["requests"] += 1
if verbose and stats["requests"] % 20 == 0:
try:
data = json.loads(response.decode("utf-8", errors="ignore"))
seq = data.get("data", {}).get("sequence", "?")
print(f"[{time.time():.2f}] Request #{stats['requests']}, sequence={seq}")
except Exception:
print(f"[{time.time():.2f}] Request #{stats['requests']}")
await asyncio.sleep(interval)
except (ConnectionError, OSError, asyncio.TimeoutError) as e:
stats["errors"] += 1
stats["reconnects"] += 1
if verbose:
print(f"[{time.time():.2f}] Connection error: {e}, reconnecting...")
if writer:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
writer = None
reader = None
await asyncio.sleep(0.5)
except KeyboardInterrupt:
print("\nStopped by user")
finally:
if writer:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
elapsed = duration - max(0, stop_time - time.time())
print()
print("=" * 50)
print("Results:")
print(f" Total requests: {stats['requests']}")
print(f" Errors: {stats['errors']}")
print(f" Reconnects: {stats['reconnects']}")
print(f" Elapsed: {elapsed:.1f}s")
print(f" Rate: {stats['requests']/elapsed:.1f} requests/sec")
print("=" * 50)
async def main():
parser = argparse.ArgumentParser(
description="Stress test EditorStateCache.GetSnapshot() to reproduce GC spikes"
)
parser.add_argument("--host", default="127.0.0.1", help="Unity bridge host")
parser.add_argument("--port", type=int, default=0, help="Unity bridge port (0=auto-discover)")
parser.add_argument("--duration", type=float, default=30.0, help="Test duration in seconds")
parser.add_argument("--interval", type=float, default=0.05, help="Interval between requests (0.05 = 20/sec)")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args()
port = args.port if args.port > 0 else discover_port(None)
await stress_loop(args.host, port, args.duration, args.interval, args.verbose)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
import asyncio
import argparse
import json
import os
import struct
import time
from pathlib import Path
import random
import sys
TIMEOUT = float(os.environ.get("MCP_STRESS_TIMEOUT", "2.0"))
DEBUG = os.environ.get("MCP_STRESS_DEBUG", "").lower() in ("1", "true", "yes")
def dlog(*args):
if DEBUG:
print(*args, file=sys.stderr)
def find_status_files() -> list[Path]:
home = Path.home()
status_dir = Path(os.environ.get(
"UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
if not status_dir.exists():
return []
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
def discover_port(project_path: str | None) -> int:
# Default bridge port if nothing found
default_port = 6400
files = find_status_files()
for f in files:
try:
data = json.loads(f.read_text())
port = int(data.get("unity_port", 0) or 0)
proj = data.get("project_path") or ""
if project_path:
# Match status for the given project if possible
if proj and project_path in proj:
if 0 < port < 65536:
return port
else:
if 0 < port < 65536:
return port
except Exception:
pass
return default_port
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = await reader.read(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed while reading")
buf += chunk
return buf
async def read_frame(reader: asyncio.StreamReader) -> bytes:
header = await read_exact(reader, 8)
(length,) = struct.unpack(">Q", header)
if length <= 0 or length > (64 * 1024 * 1024):
raise ValueError(f"Invalid frame length: {length}")
return await read_exact(reader, length)
async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None:
header = struct.pack(">Q", len(payload))
writer.write(header)
writer.write(payload)
await asyncio.wait_for(writer.drain(), timeout=TIMEOUT)
async def do_handshake(reader: asyncio.StreamReader) -> None:
# Server sends a single line handshake: "WELCOME UNITY-MCP 1 FRAMING=1\n"
line = await reader.readline()
if not line or b"WELCOME UNITY-MCP" not in line:
raise ConnectionError(f"Unexpected handshake from server: {line!r}")
def make_ping_frame() -> bytes:
return b"ping"
def make_execute_menu_item(menu_path: str) -> bytes:
# Retained for manual debugging; not used in normal stress runs
payload = {"type": "execute_menu_item", "params": {
"action": "execute", "menu_path": menu_path}}
return json.dumps(payload).encode("utf-8")
async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: dict):
reconnect_delay = 0.2
while time.time() < stop_time:
writer = None
try:
# slight stagger to prevent burst synchronization across clients
await asyncio.sleep(0.003 * (idx % 11))
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
# Send a quick ping first
await write_frame(writer, make_ping_frame())
# 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:
# Ping-only; edits are sent via reload_churn_task to avoid console spam
await write_frame(writer, make_ping_frame())
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
stats["pings"] += 1
await asyncio.sleep(0.02 + random.uniform(-0.003, 0.003))
except (ConnectionError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError):
stats["disconnects"] += 1
dlog(f"[client {idx}] disconnect/backoff {reconnect_delay}s")
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 1.5, 2.0)
continue
except Exception:
stats["errors"] += 1
dlog(f"[client {idx}] unexpected error")
await asyncio.sleep(0.2)
continue
finally:
if writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int, stats: dict, storm_count: int = 1):
# Use script edit tool to touch a C# file, which triggers compilation reliably
path = Path(unity_file) if unity_file else None
seq = 0
proj_root = Path(project_path).resolve() if project_path else None
# Build candidate list for storm mode
candidates: list[Path] = []
if proj_root:
try:
for p in (proj_root / "Assets").rglob("*.cs"):
candidates.append(p.resolve())
except Exception:
candidates = []
if path and path.exists():
rp = path.resolve()
if rp not in candidates:
candidates.append(rp)
while time.time() < stop_time:
try:
if path and path.exists():
# Determine files to touch this cycle
targets: list[Path]
if storm_count and storm_count > 1 and candidates:
k = min(max(1, storm_count), len(candidates))
targets = random.sample(candidates, k)
else:
targets = [path]
for tpath in targets:
# Build a tiny ApplyTextEdits request that toggles a trailing comment
relative = None
try:
# Derive Unity-relative path under Assets/ (cross-platform)
resolved = tpath.resolve()
parts = list(resolved.parts)
if "Assets" in parts:
i = parts.index("Assets")
relative = Path(*parts[i:]).as_posix()
elif proj_root and str(resolved).startswith(str(proj_root)):
rel = resolved.relative_to(proj_root)
parts2 = list(rel.parts)
if "Assets" in parts2:
i2 = parts2.index("Assets")
relative = Path(*parts2[i2:]).as_posix()
except Exception:
relative = None
if relative:
# Derive name and directory for ManageScript and compute precondition SHA + EOF position
name_base = Path(relative).stem
dir_path = str(
Path(relative).parent).replace('\\', '/')
# 1) Read current contents via manage_script.read to compute SHA and true EOF location
contents = None
read_success = False
for attempt in range(3):
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
read_payload = {
"type": "manage_script",
"params": {
"action": "read",
"name": name_base,
"path": dir_path
}
}
await write_frame(writer, json.dumps(read_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
read_obj = json.loads(
resp.decode("utf-8", errors="ignore"))
result = read_obj.get("result", read_obj) if isinstance(
read_obj, dict) else {}
if result.get("success"):
data_obj = result.get("data", {})
contents = data_obj.get("contents") or ""
read_success = True
break
except Exception:
# retry with backoff
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
finally:
if 'writer' in locals() and writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
if not read_success or contents is None:
stats["apply_errors"] = stats.get(
"apply_errors", 0) + 1
await asyncio.sleep(0.5)
continue
# Compute SHA and EOF insertion point
import hashlib
sha = hashlib.sha256(
contents.encode("utf-8")).hexdigest()
lines = contents.splitlines(keepends=True)
# Insert at true EOF (safe against header guards)
end_line = len(lines) + 1 # 1-based exclusive end
end_col = 1
# Build a unique marker append; ensure it begins with a newline if needed
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
seq += 1
insert_text = ("\n" if not contents.endswith(
"\n") else "") + marker + "\n"
# 2) Apply text edits with immediate refresh and precondition
apply_payload = {
"type": "manage_script",
"params": {
"action": "apply_text_edits",
"name": name_base,
"path": dir_path,
"edits": [
{
"startLine": end_line,
"startCol": end_col,
"endLine": end_line,
"endCol": end_col,
"newText": insert_text
}
],
"precondition_sha256": sha,
"options": {"refresh": "immediate", "validate": "standard"}
}
}
apply_success = False
for attempt in range(3):
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
await write_frame(writer, json.dumps(apply_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
try:
data = json.loads(resp.decode(
"utf-8", errors="ignore"))
result = data.get("result", data) if isinstance(
data, dict) else {}
ok = bool(result.get("success", False))
if ok:
stats["applies"] = stats.get(
"applies", 0) + 1
apply_success = True
break
except Exception:
# fall through to retry
pass
except Exception:
# retry with backoff
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
finally:
if 'writer' in locals() and writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
if not apply_success:
stats["apply_errors"] = stats.get(
"apply_errors", 0) + 1
except Exception:
pass
await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser(
description="Stress test MCP for Unity with concurrent clients and reload churn")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--project", default=str(
Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests"))
ap.add_argument("--unity-file", default=str(Path(__file__).resolve(
).parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs"))
ap.add_argument("--clients", type=int, default=10)
ap.add_argument("--duration", type=int, default=60)
ap.add_argument("--storm-count", type=int, default=1,
help="Number of scripts to touch each cycle")
args = ap.parse_args()
port = discover_port(args.project)
stop_time = time.time() + max(10, args.duration)
stats = {"pings": 0, "menus": 0, "mods": 0, "disconnects": 0, "errors": 0}
tasks = []
# Spawn clients
for i in range(max(1, args.clients)):
tasks.append(asyncio.create_task(
client_loop(i, args.host, port, stop_time, stats)))
# Spawn reload churn task
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time,
args.unity_file, args.host, port, stats, storm_count=args.storm_count)))
await asyncio.gather(*tasks, return_exceptions=True)
print(json.dumps({"port": port, "stats": stats}, indent=2))
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
Sync release notes from GitHub Releases into:
- website/docs/releases.md (full history, grouped by minor)
- README.md "Recent Updates" (latest N releases between sentinel markers)
Why a sync script: the previous releases.md was hand-maintained and went stale
(it claimed v9.6.3 was latest when v9.7.0 had shipped). GitHub Releases is the
single source of truth; this script makes it the only source.
Usage:
python tools/sync_release_notes.py # write + verify
python tools/sync_release_notes.py --check # CI: fail if drift
GITHUB_TOKEN=xxx python tools/sync_release_notes.py # higher rate limit
Local runs without a token use anonymous GitHub API (60 req/hr — plenty for one
sync since we only paginate the /releases endpoint).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import ssl
import subprocess
import sys
import textwrap
import urllib.error
import urllib.request
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RELEASES_MD = REPO_ROOT / "website" / "docs" / "releases.md"
README_MD = REPO_ROOT / "README.md"
OWNER = "CoplayDev"
REPO = "unity-mcp"
API = f"https://api.github.com/repos/{OWNER}/{REPO}/releases"
README_RECENT_COUNT = 5
README_MARKER_OPEN = "<!-- recent-updates:start -->"
README_MARKER_CLOSE = "<!-- recent-updates:end -->"
RELEASES_HEADER = textwrap.dedent(
"""\
---
id: releases
slug: /releases
title: Release Notes
sidebar_label: Releases
description: Full version-by-version change history for MCP for Unity.
---
# Release Notes
Latest releases land in [`beta`](https://github.com/CoplayDev/unity-mcp/tree/beta) before promotion to [`main`](https://github.com/CoplayDev/unity-mcp/tree/main). Major breaking changes get a dedicated migration guide under [Migrations](/migrations/v5).
For the canonical changelog with PR links, see [GitHub Releases](https://github.com/CoplayDev/unity-mcp/releases).
> Auto-generated from the GitHub Releases API by `tools/sync_release_notes.py`. Do not hand-edit — changes will be overwritten on the next sync.
"""
)
# ---------------------------------------------------------------------------
# Fetch
# ---------------------------------------------------------------------------
def _fetch_via_gh(path: str) -> list[dict] | None:
"""Prefer `gh api` when available — handles auth + SSL cleanly across
macOS Python distributions that miss the system trust store."""
if not shutil.which("gh"):
return None
try:
result = subprocess.run(
["gh", "api", path, "--paginate"],
check=True,
capture_output=True,
text=True,
timeout=60,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
print(f"gh api failed ({e}); falling back to urllib.", file=sys.stderr)
return None
# `gh api --paginate` concatenates JSON arrays as `][`. Split + parse.
text = result.stdout.strip()
if not text:
return []
if "][" in text:
text = "[" + text.replace("][", ",") + "]"
# Now we may have [[..],[..]] — flatten.
nested = json.loads(text)
flat: list[dict] = []
for chunk in nested:
flat.extend(chunk if isinstance(chunk, list) else [chunk])
return flat
return json.loads(text)
def _fetch_via_urllib(url: str) -> list[dict]:
headers = {
"User-Agent": "mcp-for-unity-docs-sync",
"Accept": "application/vnd.github+json",
}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
ctx = ssl.create_default_context()
try:
import certifi # type: ignore
ctx = ssl.create_default_context(cafile=certifi.where())
except ImportError:
pass
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
return json.loads(resp.read().decode("utf-8"))
def fetch_all_releases() -> list[dict]:
# Try `gh api` first.
via_gh = _fetch_via_gh(f"repos/{OWNER}/{REPO}/releases?per_page=100")
if via_gh is not None:
return [r for r in via_gh if not r.get("draft")]
# Fallback: paginate urllib.
all_releases: list[dict] = []
page = 1
while True:
batch = _fetch_via_urllib(f"{API}?per_page=100&page={page}")
if not batch:
break
all_releases.extend(batch)
if len(batch) < 100:
break
page += 1
return [r for r in all_releases if not r.get("draft")]
# ---------------------------------------------------------------------------
# Render
# ---------------------------------------------------------------------------
_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)")
def _parse_version(tag: str) -> tuple[int, int, int] | None:
m = _VERSION_RE.match(tag.strip())
if not m:
return None
return tuple(int(x) for x in m.groups()) # type: ignore[return-value]
def _minor_key(tag: str) -> str:
v = _parse_version(tag)
if not v:
return tag
return f"v{v[0]}.{v[1]} series"
def _normalize_body(body: str | None) -> str:
if not body:
return "_No release notes._"
body = body.replace("\r\n", "\n").replace("\r", "\n")
# Trim trailing whitespace; collapse 3+ blank lines.
body = body.strip()
body = re.sub(r"\n{3,}", "\n\n", body)
return body
def _digest(body: str | None, max_chars: int = 280) -> str:
"""Pull a one-line digest for the README block. Prefers the first non-empty
line that isn't a heading or list-of-PRs link."""
if not body:
return ""
for raw in body.splitlines():
line = raw.strip()
if not line:
continue
# Skip headings and bullet-of-PR lines.
if line.startswith(("#", ">", "**Full Changelog**")):
continue
if line.startswith(("*", "-")) and "github.com" in line and "pull/" in line:
continue
# Strip bullet prefixes for the digest.
line = re.sub(r"^[*-]\s+", "", line)
# Strip leading markdown emphasis.
line = re.sub(r"^\*\*[^*]+\*\*[:.\s—-]*", "", line)
if len(line) > max_chars:
line = line[: max_chars - 1].rstrip() + ""
return line
return ""
def render_releases_md(releases: list[dict]) -> str:
out = [RELEASES_HEADER]
grouped: dict[str, list[dict]] = defaultdict(list)
order: list[str] = []
for r in releases:
key = _minor_key(r["tag_name"])
if key not in grouped:
order.append(key)
grouped[key].append(r)
for key in order:
out.append(f"## {key}\n")
for r in grouped[key]:
tag = r["tag_name"]
name = (r.get("name") or tag).strip()
date = (r.get("published_at") or "").split("T")[0]
prerelease = " (beta)" if r.get("prerelease") else ""
url = r.get("html_url") or f"https://github.com/{OWNER}/{REPO}/releases/tag/{tag}"
body = _normalize_body(r.get("body"))
summary = name if name and name != tag else tag
out.append(f"### [{summary}{prerelease}]({url}) — {date}\n")
out.append(f"<details>\n<summary>Show release notes</summary>\n\n{body}\n\n</details>\n")
out.append("") # blank line between groups
# Migration footer
out.append("## Migration guides\n")
out.append(
"Breaking changes from prior major versions live under [Migrations](/migrations/v5):\n"
)
out.append("- [v5 — UnityMcpBridge → MCPForUnity](/migrations/v5)")
out.append("- [v6 — New Editor Window (UI Toolkit + service architecture)](/migrations/v6)")
out.append("- [v8 — HTTP and Stdio support](/migrations/v8)")
out.append("- [v10 — Asset Generation and Docs Refresh](/migrations/v10)\n")
return "\n".join(out)
def render_readme_recent(releases: list[dict], n: int = README_RECENT_COUNT) -> str:
lines = [README_MARKER_OPEN]
lines.append("<details>")
lines.append("<summary><strong>Recent Updates</strong></summary>")
lines.append("")
for r in releases[:n]:
tag = r["tag_name"]
date = (r.get("published_at") or "").split("T")[0]
url = r.get("html_url") or f"https://github.com/{OWNER}/{REPO}/releases/tag/{tag}"
digest = _digest(r.get("body"))
suffix = f"{digest}" if digest else ""
prerelease = " *(beta)*" if r.get("prerelease") else ""
lines.append(f"* **[{tag}{prerelease}]({url})** ({date}){suffix}")
lines.append("")
lines.append(
"Full history: [Release Notes](https://coplaydev.github.io/unity-mcp/releases)."
)
lines.append("")
lines.append("</details>")
lines.append(README_MARKER_CLOSE)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def replace_marked_block(text: str, replacement: str) -> str:
pattern = re.compile(
re.escape(README_MARKER_OPEN) + r".*?" + re.escape(README_MARKER_CLOSE),
re.DOTALL,
)
if pattern.search(text):
return pattern.sub(replacement, text)
# First-time insert: try to replace the legacy <details><summary>Recent Updates</summary>
legacy = re.compile(
r"<details>\s*<summary>\s*<strong>Recent Updates</strong>\s*</summary>.*?</details>",
re.DOTALL,
)
if legacy.search(text):
return legacy.sub(replacement, text)
# Otherwise append before the "## Community" header if present.
anchor = "## Community"
if anchor in text:
return text.replace(anchor, f"{replacement}\n\n{anchor}", 1)
return text + "\n\n" + replacement + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true",
help="Exit non-zero if files would change. Used by CI to detect drift.")
args = parser.parse_args(argv)
try:
releases = fetch_all_releases()
except urllib.error.HTTPError as e:
print(f"GitHub API error: {e.code} {e.reason}", file=sys.stderr)
if e.code == 403:
print("Hint: set GITHUB_TOKEN to lift the anonymous rate limit.", file=sys.stderr)
return 2
if not releases:
print("No releases returned by GitHub API. Aborting to avoid blanking files.", file=sys.stderr)
return 2
new_releases = render_releases_md(releases)
new_readme_block = render_readme_recent(releases)
existing_releases = RELEASES_MD.read_text(encoding="utf-8") if RELEASES_MD.exists() else ""
existing_readme = README_MD.read_text(encoding="utf-8") if README_MD.exists() else ""
new_readme = replace_marked_block(existing_readme, new_readme_block)
releases_drift = existing_releases != new_releases
readme_drift = existing_readme != new_readme
if args.check:
if releases_drift or readme_drift:
print("Release notes are stale. Run:")
print(" python tools/sync_release_notes.py")
print("then commit the changes.")
if releases_drift:
print(f" - drift in {RELEASES_MD.relative_to(REPO_ROOT)}")
if readme_drift:
print(f" - drift in {README_MD.relative_to(REPO_ROOT)}")
return 1
print(f"Release notes are up-to-date ({len(releases)} releases).")
return 0
if releases_drift:
RELEASES_MD.write_text(new_releases, encoding="utf-8")
if readme_drift:
README_MD.write_text(new_readme, encoding="utf-8")
print(f"Synced {len(releases)} releases.")
print(f" - {RELEASES_MD.relative_to(REPO_ROOT)}: {'updated' if releases_drift else 'unchanged'}")
print(f" - {README_MD.relative_to(REPO_ROOT)}: {'updated' if readme_drift else 'unchanged'}")
return 0
if __name__ == "__main__":
sys.exit(main())
+28
View File
@@ -0,0 +1,28 @@
"""Characterization tests for unity-mcp build and release infrastructure.
This package contains pytest-based characterization tests that document
the CURRENT behavior of build, release, and testing tools without refactoring.
Test Structure:
- test_build_release_characterization.py: Main characterization suite
Domains Covered:
1. Version Management (update_versions.py)
2. MCPB Bundle Generation (generate_mcpb.py)
3. Asset Store Preparation (prepare_unity_asset_store_release.py)
4. Stress Testing (stress_mcp.py, stress_editor_state.py)
5. Release Workflows and Checklists
6. Git Integration Patterns
Test Style:
- Characterization tests (capture current behavior)
- No refactoring performed
- Heavy use of mocking and fixtures
- Async tests using pytest-asyncio
- Comprehensive docstrings explaining patterns
Running Tests:
cd /Users/davidsarno/unity-mcp
python -m pytest tools/tests/ -v
python -m pytest tools/tests/test_build_release_characterization.py -v --tb=short
"""
File diff suppressed because it is too large Load Diff
+830
View File
@@ -0,0 +1,830 @@
"""Hermetic unit tests for tools/local_harness.py pure helpers.
These tests exercise the Unity-free, filesystem-free (or filesystem-injected)
helpers at the top of ``tools/local_harness.py`` WITHOUT booting Unity, opening
sockets, or shelling out. Every filesystem and platform dependency is either
monkeypatched or threaded through the module's injection seams
(``platform=``, ``environ=``, ``exists=``, ``is_exec=``, ``list_dir=``,
``read_text=``, ``glob_fn=``, ``mtime_fn=``), so the suite is pure and offline.
Covered surfaces (mapped to the harness task):
* resolve_editor_binary (= discover_editor): per-OS editor path resolution,
nearest-patch fallback, EditorNotFound search-path reporting.
* resolve_unity_version (= resolve_version): ProjectVersion.txt vs
unity-versions.json precedence.
* classify_editor_log (= classify_log): license-fatal -> exit 4 mapping,
compile-error -> exit 3 mapping, clean log -> ok.
* aggregate_exit_code (= aggregate_exit): exit-code aggregation across legs
(smoke bridge-unreachable=2, EditMode fail=1, PlayMode non-blocking fail).
* merge_junit: well-formed merged <testsuites> XML.
* build_arg_parser: defaults + flag parsing for the CLI surface.
Run from the repo root::
python -m pytest tools/tests/test_local_harness.py -v
"""
from __future__ import annotations
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
# tools/local_harness.py lives one directory up from tools/tests/. It is a
# top-level (non-package) module, so put tools/ on sys.path before importing.
_TOOLS_DIR = Path(__file__).resolve().parents[1]
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
import local_harness as lh # noqa: E402
from local_harness import ( # noqa: E402
EditorNotFound,
EditorSpec,
JUnitCase,
JUnitSuite,
LegOutcome,
aggregate_exit_code,
build_arg_parser,
classify_editor_log,
merge_junit,
resolve_editor_binary,
resolve_unity_version,
)
# ===========================================================================
# Helpers for hermetic filesystem injection into discover_editor
# ===========================================================================
def _fake_fs(present_paths: set[str], dirs: dict[str, list[str]] | None = None):
"""Build (exists, is_exec, list_dir) callables over an in-memory file set.
``present_paths`` is the set of files that "exist" and are executable.
``dirs`` maps a directory path to the names it lists (for nearest-patch
enumeration). All callables are pure and never touch the real disk.
"""
dirs = dirs or {}
def exists(p: str) -> bool:
return p in present_paths
def is_exec(p: str) -> bool:
return p in present_paths
def list_dir(d: str) -> list[str]:
return list(dirs.get(d, []))
return exists, is_exec, list_dir
def _no_secondary(_path: str) -> str:
"""read_text stub that reports no Hub secondaryInstallPath file."""
raise OSError("no secondary install path file")
# ===========================================================================
# resolve_editor_binary (discover_editor): per-OS path resolution
# ===========================================================================
class TestEditorRelpath:
def test_macos_relpath(self):
assert lh.editor_relpath("darwin") == "Unity.app/Contents/MacOS/Unity"
def test_windows_relpath(self):
assert lh.editor_relpath("win32") == "Editor/Unity.exe"
def test_linux_relpath(self):
assert lh.editor_relpath("linux") == "Editor/Unity"
def test_unknown_platform_defaults_to_linux_layout(self):
assert lh.editor_relpath("freebsd13") == "Editor/Unity"
class TestHubRoots:
def test_macos_hub_root(self):
roots = lh.hub_roots("darwin", environ={})
assert roots == ["/Applications/Unity/Hub/Editor"]
def test_windows_hub_roots_use_program_files(self):
env = {"ProgramFiles": r"C:\Program Files", "ProgramFiles(x86)": r"C:\Program Files (x86)"}
roots = lh.hub_roots("win32", environ=env)
assert any("Program Files" in r and r.endswith("Editor") for r in roots)
# Both ProgramFiles roots are enumerated.
assert len(roots) == 2
def test_windows_hub_roots_fallback_when_no_env(self):
roots = lh.hub_roots("win32", environ={})
assert roots == [r"C:\Program Files\Unity\Hub\Editor"]
def test_linux_hub_root_under_home(self):
roots = lh.hub_roots("linux", environ={"HOME": "/home/dev"})
assert roots == ["/home/dev/Unity/Hub/Editor"]
class TestDiscoverEditorPerOS:
"""First existing+executable candidate wins, per-OS layout."""
def test_macos_resolves_hub_layout(self):
version = "6000.0.75f1"
binary = f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"
exists, is_exec, list_dir = _fake_fs({binary})
spec = resolve_editor_binary(
version,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert isinstance(spec, EditorSpec)
assert spec.binary == binary
assert spec.version == version
def test_windows_resolves_hub_layout(self):
version = "2021.3.45f2"
env = {"ProgramFiles": r"C:\Program Files"}
binary = str(Path(r"C:\Program Files") / "Unity" / "Hub" / "Editor" / version / "Editor" / "Unity.exe")
exists, is_exec, list_dir = _fake_fs({binary})
spec = resolve_editor_binary(
version,
platform="win32",
environ=env,
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == binary
assert spec.version == version
def test_linux_resolves_hub_layout(self):
version = "6000.0.75f1"
env = {"HOME": "/home/dev"}
binary = str(Path("/home/dev") / "Unity" / "Hub" / "Editor" / version / "Editor" / "Unity")
exists, is_exec, list_dir = _fake_fs({binary})
spec = resolve_editor_binary(
version,
platform="linux",
environ=env,
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == binary
def test_explicit_editor_takes_precedence(self):
version = "6000.0.75f1"
explicit = "/custom/path/Unity"
hub = f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"
# Both exist; explicit (precedence 1) must win.
exists, is_exec, list_dir = _fake_fs({explicit, hub})
spec = resolve_editor_binary(
version,
explicit_editor=explicit,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == explicit
def test_unity_editor_env_override(self):
version = "6000.0.75f1"
env_editor = "/env/Unity"
exists, is_exec, list_dir = _fake_fs({env_editor})
spec = resolve_editor_binary(
version,
platform="darwin",
environ={"UNITY_EDITOR": env_editor},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == env_editor
def test_not_found_raises_with_searched_paths(self):
version = "6000.0.75f1"
exists, is_exec, list_dir = _fake_fs(set()) # nothing exists
with pytest.raises(EditorNotFound) as ei:
resolve_editor_binary(
version,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
searched = ei.value.searched
assert searched, "EditorNotFound must carry the probed paths"
# The Hub candidate path must appear in the search report.
assert any(version in s and s.endswith("Unity") for s in searched)
assert version in str(ei.value)
def test_nearest_patch_fallback_same_major_minor(self):
"""When the exact version is absent, pick the highest installed patch
within the SAME major.minor, never crossing major.minor."""
want = "6000.0.75f1"
root = "/Applications/Unity/Hub/Editor"
# Installed: a lower patch (same major.minor), a higher patch (same
# major.minor), and a different minor that must be ignored.
v_lower = "6000.0.50f1"
v_higher = "6000.0.80f1"
v_other_minor = "6000.1.10f1"
bin_lower = f"{root}/{v_lower}/Unity.app/Contents/MacOS/Unity"
bin_higher = f"{root}/{v_higher}/Unity.app/Contents/MacOS/Unity"
bin_other = f"{root}/{v_other_minor}/Unity.app/Contents/MacOS/Unity"
exists, is_exec, list_dir = _fake_fs(
{bin_lower, bin_higher, bin_other},
dirs={root: [v_lower, v_higher, v_other_minor]},
)
spec = resolve_editor_binary(
want,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
# Highest patch within 6000.0.x wins; the 6000.1.x install is excluded.
assert spec.binary == bin_higher
assert spec.version == v_higher
def test_nearest_patch_never_crosses_major_minor(self):
"""If only a different major.minor is installed, discovery must fail
rather than silently substituting an incompatible editor."""
want = "6000.0.75f1"
root = "/Applications/Unity/Hub/Editor"
v_wrong = "2021.3.45f2"
bin_wrong = f"{root}/{v_wrong}/Unity.app/Contents/MacOS/Unity"
exists, is_exec, list_dir = _fake_fs({bin_wrong}, dirs={root: [v_wrong]})
with pytest.raises(EditorNotFound):
resolve_editor_binary(
want,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
class TestDiscoverEditorMonkeypatchedPlatform:
"""Same resolution, but driving sys.platform + os.path via monkeypatch to
prove the default (non-injected) code paths honor the OS."""
def test_default_platform_darwin(self, monkeypatch):
version = "6000.0.75f1"
binary = f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"
monkeypatch.setattr(lh.sys, "platform", "darwin")
# Inject only the filesystem; let platform default from sys.platform.
exists, is_exec, list_dir = _fake_fs({binary})
spec = resolve_editor_binary(
version,
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == binary
def test_default_platform_linux(self, monkeypatch):
version = "6000.0.75f1"
monkeypatch.setattr(lh.sys, "platform", "linux")
env = {"HOME": "/home/ci"}
binary = str(Path("/home/ci") / "Unity" / "Hub" / "Editor" / version / "Editor" / "Unity")
exists, is_exec, list_dir = _fake_fs({binary})
spec = resolve_editor_binary(
version,
environ=env,
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=_no_secondary,
)
assert spec.binary == binary
class TestSecondaryInstallPath:
"""Hub secondaryInstallPath is consulted as a candidate root."""
def test_secondary_root_used_for_candidate(self):
version = "6000.0.75f1"
secondary = "/Volumes/Big/UnityEditors"
binary = str(Path(secondary) / version / "Unity.app/Contents/MacOS/Unity")
exists, is_exec, list_dir = _fake_fs({binary})
def read_text(_p: str) -> str:
# Hub stores a JSON-encoded string path.
return f'"{secondary}"'
spec = resolve_editor_binary(
version,
platform="darwin",
environ={},
exists=exists,
is_exec=is_exec,
list_dir=list_dir,
read_text=read_text,
)
assert spec.binary == binary
# ===========================================================================
# resolve_unity_version: ProjectVersion.txt vs unity-versions.json precedence
# ===========================================================================
class TestResolveUnityVersion:
def test_project_version_takes_precedence(self, tmp_path):
"""ProjectVersion.txt wins over unity-versions.json defaultVersion."""
proj = tmp_path / "MyProject"
(proj / "ProjectSettings").mkdir(parents=True)
(proj / "ProjectSettings" / "ProjectVersion.txt").write_text(
"m_EditorVersion: 2021.3.45f2\nm_EditorVersionWithRevision: 2021.3.45f2 (abc123)\n",
encoding="utf-8",
)
versions_json = tmp_path / "unity-versions.json"
versions_json.write_text('{"defaultVersion": "6000.0.75f1"}', encoding="utf-8")
resolved = resolve_unity_version(proj, versions_json=versions_json)
assert resolved == "2021.3.45f2"
def test_falls_back_to_default_version_when_no_project_file(self, tmp_path):
proj = tmp_path / "EmptyProject"
proj.mkdir()
versions_json = tmp_path / "unity-versions.json"
versions_json.write_text('{"defaultVersion": "6000.0.75f1"}', encoding="utf-8")
resolved = resolve_unity_version(proj, versions_json=versions_json)
assert resolved == "6000.0.75f1"
def test_returns_none_when_neither_source_present(self, tmp_path):
proj = tmp_path / "EmptyProject"
proj.mkdir()
missing_json = tmp_path / "does-not-exist.json"
assert resolve_unity_version(proj, versions_json=missing_json) is None
def test_project_version_tolerates_bom(self, tmp_path):
proj = tmp_path / "BomProject"
(proj / "ProjectSettings").mkdir(parents=True)
# Write a UTF-8 BOM before the version line.
(proj / "ProjectSettings" / "ProjectVersion.txt").write_bytes(
b"\xef\xbb\xbfm_EditorVersion: 6000.0.75f1\n"
)
assert resolve_unity_version(proj, versions_json=tmp_path / "nope.json") == "6000.0.75f1"
def test_malformed_default_version_json_returns_none(self, tmp_path):
proj = tmp_path / "EmptyProject"
proj.mkdir()
versions_json = tmp_path / "unity-versions.json"
versions_json.write_text("{ this is not json", encoding="utf-8")
assert resolve_unity_version(proj, versions_json=versions_json) is None
def test_real_repo_versions_json_default(self):
"""Sanity-check the precedence helper against the real (committed)
tools/unity-versions.json defaultVersion, with an empty project so the
json branch is exercised."""
repo_json = lh.REPO_ROOT / "tools" / "unity-versions.json"
default = lh.read_default_version(repo_json)
assert isinstance(default, str) and default, "unity-versions.json must have a defaultVersion"
# ===========================================================================
# classify_editor_log: license-fatal -> 4, compile-error -> 3, clean -> ok
# ===========================================================================
class TestClassifyEditorLog:
def test_license_fatal_log(self):
log = (
"[Licensing::Client] Successfully resolved entitlement\n"
"No valid Unity Editor license found. License is not active.\n"
)
assert classify_editor_log(log, license_grace_elapsed=True) == "license_fatal"
def test_license_fatal_maps_to_exit_4_via_wait_for_ready_branching(self):
"""The harness wait loop maps a license_fatal classification to exit 4.
We assert the classification AND the mapping the live code uses
(license_fatal -> 4) so the fixture documents the exit contract without
booting Unity. Mapping mirrors wait_for_ready() / the warm-up branch.
"""
log = "cannot load ULF license file; Entitlement check failed"
kind = classify_editor_log(log, license_grace_elapsed=True)
assert kind == "license_fatal"
exit_code = {"license_fatal": 4, "compile_fatal": 3}.get(kind, 2)
assert exit_code == 4
def test_license_suppressed_during_grace(self):
"""Transient Licensing chatter before the grace window must NOT be
classified license_fatal."""
log = "No valid Unity Editor license found"
assert classify_editor_log(log, license_grace_elapsed=False) == "none"
def test_compile_error_log_maps_to_exit_3(self):
log = (
"Assets/Foo.cs(12,5): error CS0103: The name 'Bar' does not exist\n"
"Scripts have compiler errors.\n"
)
kind = classify_editor_log(log, license_grace_elapsed=True)
assert kind == "compile_fatal"
exit_code = {"license_fatal": 4, "compile_fatal": 3}.get(kind, 2)
assert exit_code == 3
def test_clean_ready_log_is_ok(self):
log = (
"[MCPForUnity] Bridge listening on port 6400\n"
"AutoConnect started; bound to loopback\n"
)
assert classify_editor_log(log, license_grace_elapsed=True) == "ready_ok"
def test_empty_log_is_none(self):
assert classify_editor_log("", license_grace_elapsed=True) == "none"
assert classify_editor_log(None, license_grace_elapsed=True) == "none"
def test_precedence_license_over_compile(self):
"""When both signals appear, license dominates (it is the more
actionable setup failure, exit 4 > 3)."""
log = "error CS0103: missing symbol\nLicense activation failed\n"
assert classify_editor_log(log, license_grace_elapsed=True) == "license_fatal"
def test_compile_when_license_in_grace(self):
"""Within the license grace window, a real compile error still
classifies as compile_fatal (compile gate is not grace-suppressed)."""
log = "License is not active\nerror CS1002: ; expected\n"
assert classify_editor_log(log, license_grace_elapsed=False) == "compile_fatal"
# ===========================================================================
# aggregate_exit_code: exit aggregation across leg results
# ===========================================================================
def _leg(name, status, blocking, exit_code):
return LegOutcome(name=name, status=status, blocking=blocking, exit_code=exit_code)
class TestAggregateExitCode:
def test_all_pass_is_zero(self):
outcomes = [
_leg("smoke", "pass", True, 0),
_leg("editmode", "pass", True, 0),
_leg("playmode", "pass", False, 0),
]
assert aggregate_exit_code(outcomes) == 0
def test_editmode_fail_yields_1(self):
outcomes = [
_leg("smoke", "pass", True, 0),
_leg("editmode", "fail", True, 1),
]
assert aggregate_exit_code(outcomes) == 1
def test_smoke_bridge_unreachable_is_2(self):
outcomes = [
_leg("smoke", "error", True, 2),
]
assert aggregate_exit_code(outcomes) == 2
def test_smoke_2_dominates_editmode_1(self):
"""Setup/infra severity (2) dominates a real test regression (1)."""
outcomes = [
_leg("smoke", "error", True, 2),
_leg("editmode", "fail", True, 1),
]
assert aggregate_exit_code(outcomes) == 2
def test_playmode_nonblocking_fail_does_not_raise_code(self):
"""The composite scenario from the task: smoke bridge-unreachable=2,
editmode fail=1, playmode non-blocking fail. The non-blocking PlayMode
leg must never raise the top-level code; severity ordering keeps 2."""
outcomes = [
_leg("smoke", "error", True, 2),
_leg("editmode", "fail", True, 1),
_leg("playmode", "fail", False, 1), # non-blocking
]
assert aggregate_exit_code(outcomes) == 2
def test_playmode_nonblocking_fail_alone_is_zero(self):
outcomes = [
_leg("smoke", "pass", True, 0),
_leg("editmode", "pass", True, 0),
_leg("playmode", "fail", False, 1), # non-blocking -> swallowed
]
assert aggregate_exit_code(outcomes) == 0
def test_strict_playmode_blocking_fail_yields_1(self):
"""Under --strict-playmode the leg is blocking and a failure raises 1."""
outcomes = [
_leg("smoke", "pass", True, 0),
_leg("editmode", "pass", True, 0),
_leg("playmode", "fail", True, 1), # blocking via --strict-playmode
]
assert aggregate_exit_code(outcomes) == 1
def test_compile_fatal_3_dominates_test_fail_1(self):
outcomes = [
_leg("editmode", "fail", True, 3), # compile fatal
_leg("playmode", "fail", False, 1),
]
assert aggregate_exit_code(outcomes) == 3
def test_license_4_dominates_compile_3(self):
outcomes = [
_leg("editmode", "fail", True, 3),
_leg("smoke", "error", True, 4),
]
assert aggregate_exit_code(outcomes) == 4
def test_failing_leg_without_explicit_code_defaults_to_1(self):
outcomes = [_leg("editmode", "fail", True, 0)]
assert aggregate_exit_code(outcomes) == 1
# ===========================================================================
# merge_junit: well-formed merged <testsuites>
# ===========================================================================
class TestMergeJUnit:
def test_merge_produces_wellformed_testsuites(self):
suites = [
JUnitSuite(
name="smoke",
cases=[JUnitCase(name="ping", time_s=0.1)],
),
JUnitSuite(
name="editmode",
cases=[
JUnitCase(name="t_pass", time_s=0.5),
JUnitCase(name="t_fail", time_s=0.2, failure="AssertionError: boom"),
JUnitCase(name="t_skip", time_s=0.0, skipped=True),
],
),
]
tree = merge_junit(suites)
root = tree.getroot()
assert root.tag == "testsuites"
# Roundtrip through serialization to prove it is well-formed XML.
serialized = ET.tostring(root, encoding="unicode")
reparsed = ET.fromstring(serialized)
assert reparsed.tag == "testsuites"
# Aggregate counts on the root.
assert root.get("tests") == "4"
assert root.get("failures") == "1"
assert root.get("skipped") == "1"
testsuite_els = root.findall("testsuite")
assert len(testsuite_els) == 2
names = {ts.get("name") for ts in testsuite_els}
assert names == {"smoke", "editmode"}
editmode = next(ts for ts in testsuite_els if ts.get("name") == "editmode")
assert editmode.get("tests") == "3"
assert editmode.get("failures") == "1"
assert editmode.get("skipped") == "1"
# The failing case carries a <failure> element with the message text.
fail_case = next(tc for tc in editmode.findall("testcase") if tc.get("name") == "t_fail")
fail_el = fail_case.find("failure")
assert fail_el is not None
assert "boom" in (fail_el.text or "")
# The skipped case carries a <skipped/> element.
skip_case = next(tc for tc in editmode.findall("testcase") if tc.get("name") == "t_skip")
assert skip_case.find("skipped") is not None
def test_merge_empty_suite_list_is_wellformed(self):
tree = merge_junit([])
root = tree.getroot()
assert root.tag == "testsuites"
assert root.get("tests") == "0"
assert root.get("failures") == "0"
# Roundtrips cleanly.
ET.fromstring(ET.tostring(root, encoding="unicode"))
def test_merge_skips_none_suites(self):
tree = merge_junit([None, JUnitSuite(name="only", cases=[JUnitCase(name="t")])])
root = tree.getroot()
assert root.get("tests") == "1"
assert len(root.findall("testsuite")) == 1
def test_failure_message_attribute_is_capped(self):
long_msg = "x" * 500
tree = merge_junit([JUnitSuite(name="s", cases=[JUnitCase(name="big", failure=long_msg)])])
fail_el = tree.getroot().find("testsuite").find("testcase").find("failure")
# Attribute is truncated to 200 chars; full text preserved in body.
assert len(fail_el.get("message")) == 200
assert fail_el.text == long_msg
def test_time_attributes_are_formatted(self):
tree = merge_junit([JUnitSuite(name="s", cases=[JUnitCase(name="t", time_s=1.2345)])])
ts = tree.getroot().find("testsuite")
# Three-decimal formatting on suite + case time.
assert ts.get("time") == "1.234" or ts.get("time") == "1.235"
tc = ts.find("testcase")
assert tc.get("time").count(".") == 1
# ===========================================================================
# build_arg_parser: defaults + flag parsing
# ===========================================================================
class TestBuildArgParser:
def test_defaults(self):
parser = build_arg_parser()
ns = parser.parse_args([])
assert ns.legs == "smoke,editmode,playmode"
assert ns.project_path == lh.DEFAULT_PROJECT_PATH
assert ns.editor is None
assert ns.ci is False
assert ns.reuse is False
assert ns.keep_alive is False
assert ns.no_warmup is False
assert ns.strict_playmode is False
assert ns.playmode_init_timeout == lh.DEFAULT_PLAYMODE_INIT_TIMEOUT_MS
assert ns.editor_args == []
def test_legs_and_project_override(self):
parser = build_arg_parser()
ns = parser.parse_args(["--legs", "smoke", "--project-path", "/tmp/MyProj"])
assert ns.legs == "smoke"
assert ns.project_path == "/tmp/MyProj"
def test_boolean_flags(self):
parser = build_arg_parser()
ns = parser.parse_args(["--ci", "--reuse", "--strict-playmode", "--no-warmup", "--keep-alive"])
assert ns.ci is True
assert ns.reuse is True
assert ns.strict_playmode is True
assert ns.no_warmup is True
assert ns.keep_alive is True
def test_editor_arg_is_repeatable(self):
parser = build_arg_parser()
# Dash-prefixed editor args must use the --opt=value form so argparse
# does not mistake the value for another option (this is how the
# harness docstring's -manualLicenseFile example must be passed).
ns = parser.parse_args(
["--editor-arg=-manualLicenseFile", "--editor-arg", "/root/Unity_lic.ulf"]
)
assert ns.editor_args == ["-manualLicenseFile", "/root/Unity_lic.ulf"]
def test_numeric_args_are_typed(self):
parser = build_arg_parser()
ns = parser.parse_args(
["--boot-timeout", "1200", "--bridge-wait", "300", "--playmode-init-timeout", "60000"]
)
assert ns.boot_timeout == 1200
assert isinstance(ns.boot_timeout, int)
assert ns.bridge_wait == 300
assert ns.playmode_init_timeout == 60000
# ===========================================================================
# parse_legs: ordered, de-duplicated, allow-listed (supporting helper)
# ===========================================================================
class TestParseLegs:
def test_normalizes_and_dedupes(self):
assert lh.parse_legs("smoke, EditMode ,smoke,playmode") == ["smoke", "editmode", "playmode"]
def test_drops_unknown_legs(self):
assert lh.parse_legs("smoke,bogus,editmode") == ["smoke", "editmode"]
def test_empty_string_is_empty_list(self):
assert lh.parse_legs("") == []
# ===========================================================================
# compile_probe: must read read_console entries from the bare-list "data"
# envelope. Regression: the exit-3 compile gate was dead because it only looked
# under "items"/"result", so a real `error CS0103` slipped through as ok.
# ===========================================================================
class TestConsoleEntriesAndCompileProbe:
def test_console_entries_bare_list_under_data(self):
resp = {"success": True, "message": "1 entry",
"data": [{"type": "Error", "message": "Assets/X.cs(1,1): error CS0103: bad"}]}
entries = lh._console_entries(resp)
assert isinstance(entries, list) and len(entries) == 1
def test_console_entries_paging_items_under_data(self):
resp = {"success": True, "data": {"items": [{"message": "info"}], "next_cursor": 5}}
assert lh._console_entries(resp) == [{"message": "info"}]
def test_console_entries_result_wrapped(self):
resp = {"result": {"success": True, "data": [{"message": "x"}]}}
assert lh._console_entries(resp) == [{"message": "x"}]
def test_compile_probe_detects_cs_error(self):
def fake_send(cmd, params, **kw):
assert cmd == "read_console"
return {"success": True, "message": "1 error",
"data": [{"type": "Error",
"message": "Assets/Foo.cs(10,5): error CS0103: 'X' not found"}]}
assert lh.compile_probe("inst@hash", 1, 50, send=fake_send) is False
def test_compile_probe_clean_project(self):
def fake_send(cmd, params, **kw):
return {"success": True, "message": "0 errors", "data": []}
assert lh.compile_probe("inst@hash", 1, 50, send=fake_send) is True
def test_compile_probe_inconclusive_does_not_block(self):
# An errored probe is inconclusive -> treated as compiles-OK (do not block).
def fake_send(cmd, params, **kw):
return {"success": False, "error": "boom"}
assert lh.compile_probe("inst@hash", 1, 50, send=fake_send) is True
# ===========================================================================
# _start_utf: the tests_running back-off must fire BEFORE the _ok() gate.
# Regression: the success:false ErrorResponse was rejected by _ok() first, so a
# transient "tests already running" was misreported as a hard start failure.
# ===========================================================================
class TestStartUtfTestsRunning:
def test_retries_on_tests_running_then_succeeds(self, monkeypatch):
monkeypatch.setattr(lh.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
def fake_send(cmd, params, **kw):
calls["n"] += 1
if calls["n"] == 1:
return {"success": False, "error": "tests_running",
"data": {"retry_after_ms": 10}}
return {"success": True, "data": {"job_id": "J42"}}
job_id, _ = lh._start_utf(fake_send, "EditMode", "inst@hash", 120000, 1, 50)
assert job_id == "J42"
assert calls["n"] == 2 # retried exactly once
def test_exhausts_after_persistent_tests_running(self, monkeypatch):
monkeypatch.setattr(lh.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
def fake_send(cmd, params, **kw):
calls["n"] += 1
return {"success": False, "error": "tests_running", "data": {"retry_after_ms": 1}}
job_id, marker = lh._start_utf(fake_send, "EditMode", "inst@hash", None, 1, 50)
assert job_id is None
assert calls["n"] == 5 # bounded retry budget
assert "exhausted" in str(marker)
def test_hard_start_failure_returns_immediately(self):
def fake_send(cmd, params, **kw):
return {"success": False, "error": "bridge_down"}
job_id, _ = lh._start_utf(fake_send, "EditMode", "inst@hash", None, 1, 50)
assert job_id is None
def test_success_returns_job_id(self):
def fake_send(cmd, params, **kw):
assert params.get("initTimeout") == 120000
return {"success": True, "data": {"job_id": 7}}
job_id, _ = lh._start_utf(fake_send, "PlayMode", "inst@hash", 120000, 1, 50)
assert job_id == "7"
# ===========================================================================
# UTF poll/start transport resilience. Regression: a live EditMode run blocks
# Unity's main thread, so get_test_job times out mid-run; the poll/start loops
# must treat a raised transport error as non-terminal, not crash the harness.
# ===========================================================================
class TestUtfTransportResilience:
def test_poll_tolerates_timeout_then_returns_terminal(self, monkeypatch):
monkeypatch.setattr(lh.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
def fake_send(cmd, params, **kw):
calls["n"] += 1
if calls["n"] <= 3:
raise TimeoutError("Timeout receiving Unity response")
return {"success": True, "data": {"status": "succeeded",
"result": {"summary": {"total": 2, "passed": 2}}}}
terminal = lh._poll_utf(fake_send, "J1", "inst@hash", lh.time.time() + 1000, 8, 50)
assert lh._dig(terminal, "status") == "succeeded"
assert calls["n"] == 4 # 3 timeouts tolerated, then terminal
def test_poll_wedges_after_deadline_without_crashing(self, monkeypatch):
monkeypatch.setattr(lh.time, "sleep", lambda *_a, **_k: None)
def fake_send(cmd, params, **kw):
raise TimeoutError("editor unresponsive")
terminal = lh._poll_utf(fake_send, "J1", "inst@hash", lh.time.time() + 0.3, 8, 50)
assert isinstance(terminal, dict) and terminal.get("_wedge") is True
def test_start_tolerates_transport_exception_then_succeeds(self, monkeypatch):
monkeypatch.setattr(lh.time, "sleep", lambda *_a, **_k: None)
calls = {"n": 0}
def fake_send(cmd, params, **kw):
calls["n"] += 1
if calls["n"] == 1:
raise TimeoutError("transient")
return {"success": True, "data": {"job_id": "J9"}}
job_id, _ = lh._start_utf(fake_send, "EditMode", "inst@hash", None, 8, 50)
assert job_id == "J9"
assert calls["n"] == 2
+28
View File
@@ -0,0 +1,28 @@
{
"$comment": "Single source of truth for Unity versions exercised by CI and tools/check-unity-versions.* local script. Versions are pinned to specific patch releases (no floating tags) per acceptance criteria in CoplayDev/unity-mcp#1107. Every id MUST have a corresponding GameCI Docker image at unityci/editor:ubuntu-<id>-base-3 since both CI (game-ci/unity-test-runner@v4) and local --docker mode pull from there. Verify availability before bumping: https://hub.docker.com/v2/repositories/unityci/editor/tags/?name=<id>",
"$coverageGap": "UNITY_6000_5_OR_NEWER and UNITY_6000_6_OR_NEWER branches are NOT exercised by this matrix because GameCI has not yet published Docker images for Unity 6000.5+/6000.6+. Bump the 'rolling' row to a 6000.5+/6000.6+ patch once https://hub.docker.com/r/unityci/editor publishes one. Tracked branches in code: UnityObjectIdCompat.cs (EntityId path).",
"defaultVersion": "6000.0.75f1",
"$defaultVersionComment": "Unity version used when CI runs the narrow matrix (default PR feedback path). Must be one of the ids in 'versions' below. Today's pick is 6.0 LTS — the version most users target. The 'floor' role still identifies the package's minimum supported version separately.",
"versions": [
{
"id": "2021.3.45f2",
"role": "floor",
"notes": "Package minimum (matches MCPForUnity/package.json 'unity' field and TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt). Legacy branch: all UNITY_2022_*/UNITY_6000_* flags OFF."
},
{
"id": "2022.3.62f1",
"role": "lts",
"notes": "Last 2022 LTS. Exercises UNITY_2022_1/2/3_OR_NEWER branches; UNITY_2023_*/UNITY_6000_* OFF. Catches FindObjectsByType-style regressions (the #1105 path)."
},
{
"id": "6000.0.75f1",
"role": "lts",
"notes": "Unity 6.0 LTS (latest patch on GameCI). UNITY_6000_0_OR_NEWER ON (27 files), UNITY_2023_*_OR_NEWER ON, UNITY_6000_3/4/5/6 OFF."
},
{
"id": "6000.4.8f1",
"role": "rolling",
"notes": "Latest 6.x available on GameCI. UNITY_6000_0/1/2/3/4_OR_NEWER ON; UNITY_6000_5/6 OFF (no GameCI image yet — see $coverageGap)."
}
]
}
+17
View File
@@ -0,0 +1,17 @@
@echo off
setlocal
git checkout main
if errorlevel 1 exit /b 1
git fetch -ap upstream
if errorlevel 1 exit /b 1
git fetch -ap
if errorlevel 1 exit /b 1
git rebase upstream/main
if errorlevel 1 exit /b 1
git push
if errorlevel 1 exit /b 1
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
git checkout main
git fetch -ap upstream
git fetch -ap
git rebase upstream/main
git push
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""Update version across all project files.
This script updates the version in all files that need it:
- MCPForUnity/package.json (Unity package version)
- manifest.json (MCP bundle manifest)
- Server/pyproject.toml (Python package version)
- Server/README.md (version references)
- README.md (fixed version examples)
- docs/i18n/README-zh.md (fixed version examples)
Usage:
python3 tools/update_versions.py [--dry-run] [--version VERSION]
Options:
--dry-run: Show what would be updated without making changes
--version: Specify version to use (auto-detected from package.json if not provided)
Examples:
# Update all files to match package.json version
python3 tools/update_versions.py
# Update all files to a specific version
python3 tools/update_versions.py --version 9.2.0
# Dry run to see what would be updated
python3 tools/update_versions.py --dry-run
"""
import argparse
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PACKAGE_JSON = REPO_ROOT / "MCPForUnity" / "package.json"
MANIFEST_JSON = REPO_ROOT / "manifest.json"
PYPROJECT_TOML = REPO_ROOT / "Server" / "pyproject.toml"
SERVER_README = REPO_ROOT / "Server" / "README.md"
ROOT_README = REPO_ROOT / "README.md"
ZH_README = REPO_ROOT / "docs" / "i18n" / "README-zh.md"
def load_package_version() -> str:
"""Load version from package.json."""
if not PACKAGE_JSON.exists():
raise FileNotFoundError(f"Package file not found: {PACKAGE_JSON}")
package_data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
version = package_data.get("version")
if not version:
raise ValueError("No version found in package.json")
return version
def update_package_json(new_version: str, dry_run: bool = False) -> bool:
"""Update version in MCPForUnity/package.json."""
if not PACKAGE_JSON.exists():
print(f"Warning: {PACKAGE_JSON.relative_to(REPO_ROOT)} not found")
return False
package_data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8"))
current_version = package_data.get("version", "unknown")
if current_version == new_version:
print(f"{PACKAGE_JSON.relative_to(REPO_ROOT)} already at v{new_version}")
return False
print(
f"Updating {PACKAGE_JSON.relative_to(REPO_ROOT)}: {current_version}{new_version}")
if not dry_run:
package_data["version"] = new_version
PACKAGE_JSON.write_text(
json.dumps(package_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return True
def update_manifest_json(new_version: str, dry_run: bool = False) -> bool:
"""Update version in manifest.json."""
if not MANIFEST_JSON.exists():
print(f"Warning: {MANIFEST_JSON.relative_to(REPO_ROOT)} not found")
return False
manifest = json.loads(MANIFEST_JSON.read_text(encoding="utf-8"))
current_version = manifest.get("version", "unknown")
if current_version == new_version:
print(f"{MANIFEST_JSON.relative_to(REPO_ROOT)} already at v{new_version}")
return False
print(
f"Updating {MANIFEST_JSON.relative_to(REPO_ROOT)}: {current_version}{new_version}")
if not dry_run:
manifest["version"] = new_version
MANIFEST_JSON.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return True
def update_pyproject_toml(new_version: str, dry_run: bool = False) -> bool:
"""Update version in Server/pyproject.toml."""
if not PYPROJECT_TOML.exists():
print(f"Warning: {PYPROJECT_TOML.relative_to(REPO_ROOT)} not found")
return False
content = PYPROJECT_TOML.read_text(encoding="utf-8")
# Find current version
version_match = re.search(r'^version = "([^"]+)"', content, re.MULTILINE)
if not version_match:
print(
f"Warning: Could not find version in {PYPROJECT_TOML.relative_to(REPO_ROOT)}")
return False
current_version = version_match.group(1)
if current_version == new_version:
print(f"{PYPROJECT_TOML.relative_to(REPO_ROOT)} already at v{new_version}")
return False
print(
f"Updating {PYPROJECT_TOML.relative_to(REPO_ROOT)}: {current_version}{new_version}")
if not dry_run:
# Replace only the first occurrence (the version field)
content = re.sub(
r'^version = ".*"', f'version = "{new_version}"', content, count=1, flags=re.MULTILINE)
PYPROJECT_TOML.write_text(content, encoding="utf-8")
return True
def update_server_readme(new_version: str, dry_run: bool = False) -> bool:
"""Update version references in Server/README.md."""
if not SERVER_README.exists():
print(f"Warning: {SERVER_README.relative_to(REPO_ROOT)} not found")
return False
content = SERVER_README.read_text(encoding="utf-8")
# Pattern to match git+https URLs with version tags
pattern = r'git\+https://github\.com/CoplayDev/unity-mcp@v[0-9]+\.[0-9]+\.[0-9]+#subdirectory=Server'
replacement = f'git+https://github.com/CoplayDev/unity-mcp@v{new_version}#subdirectory=Server'
if not re.search(pattern, content):
print(
f"{SERVER_README.relative_to(REPO_ROOT)} has no version references to update")
return False
print(
f"Updating version references in {SERVER_README.relative_to(REPO_ROOT)}")
if not dry_run:
content = re.sub(pattern, replacement, content)
SERVER_README.write_text(content, encoding="utf-8")
return True
def update_root_readme(new_version: str, dry_run: bool = False) -> bool:
"""Update fixed version examples in README.md."""
if not ROOT_README.exists():
print(f"Warning: {ROOT_README.relative_to(REPO_ROOT)} not found")
return False
content = ROOT_README.read_text(encoding="utf-8")
# Pattern to match git URLs with fixed version tags
pattern = r'https://github\.com/CoplayDev/unity-mcp\.git\?path=/MCPForUnity#v[0-9]+\.[0-9]+\.[0-9]+'
replacement = f'https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v{new_version}'
if not re.search(pattern, content):
print(
f"{ROOT_README.relative_to(REPO_ROOT)} has no version references to update")
return False
print(
f"Updating version references in {ROOT_README.relative_to(REPO_ROOT)}")
if not dry_run:
content = re.sub(pattern, replacement, content)
ROOT_README.write_text(content, encoding="utf-8")
return True
def update_zh_readme(new_version: str, dry_run: bool = False) -> bool:
"""Update fixed version examples in docs/i18n/README-zh.md."""
if not ZH_README.exists():
print(f"Warning: {ZH_README.relative_to(REPO_ROOT)} not found")
return False
content = ZH_README.read_text(encoding="utf-8")
# Pattern to match git URLs with fixed version tags
pattern = r'https://github\.com/CoplayDev/unity-mcp\.git\?path=/MCPForUnity#v[0-9]+\.[0-9]+\.[0-9]+'
replacement = f'https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v{new_version}'
if not re.search(pattern, content):
print(
f"{ZH_README.relative_to(REPO_ROOT)} has no version references to update")
return False
print(f"Updating version references in {ZH_README.relative_to(REPO_ROOT)}")
if not dry_run:
content = re.sub(pattern, replacement, content)
ZH_README.write_text(content, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser(
description="Update version across all project files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be updated without making changes",
)
parser.add_argument(
"--version",
help="Version to set (auto-detected from package.json if not provided)",
)
args = parser.parse_args()
try:
# Determine version
if args.version:
version = args.version
print(f"Using specified version: {version}")
else:
version = load_package_version()
print(f"Auto-detected version from package.json: {version}")
# Update all files
updates_made = []
# Always update package.json if a version is specified
if args.version:
if update_package_json(version, args.dry_run):
updates_made.append("MCPForUnity/package.json")
if update_manifest_json(version, args.dry_run):
updates_made.append("manifest.json")
if update_pyproject_toml(version, args.dry_run):
updates_made.append("Server/pyproject.toml")
if update_server_readme(version, args.dry_run):
updates_made.append("Server/README.md")
# Summary
if args.dry_run:
print("\nDry run complete. No files were modified.")
else:
if updates_made:
print(
f"\nUpdated {len(updates_made)} files: {', '.join(updates_made)}")
else:
print("\nAll files already at the correct version.")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())