chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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
|
||||
Executable
+285
@@ -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
|
||||
Executable
+54
@@ -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 \
|
||||
.
|
||||
@@ -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}` · "
|
||||
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}` ({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())
|
||||
Executable
+147
@@ -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())
|
||||
Executable
+42
@@ -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
|
||||
Executable
+50
@@ -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
|
||||
Executable
+84
@@ -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
@@ -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())
|
||||
Executable
+86
@@ -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[@]}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
@@ -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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+17
@@ -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
|
||||
Executable
+8
@@ -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
|
||||
Executable
+287
@@ -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())
|
||||
Reference in New Issue
Block a user