Compare commits

..

16 Commits
main ... beta

Author SHA1 Message Date
github-actions[bot] 8c6cefdd3f
chore: update Unity package to beta version 9.4.1-beta.1 (#683)
Co-authored-by: GitHub Actions <actions@github.com>
2026-02-03 14:50:30 -08:00
dsarno 6f3b869f3d
fix: speed up Claude Code config check by reading JSON directly (#682)
* fix: speed up Claude Code config check by reading JSON directly

Instead of running `claude mcp list` (15+ seconds due to health checks),
read the config directly from ~/.claude.json (instant).

Changes:
- Add ReadClaudeCodeConfig() to parse Claude's JSON config file
- Walk up directory tree to find config at parent directories
- Handle duplicate path entries (forward/backslash variants)
- Add beta/stable version mismatch detection with clear messages
- Add IsBetaPackageSource() to detect PyPI beta versions and prerelease ranges
- Change button label from "Register" to "Configure" for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: speed up Claude Code config check by reading JSON directly

Instead of running `claude mcp list` (15+ seconds due to health checks),
read the config directly from ~/.claude.json (instant).

Changes:
- Add ReadClaudeCodeConfig() to parse Claude's JSON config file
- Walk up directory tree to find config at parent directories
- Handle duplicate path entries (forward/backslash variants)
- Add beta/stable version mismatch detection with clear messages
- Add IsBetaPackageSource() to detect PyPI beta versions and prerelease ranges
- Change button label from "Register" to "Configure" for consistency
- Refresh client status when switching to Connect tab or toggling beta mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add VersionMismatch status for Claude Code config detection

- Add McpStatus.VersionMismatch enum value for version mismatch cases
- Show "Version Mismatch" with yellow warning indicator instead of "Error"
- Use VersionMismatch for beta/stable package source mismatches
- Keep Error status for transport mismatches and general errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add version mismatch warning banner in Server section

- Add version-mismatch-warning banner to McpConnectionSection.uxml
- Add UpdateVersionMismatchWarning method to show/hide the banner
- Fire OnClientConfigMismatch event when VersionMismatch status detected
- Wire up event in main window to update the warning banner
- Store mismatch details in configStatus for both Error and VersionMismatch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: simplify version mismatch messages for non-technical users

Before: "Beta/stable mismatch: registered with beta 'mcpforunityserver>=0.0.0a0' but plugin is stable 'mcpforunityserver==9.4.0'."

After: "Configured for beta server, but 'Use Beta Server' is disabled in Advanced settings."

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback

- Treat missing ~/.claude.json as "not configured" instead of error
  (distinguishes "no Claude Code installed" from actual read failures)
- Handle --from=VALUE format in ExtractPackageSourceFromConfig
  (in addition to existing --from VALUE format)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add beta/stable version mismatch detection to all JSON-based clients

- Move GetExpectedPackageSourceForValidation() and IsBetaPackageSource()
  to base class so all configurators can use them
- Update JsonFileMcpConfigurator.CheckStatus() to use beta-aware comparison
- Show VersionMismatch status with clear messaging for Claude Desktop,
  Cursor, Windsurf, VS Code, and other JSON-based clients
- Auto-rewrite still attempts to fix mismatches automatically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add beta-aware validation to CodexMcpConfigurator

CodexMcpConfigurator was still using the non-beta-aware package source
comparison. Now uses GetExpectedPackageSourceForValidation() and shows
VersionMismatch status with clear messaging like other configurators.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:48:44 -08:00
dsarno a8e478a42a
Merge pull request #681 from CoplayDev/fix/sync-beta-no-delete
fix: prevent main branch deletion in sync_beta step
2026-02-03 11:28:23 -08:00
dsarno ebe0296b51 fix: prevent main branch deletion in sync_beta step
Added --no-delete-branch flag to gh pr merge commands in the
sync_beta job. This prevents GitHub's auto-delete feature from
removing the main branch when the sync PR (main -> beta) merges.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:18:17 -08:00
github-actions[bot] c21b65efb5
Merge pull request #680 from CoplayDev/main
chore: sync main (v9.4.0) into beta
2026-02-03 18:43:58 +00:00
dsarno b7ed1f3494 chore: merge main (v9.3.2) into beta 2026-02-03 10:32:59 -08:00
github-actions[bot] b52d1563b0
chore: update Unity package to beta version 9.4.0-beta.2 (#674)
Co-authored-by: GitHub Actions <actions@github.com>
2026-02-03 09:59:07 -08:00
dsarno 7aa539c9fe
fix: beta workflow no longer auto-bumps minor version (#673)
* fix: beta workflow no longer auto-bumps minor version

Changes to beta-release.yml:
- Beta now bumps patch instead of minor (9.3.1 → 9.3.2-beta.1)
- Allows patch releases without being forced into minor bumps
- Increment beta number if already a beta version

Changes to release.yml:
- Added "none" bump option to release beta version as-is
- Added version status display showing main/beta versions and
  what the release version will be

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove redundant β character from version badge

The version string now includes "-beta.N" suffix, making the
separate β indicator redundant.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: improve version consistency in release workflows

beta-release.yml:
- Changed from already_beta to needs_update flag
- Only skip updates when computed version matches current
- PyPI versioning now detects existing prereleases and keeps
  the same base version instead of always bumping patch

release.yml:
- Preview step outputs stripped_version to GITHUB_OUTPUT
- Compute step uses previewed value for "none" bump option
- Added sanity check warning if versions diverge unexpectedly

This ensures the released version matches what was shown to the
user and prevents unnecessary patch bumps on consecutive beta runs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:53:01 -08:00
Shutong Wu 20e822b60f
Unity-MCP skills (#659)
* Initial Upload for Skills

* Update unity-mcp-skill/SKILL.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refine language in Unity-MCP Operator Guide

Updated wording for clarity and consistency in the guide.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-03 10:14:10 -05:00
dsarno 74236b6f66
fix: Beta mode status validation and EditorPrefs Manager improvements (#671)
* fix: Use beta server args for Claude Code registration

Claude Code registration was still using gitUrl directly instead of
GetBetaServerFromArgs(), meaning the beta server toggle had no effect
on registration. Now uses GetBetaServerFromArgs(quoteFromPath: true)
to properly generate --prerelease args for beta users.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: EditorPrefs Manager shows 'Unset' for keys that haven't been set

- Show "Unset. Default: [value]" for EditorPrefs that haven't been explicitly set
- Disable value field and save button for unset items (grayed out at 60% opacity)
- Fix search filter bug where reloading window showed filtered results with empty search field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Claude Code status check now accounts for beta server mode

The version validation was comparing the registered package source
(mcpforunityserver>=0.0.0a0 for beta) against the exact package version
(mcpforunityserver==9.4.0-beta.1), causing false "Incorrect Path" status.

Now both CheckStatus paths (main thread and background thread) use the
same logic that considers:
- Explicit GitUrlOverride
- UseBetaServer setting (with dynamic default for prerelease packages)
- Standard pinned version as fallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:16:30 -08:00
dsarno 2671f2091a
Merge pull request #669 from CoplayDev/beta-version-9.4.0-beta.1-21614965734
chore: update Unity package to beta version 9.4.0-beta.1
2026-02-02 22:25:51 -08:00
GitHub Actions b5a77feb9e chore: update Unity package to beta version 9.4.0-beta.1 2026-02-03 02:52:24 +00:00
dsarno 862c7fa4c3
chore: trigger beta workflow (#668)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:52:16 -08:00
dsarno a126ed6c3f fix: use PR-based approach for beta version updates
- Replace direct push with PR creation to bypass branch protection rules
- Add bot check to both jobs to prevent loops and double-publish
- Remove needs dependency so PyPI publish runs in parallel
- Simplify by not auto-merging the version PR

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:49:15 -08:00
dsarno cb08b0c59b
fix: Claude Code registration, thread-safety, and auto-detect beta server (#664) (#667)
* Fix Git URL in README for package installation

Updated the Git URL for adding the package to include the branch name.

* fix: Clean up Claude Code config from all scopes to prevent stale config conflicts (#664)

- Add RemoveFromAllScopes helper to remove from local/user/project scopes
- Use explicit --scope local when registering
- Update manual snippets to show multi-scope cleanup
- Handle legacy 'unityMCP' naming in all scopes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Make Claude Code status check thread-safe (#664)

The background thread status check was accessing main-thread-only Unity
APIs (Application.platform, EditorPrefs via HttpEndpointUtility and
AssetPathUtility), causing "GetString can only be called from main thread"
errors.

Now all main-thread-only values are captured before Task.Run() and passed
as parameters to CheckStatusWithProjectDir().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Persist client dropdown selection and remove dead IO code

- Remember last selected client in EditorPrefs so it restores on window
  reopen (prevents hiding config issues by defaulting to first client)
- Remove dead Outbound class, _outbox BlockingCollection, and writer
  thread that was never used (nothing ever enqueued to outbox)
- Keep only failure IO logs, remove verbose success logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Auto-detect beta package to enable UseBetaServer + workflow updates

- Add IsPreReleaseVersion() helper to detect beta/alpha/rc package versions
- UseBetaServer now defaults to true only for prerelease package versions
- Main branch users get false default, beta branch users get true default
- Update beta-release.yml to set Unity package version with -beta.1 suffix
- Update release.yml to merge beta → main and strip beta suffix
- Fix CodexConfigHelperTests to explicitly set UseBetaServer for determinism
- Use EditorConfigurationCache consistently for UseBetaServer access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Address code review feedback for thread-safety and validation

- Add thread-safe overloads for GetBetaServerFromArgs/List that accept
  pre-captured useBetaServer and gitUrlOverride parameters
- Use EditorConfigurationCache.SetUseBetaServer() in McpAdvancedSection
  for atomic cache + EditorPrefs update
- Add semver validation guard in beta-release.yml before version arithmetic
- Add semver validation guard in release.yml after stripping prerelease suffix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Complete thread-safety for GetBetaServerFromArgs overloads

- Add packageSource parameter to thread-safe overloads to avoid calling
  GetMcpServerPackageSource() (which uses EditorPrefs) from background threads
- Apply quoteFromPath logic to gitUrlOverride and packageSource paths to
  handle local paths with spaces correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Patch legacy connection pool in transport tests to prevent real Unity discovery

The auto-select tests were failing because they only patched PluginHub
but not the fallback legacy connection pool discovery. When PluginHub
returns no results, the middleware falls back to discovering instances
via get_unity_connection_pool(), which found the real running Unity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:35:01 -08:00
dsarno ac7f4a3099
Fix Git URL in README for MCPForUnity package
Updated the Git URL for the MCPForUnity package to include the branch name.
2026-02-01 08:16:17 -08:00
24 changed files with 2963 additions and 263 deletions

View File

@ -10,11 +10,128 @@ on:
- beta
paths:
- "Server/**"
- "MCPForUnity/**"
jobs:
update_unity_beta_version:
name: Update Unity package to beta version
runs-on: ubuntu-latest
# Avoid running when the workflow's own automation merges the PR
# created by this workflow (prevents a version-bump loop).
if: github.actor != 'github-actions[bot]'
permissions:
contents: write
pull-requests: write
outputs:
unity_beta_version: ${{ steps.version.outputs.unity_beta_version }}
version_updated: ${{ steps.commit.outputs.updated }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: beta
- name: Generate beta version for Unity package
id: version
shell: bash
run: |
set -euo pipefail
# Read current Unity package version
CURRENT_VERSION=$(jq -r '.version' MCPForUnity/package.json)
echo "Current Unity package version: $CURRENT_VERSION"
# Check if already a beta version - increment beta number
if [[ "$CURRENT_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-beta\.([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
BETA_NUM="${BASH_REMATCH[2]}"
NEXT_BETA=$((BETA_NUM + 1))
BETA_VERSION="${BASE_VERSION}-beta.${NEXT_BETA}"
echo "Incrementing beta number: $CURRENT_VERSION -> $BETA_VERSION"
elif [[ "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
# Stable version - bump patch and add -beta.1 suffix
# This ensures beta is "newer" than stable (9.3.2-beta.1 > 9.3.1)
# The release workflow decides final bump type (patch/minor/major)
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
NEXT_PATCH=$((PATCH + 1))
BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}-beta.1"
echo "Converting stable to beta: $CURRENT_VERSION -> $BETA_VERSION"
else
echo "Error: Could not parse version '$CURRENT_VERSION'" >&2
exit 1
fi
# Always output the computed version
echo "unity_beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"
# Only skip update if computed version matches current (no change needed)
if [[ "$BETA_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Version unchanged, skipping update"
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Version will be updated: $CURRENT_VERSION -> $BETA_VERSION"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
- name: Update Unity package.json with beta version
if: steps.version.outputs.needs_update == 'true'
env:
BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }}
shell: bash
run: |
set -euo pipefail
# Update package.json version
jq --arg v "$BETA_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json
mv tmp.json MCPForUnity/package.json
echo "Updated MCPForUnity/package.json:"
jq '.version' MCPForUnity/package.json
- name: Commit to temporary branch and create PR
id: commit
if: steps.version.outputs.needs_update == 'true'
env:
GH_TOKEN: ${{ github.token }}
BETA_VERSION: ${{ steps.version.outputs.unity_beta_version }}
shell: bash
run: |
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
if git diff --quiet MCPForUnity/package.json; then
echo "No changes to commit"
echo "updated=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Create a temporary branch for the version update
BRANCH="beta-version-${BETA_VERSION}-${GITHUB_RUN_ID}"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
git checkout -b "$BRANCH"
git add MCPForUnity/package.json
git commit -m "chore: update Unity package to beta version ${BETA_VERSION}"
git push origin "$BRANCH"
# Check if PR already exists
if gh pr view "$BRANCH" >/dev/null 2>&1; then
echo "PR already exists for $BRANCH"
else
PR_URL=$(gh pr create \
--base beta \
--head "$BRANCH" \
--title "chore: update Unity package to beta version ${BETA_VERSION}" \
--body "Automated beta version bump for the Unity package.")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
fi
echo "updated=true" >> "$GITHUB_OUTPUT"
publish_pypi_prerelease:
name: Publish beta to PyPI (pre-release)
runs-on: ubuntu-latest
# Avoid double-publish when the bot merges the version bump PR
if: github.actor != 'github-actions[bot]'
environment:
name: pypi
url: https://pypi.org/p/mcpforunityserver
@ -25,6 +142,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
ref: beta
- name: Install uv
uses: astral-sh/setup-uv@v7
@ -39,21 +157,39 @@ jobs:
run: |
set -euo pipefail
RAW_VERSION=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
# Strip any existing pre-release suffix (a, b, rc, dev, post) for safe parsing
# e.g., "9.2.0b1" -> "9.2.0", "9.2.0.dev1" -> "9.2.0"
BASE_VERSION=$(echo "$RAW_VERSION" | sed -E 's/(a|b|rc|\.dev|\.post)[0-9]+$//')
echo "Raw version: $RAW_VERSION"
# Check if already a beta/prerelease version
if [[ "$RAW_VERSION" =~ (a|b|rc|\.dev|\.post)[0-9]+$ ]]; then
IS_PRERELEASE=true
# Strip the prerelease suffix to get base version
BASE_VERSION=$(echo "$RAW_VERSION" | sed -E 's/(a|b|rc|\.dev|\.post)[0-9]+$//')
else
IS_PRERELEASE=false
BASE_VERSION="$RAW_VERSION"
fi
# Validate we have a proper X.Y.Z format
if ! [[ "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Could not parse version '$RAW_VERSION' -> '$BASE_VERSION'" >&2
exit 1
fi
# Bump minor version and use beta suffix (PEP 440 compliant: X.Y+1.0bN)
# This ensures beta is "newer" than the stable release
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
NEXT_MINOR=$((MINOR + 1))
# Only bump patch if coming from stable; keep same base if already prerelease
if [[ "$IS_PRERELEASE" == "true" ]]; then
# Already on a beta series - keep the same base version
NEXT_PATCH="$PATCH"
echo "Already prerelease, keeping base: $BASE_VERSION"
else
# Stable version - bump patch to ensure beta is "newer"
NEXT_PATCH=$((PATCH + 1))
echo "Stable version, bumping patch: $PATCH -> $NEXT_PATCH"
fi
BETA_NUMBER="$(date +%Y%m%d%H%M%S)"
BETA_VERSION="${MAJOR}.${NEXT_MINOR}.0b${BETA_NUMBER}"
echo "Raw version: $RAW_VERSION"
BETA_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}b${BETA_NUMBER}"
echo "Base version: $BASE_VERSION"
echo "Beta version: $BETA_VERSION"
echo "beta_version=$BETA_VERSION" >> "$GITHUB_OUTPUT"

View File

@ -8,12 +8,13 @@ on:
workflow_dispatch:
inputs:
version_bump:
description: "Version bump type"
description: "Version bump type (none = release beta version as-is)"
type: choice
options:
- patch
- minor
- major
- none
default: patch
required: true
@ -44,8 +45,102 @@ jobs:
ref: main
fetch-depth: 0
- name: Show current versions
id: preview
shell: bash
run: |
set -euo pipefail
echo "============================================"
echo "CURRENT VERSION STATUS"
echo "============================================"
# Get main version
MAIN_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
MAIN_PYPI=$(grep -oP '(?<=version = ")[^"]+' Server/pyproject.toml)
echo "Main branch:"
echo " Unity package: $MAIN_VERSION"
echo " PyPI server: $MAIN_PYPI"
echo ""
# Get beta version
git fetch origin beta
BETA_VERSION=$(git show origin/beta:MCPForUnity/package.json | jq -r '.version')
BETA_PYPI=$(git show origin/beta:Server/pyproject.toml | grep -oP '(?<=version = ")[^"]+')
echo "Beta branch:"
echo " Unity package: $BETA_VERSION"
echo " PyPI server: $BETA_PYPI"
echo ""
# Compute stripped version (used for "none" bump option)
STRIPPED=$(echo "$BETA_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//')
echo "stripped_version=$STRIPPED" >> "$GITHUB_OUTPUT"
# Show what will happen
BUMP="${{ inputs.version_bump }}"
echo "Selected bump type: $BUMP"
echo "After stripping beta suffix: $STRIPPED"
if [[ "$BUMP" == "none" ]]; then
echo "Release version will be: $STRIPPED"
else
IFS='.' read -r MA MI PA <<< "$STRIPPED"
case "$BUMP" in
major) ((MA+=1)); MI=0; PA=0 ;;
minor) ((MI+=1)); PA=0 ;;
patch) ((PA+=1)) ;;
esac
echo "Release version will be: $MA.$MI.$PA"
fi
echo "============================================"
- name: Merge beta into main
shell: bash
run: |
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
# Fetch beta branch
git fetch origin beta
# Check if beta has changes not in main
if git merge-base --is-ancestor origin/beta HEAD; then
echo "beta is already merged into main. Nothing to merge."
else
echo "Merging beta into main..."
git merge origin/beta --no-edit -m "chore: merge beta into main for release"
echo "Beta merged successfully."
fi
- name: Strip beta suffix from version if present
shell: bash
run: |
set -euo pipefail
CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
echo "Current version: $CURRENT_VERSION"
# Strip beta/alpha/rc suffix if present (e.g., "9.4.0-beta.1" -> "9.4.0")
if [[ "$CURRENT_VERSION" == *"-"* ]]; then
STABLE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-[a-zA-Z]+\.[0-9]+$//')
# Validate we have a proper X.Y.Z format after stripping
if ! [[ "$STABLE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Could not parse version '$CURRENT_VERSION' -> '$STABLE_VERSION'" >&2
exit 1
fi
echo "Stripping prerelease suffix: $CURRENT_VERSION -> $STABLE_VERSION"
jq --arg v "$STABLE_VERSION" '.version = $v' MCPForUnity/package.json > tmp.json
mv tmp.json MCPForUnity/package.json
# Also update pyproject.toml
sed -i "s/^version = .*/version = \"${STABLE_VERSION}\"/" Server/pyproject.toml
else
echo "Version is already stable: $CURRENT_VERSION"
fi
- name: Compute new version
id: compute
env:
PREVIEWED_STRIPPED: ${{ steps.preview.outputs.stripped_version }}
shell: bash
run: |
set -euo pipefail
@ -53,24 +148,35 @@ jobs:
CURRENT_VERSION=$(jq -r '.version' "MCPForUnity/package.json")
echo "Current version: $CURRENT_VERSION"
IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION"
case "$BUMP" in
major)
((MA+=1)); MI=0; PA=0
;;
minor)
((MI+=1)); PA=0
;;
patch)
((PA+=1))
;;
*)
echo "Unknown version_bump: $BUMP" >&2
exit 1
;;
esac
# Sanity check: ensure current version matches what was previewed
if [[ "$CURRENT_VERSION" != "$PREVIEWED_STRIPPED" ]]; then
echo "Warning: Current version ($CURRENT_VERSION) differs from previewed ($PREVIEWED_STRIPPED)"
echo "This may indicate an unexpected merge result. Proceeding with current version."
fi
if [[ "$BUMP" == "none" ]]; then
# Use the previewed stripped version to ensure consistency with what user saw
NEW_VERSION="$PREVIEWED_STRIPPED"
else
IFS='.' read -r MA MI PA <<< "$CURRENT_VERSION"
case "$BUMP" in
major)
((MA+=1)); MI=0; PA=0
;;
minor)
((MI+=1)); PA=0
;;
patch)
((PA+=1))
;;
*)
echo "Unknown version_bump: $BUMP" >&2
exit 1
;;
esac
NEW_VERSION="$MA.$MI.$PA"
fi
NEW_VERSION="$MA.$MI.$PA"
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
@ -244,7 +350,8 @@ jobs:
run: |
set -euo pipefail
# Enable auto-merge (requires repo setting "Allow auto-merge")
gh pr merge "$PR_NUMBER" --merge --auto || true
# Use --no-delete-branch to prevent deleting main (the head branch)
gh pr merge "$PR_NUMBER" --merge --auto --no-delete-branch || true
# Wait for PR to be merged (poll up to 2 minutes)
for i in {1..24}; do
STATE=$(gh pr view "$PR_NUMBER" --json state -q '.state')
@ -256,7 +363,7 @@ jobs:
sleep 5
done
echo "Sync PR did not merge in time. Attempting direct merge..."
gh pr merge "$PR_NUMBER" --merge
gh pr merge "$PR_NUMBER" --merge --no-delete-branch
publish_docker:
name: Publish Docker image

View File

@ -19,7 +19,7 @@ namespace MCPForUnity.Editor.Clients.Configurators
public override IList<string> GetInstallationSteps() => new List<string>
{
"Ensure Claude CLI is installed (comes with Claude Code)",
"Click Register to add UnityMCP via 'claude mcp add'",
"Click Configure to add UnityMCP via 'claude mcp add'",
"The server will be automatically available in Claude Code",
"Use Unregister to remove via 'claude mcp remove'"
};

View File

@ -78,6 +78,74 @@ namespace MCPForUnity.Editor.Clients
string Normalize(string value) => value.Trim().TrimEnd('/');
return string.Equals(Normalize(a), Normalize(b), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the expected package source for validation, accounting for beta mode.
/// This should match what Configure() would actually use for the --from argument.
/// MUST be called from the main thread due to EditorPrefs access.
/// </summary>
protected static string GetExpectedPackageSourceForValidation()
{
// Check for explicit override first
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(gitUrlOverride))
{
return gitUrlOverride;
}
// Check beta mode using the same logic as GetUseBetaServerWithDynamicDefault
// (bypass cache to ensure fresh read)
bool useBetaServer;
bool hasPrefKey = EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer);
if (hasPrefKey)
{
useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, false);
}
else
{
// Dynamic default based on package version
useBetaServer = AssetPathUtility.IsPreReleaseVersion();
}
if (useBetaServer)
{
return "mcpforunityserver>=0.0.0a0";
}
// Standard mode uses exact version from package.json
return AssetPathUtility.GetMcpServerPackageSource();
}
/// <summary>
/// Checks if a package source string represents a beta/prerelease version.
/// Beta versions include:
/// - PyPI beta: "mcpforunityserver==9.4.0b20250203..." (contains 'b' before timestamp)
/// - PyPI prerelease range: "mcpforunityserver>=0.0.0a0" (used when beta mode is enabled)
/// - Git beta branch: contains "@beta" or "-beta"
/// </summary>
protected static bool IsBetaPackageSource(string packageSource)
{
if (string.IsNullOrEmpty(packageSource))
return false;
// PyPI beta format: mcpforunityserver==X.Y.Zb<timestamp>
// The 'b' suffix before numbers indicates a PEP 440 beta version
if (System.Text.RegularExpressions.Regex.IsMatch(packageSource, @"==\d+\.\d+\.\d+b\d+"))
return true;
// PyPI prerelease range: >=0.0.0a0 (used when "Use Beta Server" is enabled in Unity settings)
if (packageSource.Contains(">=0.0.0a0", StringComparison.OrdinalIgnoreCase))
return true;
// Git-based beta references
if (packageSource.Contains("@beta", StringComparison.OrdinalIgnoreCase))
return true;
if (packageSource.Contains("-beta", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>JSON-file based configurator (Cursor, Windsurf, VS Code, etc.).</summary>
@ -174,12 +242,44 @@ namespace MCPForUnity.Editor.Clients
}
bool matches = false;
bool hasVersionMismatch = false;
string mismatchReason = null;
if (args != null && args.Length > 0)
{
string expectedUvxUrl = AssetPathUtility.GetMcpServerPackageSource();
// Use beta-aware expected package source for comparison
string expectedUvxUrl = GetExpectedPackageSourceForValidation();
string configuredUvxUrl = McpConfigurationHelper.ExtractUvxUrl(args);
matches = !string.IsNullOrEmpty(configuredUvxUrl) &&
McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl);
if (!string.IsNullOrEmpty(configuredUvxUrl) && !string.IsNullOrEmpty(expectedUvxUrl))
{
if (McpConfigurationHelper.PathsEqual(configuredUvxUrl, expectedUvxUrl))
{
matches = true;
}
else
{
// Check for beta/stable mismatch
bool configuredIsBeta = IsBetaPackageSource(configuredUvxUrl);
bool expectedIsBeta = IsBetaPackageSource(expectedUvxUrl);
if (configuredIsBeta && !expectedIsBeta)
{
hasVersionMismatch = true;
mismatchReason = "Configured for beta server, but 'Use Beta Server' is disabled in Advanced settings.";
}
else if (!configuredIsBeta && expectedIsBeta)
{
hasVersionMismatch = true;
mismatchReason = "Configured for stable server, but 'Use Beta Server' is enabled in Advanced settings.";
}
else
{
hasVersionMismatch = true;
mismatchReason = "Server version doesn't match the plugin. Re-configure to update.";
}
}
}
}
else if (!string.IsNullOrEmpty(configuredUrl))
{
@ -194,7 +294,27 @@ namespace MCPForUnity.Editor.Clients
return client.status;
}
if (attemptAutoRewrite)
if (hasVersionMismatch)
{
if (attemptAutoRewrite)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
}
else
{
client.SetStatus(McpStatus.VersionMismatch, mismatchReason);
}
}
else
{
client.SetStatus(McpStatus.VersionMismatch, mismatchReason);
}
}
else if (attemptAutoRewrite)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(path, client);
if (result == "Configured successfully")
@ -300,6 +420,9 @@ namespace MCPForUnity.Editor.Clients
}
bool matches = false;
bool hasVersionMismatch = false;
string mismatchReason = null;
if (!string.IsNullOrEmpty(url))
{
// Match against the active scope's URL
@ -307,10 +430,39 @@ namespace MCPForUnity.Editor.Clients
}
else if (args != null && args.Length > 0)
{
string expected = AssetPathUtility.GetMcpServerPackageSource();
// Use beta-aware expected package source for comparison
string expected = GetExpectedPackageSourceForValidation();
string configured = McpConfigurationHelper.ExtractUvxUrl(args);
matches = !string.IsNullOrEmpty(configured) &&
McpConfigurationHelper.PathsEqual(configured, expected);
if (!string.IsNullOrEmpty(configured) && !string.IsNullOrEmpty(expected))
{
if (McpConfigurationHelper.PathsEqual(configured, expected))
{
matches = true;
}
else
{
// Check for beta/stable mismatch
bool configuredIsBeta = IsBetaPackageSource(configured);
bool expectedIsBeta = IsBetaPackageSource(expected);
if (configuredIsBeta && !expectedIsBeta)
{
hasVersionMismatch = true;
mismatchReason = "Configured for beta server, but 'Use Beta Server' is disabled in Advanced settings.";
}
else if (!configuredIsBeta && expectedIsBeta)
{
hasVersionMismatch = true;
mismatchReason = "Configured for stable server, but 'Use Beta Server' is enabled in Advanced settings.";
}
else
{
hasVersionMismatch = true;
mismatchReason = "Server version doesn't match the plugin. Re-configure to update.";
}
}
}
}
if (matches)
@ -318,6 +470,22 @@ namespace MCPForUnity.Editor.Clients
client.SetStatus(McpStatus.Configured);
return client.status;
}
if (hasVersionMismatch)
{
if (attemptAutoRewrite)
{
string result = McpConfigurationHelper.ConfigureCodexClient(path, client);
if (result == "Configured successfully")
{
client.SetStatus(McpStatus.Configured);
client.configuredTransport = HttpEndpointUtility.GetCurrentServerTransport();
return client.status;
}
}
client.SetStatus(McpStatus.VersionMismatch, mismatchReason);
return client.status;
}
}
else
{
@ -394,7 +562,7 @@ namespace MCPForUnity.Editor.Clients
public ClaudeCliMcpConfigurator(McpClient client) : base(client) { }
public override bool SupportsAutoConfigure => true;
public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Register";
public override string GetConfigureActionLabel() => client.status == McpStatus.Configured ? "Unregister" : "Configure";
public override string GetConfigPath() => "Managed via Claude CLI";
@ -409,18 +577,26 @@ namespace MCPForUnity.Editor.Clients
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
// Resolve claudePath on the main thread (EditorPrefs access)
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite);
RuntimePlatform platform = Application.platform;
bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
// Get expected package source considering beta mode (matches what Register() would use)
string expectedPackageSource = GetExpectedPackageSourceForValidation();
return CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite);
}
/// <summary>
/// Internal thread-safe version of CheckStatus.
/// Can be called from background threads because all main-thread-only values are passed as parameters.
/// projectDir, useHttpTransport, and claudePath are REQUIRED (non-nullable) to enforce thread safety at compile time.
/// projectDir, useHttpTransport, claudePath, platform, isRemoteScope, and expectedPackageSource are REQUIRED
/// (non-nullable where applicable) to enforce thread safety at compile time.
/// NOTE: attemptAutoRewrite is NOT fully thread-safe because Configure() requires the main thread.
/// When called from a background thread, pass attemptAutoRewrite=false and handle re-registration
/// on the main thread based on the returned status.
/// </summary>
internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, string claudePath, bool attemptAutoRewrite = false)
internal McpStatus CheckStatusWithProjectDir(
string projectDir, bool useHttpTransport, string claudePath, RuntimePlatform platform,
bool isRemoteScope, string expectedPackageSource,
bool attemptAutoRewrite = false)
{
try
{
@ -437,135 +613,134 @@ namespace MCPForUnity.Editor.Clients
throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution");
}
string pathPrepend = null;
if (Application.platform == RuntimePlatform.OSXEditor)
// Read Claude Code config directly from ~/.claude.json instead of using slow CLI
// This is instant vs 15+ seconds for `claude mcp list` which does health checks
var configResult = ReadClaudeCodeConfig(projectDir);
if (configResult.error != null)
{
pathPrepend = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
}
else if (Application.platform == RuntimePlatform.LinuxEditor)
{
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
client.SetStatus(McpStatus.NotConfigured, configResult.error);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
try
if (configResult.serverConfig == null)
{
string claudeDir = Path.GetDirectoryName(claudePath);
if (!string.IsNullOrEmpty(claudeDir))
// UnityMCP not found in config
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
return client.status;
}
// UnityMCP is registered - check transport and version
bool currentUseHttp = useHttpTransport;
var serverConfig = configResult.serverConfig;
// Determine registered transport type
string registeredType = serverConfig["type"]?.ToString()?.ToLowerInvariant() ?? "";
bool registeredWithHttp = registeredType == "http";
bool registeredWithStdio = registeredType == "stdio";
// Set the configured transport based on what we detected
if (registeredWithHttp)
{
client.configuredTransport = isRemoteScope
? Models.ConfiguredTransport.HttpRemote
: Models.ConfiguredTransport.Http;
}
else if (registeredWithStdio)
{
client.configuredTransport = Models.ConfiguredTransport.Stdio;
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
// Check for transport mismatch
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
// For stdio transport, also check package version
bool hasVersionMismatch = false;
string configuredPackageSource = null;
string mismatchReason = null;
if (registeredWithStdio)
{
configuredPackageSource = ExtractPackageSourceFromConfig(serverConfig);
if (!string.IsNullOrEmpty(configuredPackageSource) && !string.IsNullOrEmpty(expectedPackageSource))
{
pathPrepend = string.IsNullOrEmpty(pathPrepend)
? claudeDir
: $"{claudeDir}:{pathPrepend}";
}
}
catch { }
// Check if UnityMCP exists (handles both "UnityMCP" and legacy "unityMCP")
if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend))
{
if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0)
{
// UnityMCP is registered - now verify transport mode matches
// useHttpTransport parameter is required (non-nullable) to ensure thread safety
bool currentUseHttp = useHttpTransport;
// Get detailed info about the registration to check transport type
// Try both "UnityMCP" and "unityMCP" (legacy naming)
string getStdout = null, getStderr = null;
bool gotInfo = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend)
|| ExecPath.TryRun(claudePath, "mcp get unityMCP", projectDir, out getStdout, out getStderr, 7000, pathPrepend);
if (gotInfo)
// Check for exact match first
if (!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase))
{
// Parse the output to determine registered transport mode
// The CLI output format contains "Type: http" or "Type: stdio"
bool registeredWithHttp = getStdout.Contains("Type: http", StringComparison.OrdinalIgnoreCase);
bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase);
hasVersionMismatch = true;
// Set the configured transport based on what we detected
// For HTTP, we can't distinguish local/remote from CLI output alone,
// so infer from the current scope setting when HTTP is detected.
if (registeredWithHttp)
// Provide more specific mismatch reason for beta/stable differences
bool configuredIsBeta = IsBetaPackageSource(configuredPackageSource);
bool expectedIsBeta = IsBetaPackageSource(expectedPackageSource);
if (configuredIsBeta && !expectedIsBeta)
{
client.configuredTransport = HttpEndpointUtility.IsRemoteScope()
? Models.ConfiguredTransport.HttpRemote
: Models.ConfiguredTransport.Http;
mismatchReason = "Configured for beta server, but 'Use Beta Server' is disabled in Advanced settings.";
}
else if (registeredWithStdio)
else if (!configuredIsBeta && expectedIsBeta)
{
client.configuredTransport = Models.ConfiguredTransport.Stdio;
mismatchReason = "Configured for stable server, but 'Use Beta Server' is enabled in Advanced settings.";
}
else
{
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
// Check for transport mismatch (3-way: Stdio, Http, HttpRemote)
bool hasTransportMismatch = (currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp);
// For stdio transport, also check package version
bool hasVersionMismatch = false;
string configuredPackageSource = null;
string expectedPackageSource = null;
if (registeredWithStdio)
{
expectedPackageSource = AssetPathUtility.GetMcpServerPackageSource();
configuredPackageSource = ExtractPackageSourceFromCliOutput(getStdout);
hasVersionMismatch = !string.IsNullOrEmpty(configuredPackageSource) &&
!string.Equals(configuredPackageSource, expectedPackageSource, StringComparison.OrdinalIgnoreCase);
}
// If there's any mismatch and auto-rewrite is enabled, re-register
if (hasTransportMismatch || hasVersionMismatch)
{
// Configure() requires main thread (accesses EditorPrefs, Application.dataPath)
// Only attempt auto-rewrite if we're on the main thread
bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1;
if (attemptAutoRewrite && isMainThread)
{
string reason = hasTransportMismatch
? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})"
: $"Package version mismatch (registered: {configuredPackageSource}, expected: {expectedPackageSource})";
McpLog.Info($"{reason}. Re-registering...");
try
{
// Force re-register by ensuring status is not Configured (which would toggle to Unregister)
client.SetStatus(McpStatus.IncorrectPath);
Configure();
return client.status;
}
catch (Exception ex)
{
McpLog.Warn($"Auto-reregister failed: {ex.Message}");
client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register.");
return client.status;
}
}
else
{
if (hasTransportMismatch)
{
string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register.";
client.SetStatus(McpStatus.Error, errorMsg);
McpLog.Warn(errorMsg);
}
else
{
client.SetStatus(McpStatus.IncorrectPath, $"Package version mismatch: registered with '{configuredPackageSource}' but current version is '{expectedPackageSource}'.");
}
return client.status;
}
mismatchReason = "Server version doesn't match the plugin. Re-configure to update.";
}
}
}
}
client.SetStatus(McpStatus.Configured);
// If there's any mismatch and auto-rewrite is enabled, re-register
if (hasTransportMismatch || hasVersionMismatch)
{
// Configure() requires main thread (accesses EditorPrefs, Application.dataPath)
// Only attempt auto-rewrite if we're on the main thread
bool isMainThread = System.Threading.Thread.CurrentThread.ManagedThreadId == 1;
if (attemptAutoRewrite && isMainThread)
{
string reason = hasTransportMismatch
? $"Transport mismatch (registered: {(registeredWithHttp ? "HTTP" : "stdio")}, expected: {(currentUseHttp ? "HTTP" : "stdio")})"
: mismatchReason ?? $"Package version mismatch";
McpLog.Info($"{reason}. Re-registering...");
try
{
// Force re-register by ensuring status is not Configured (which would toggle to Unregister)
client.SetStatus(McpStatus.IncorrectPath);
Configure();
return client.status;
}
catch (Exception ex)
{
McpLog.Warn($"Auto-reregister failed: {ex.Message}");
client.SetStatus(McpStatus.IncorrectPath, $"Configuration mismatch. Click Configure to re-register.");
return client.status;
}
}
else
{
if (hasTransportMismatch)
{
string errorMsg = $"Transport mismatch: Claude Code is registered with {(registeredWithHttp ? "HTTP" : "stdio")} but current setting is {(currentUseHttp ? "HTTP" : "stdio")}. Click Configure to re-register.";
client.SetStatus(McpStatus.Error, errorMsg);
McpLog.Warn(errorMsg);
}
else
{
client.SetStatus(McpStatus.VersionMismatch, mismatchReason);
}
return client.status;
}
}
client.SetStatus(McpStatus.NotConfigured);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
client.SetStatus(McpStatus.Configured);
return client.status;
}
catch (Exception ex)
{
McpLog.Warn($"[Claude Code] CheckStatus exception: {ex.GetType().Name}: {ex.Message}");
client.SetStatus(McpStatus.Error, ex.Message);
client.configuredTransport = Models.ConfiguredTransport.Unknown;
}
@ -592,7 +767,7 @@ namespace MCPForUnity.Editor.Clients
public void ConfigureWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh,
string uvxPath, string fromArgs, string packageName, bool shouldForceRefresh,
string apiKey,
Models.ConfiguredTransport serverTransport)
{
@ -603,7 +778,7 @@ namespace MCPForUnity.Editor.Clients
else
{
RegisterWithCapturedValues(projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl, uvxPath, gitUrl, packageName, shouldForceRefresh,
useHttpTransport, httpUrl, uvxPath, fromArgs, packageName, shouldForceRefresh,
apiKey, serverTransport);
}
}
@ -614,7 +789,7 @@ namespace MCPForUnity.Editor.Clients
private void RegisterWithCapturedValues(
string projectDir, string claudePath, string pathPrepend,
bool useHttpTransport, string httpUrl,
string uvxPath, string gitUrl, string packageName, bool shouldForceRefresh,
string uvxPath, string fromArgs, string packageName, bool shouldForceRefresh,
string apiKey,
Models.ConfiguredTransport serverTransport)
{
@ -627,27 +802,28 @@ namespace MCPForUnity.Editor.Clients
if (useHttpTransport)
{
// Only include API key header for remote-hosted mode
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
if (serverTransport == Models.ConfiguredTransport.HttpRemote && !string.IsNullOrEmpty(apiKey))
{
string safeKey = SanitizeShellHeaderValue(apiKey);
args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
args = $"mcp add --scope local --transport http UnityMCP {httpUrl}";
}
}
else
{
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = shouldForceRefresh ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}";
}
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664)
McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding...");
RemoveFromAllScopes(claudePath, projectDir, pathPrepend);
// Now add the registration
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
@ -670,10 +846,9 @@ namespace MCPForUnity.Editor.Clients
throw new InvalidOperationException("Claude CLI not found. Please install Claude Code first.");
}
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Remove from ALL scopes to ensure complete cleanup (#664)
McpLog.Info("Removing all UnityMCP registrations from all scopes...");
RemoveFromAllScopes(claudePath, projectDir, pathPrepend);
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured);
@ -696,31 +871,34 @@ namespace MCPForUnity.Editor.Clients
{
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
// Only include API key header for remote-hosted mode
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
if (HttpEndpointUtility.IsRemoteScope())
{
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
if (!string.IsNullOrEmpty(apiKey))
{
string safeKey = SanitizeShellHeaderValue(apiKey);
args = $"mcp add --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
args = $"mcp add --scope local --transport http UnityMCP {httpUrl} --header \"{AuthConstants.ApiKeyHeader}: {safeKey}\"";
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
args = $"mcp add --scope local --transport http UnityMCP {httpUrl}";
}
}
else
{
args = $"mcp add --transport http UnityMCP {httpUrl}";
args = $"mcp add --scope local --transport http UnityMCP {httpUrl}";
}
}
else
{
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
// Use --scope local to register in the project-local config, avoiding conflicts with user-level config (#664)
args = $"mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} {packageName}";
}
string projectDir = Path.GetDirectoryName(Application.dataPath);
@ -747,10 +925,9 @@ namespace MCPForUnity.Editor.Clients
}
catch { }
// Remove any existing registrations - handle both "UnityMCP" and "unityMCP" (legacy)
McpLog.Info("Removing any existing UnityMCP registrations before adding...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Remove any existing registrations from ALL scopes to prevent stale config conflicts (#664)
McpLog.Info("Removing any existing UnityMCP registrations from all scopes before adding...");
RemoveFromAllScopes(claudePath, projectDir, pathPrepend);
// Now add the registration with the current transport mode
if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend))
@ -787,10 +964,9 @@ namespace MCPForUnity.Editor.Clients
pathPrepend = "/usr/local/bin:/usr/bin:/bin";
}
// Remove both "UnityMCP" and "unityMCP" (legacy naming)
McpLog.Info("Removing all UnityMCP registrations...");
ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out _, out _, 7000, pathPrepend);
ExecPath.TryRun(claudePath, "mcp remove unityMCP", projectDir, out _, out _, 7000, pathPrepend);
// Remove from ALL scopes to ensure complete cleanup (#664)
McpLog.Info("Removing all UnityMCP registrations from all scopes...");
RemoveFromAllScopes(claudePath, projectDir, pathPrepend);
McpLog.Info("MCP server successfully unregistered from Claude Code.");
client.SetStatus(McpStatus.NotConfigured);
@ -813,9 +989,11 @@ namespace MCPForUnity.Editor.Clients
headerArg = !string.IsNullOrEmpty(apiKey) ? $" --header \"{AuthConstants.ApiKeyHeader}: {SanitizeShellHeaderValue(apiKey)}\"" : "";
}
return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --transport http UnityMCP {httpUrl}{headerArg}\n\n" +
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
$"claude mcp add --scope local --transport http UnityMCP {httpUrl}{headerArg}\n\n" +
"# Unregister the MCP server (from all scopes to clean up any stale configs):\n" +
"claude mcp remove --scope local UnityMCP\n" +
"claude mcp remove --scope user UnityMCP\n" +
"claude mcp remove --scope project UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list";
}
@ -825,15 +1003,17 @@ namespace MCPForUnity.Editor.Clients
return "# Error: Configuration not available - check paths in Advanced Settings";
}
string packageSource = AssetPathUtility.GetMcpServerPackageSource();
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{packageSource}\" mcp-for-unity\n\n" +
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
$"claude mcp add --scope local --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}{fromArgs} mcp-for-unity\n\n" +
"# Unregister the MCP server (from all scopes to clean up any stale configs):\n" +
"claude mcp remove --scope local UnityMCP\n" +
"claude mcp remove --scope user UnityMCP\n" +
"claude mcp remove --scope project UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list";
}
@ -841,10 +1021,32 @@ namespace MCPForUnity.Editor.Clients
public override IList<string> GetInstallationSteps() => new List<string>
{
"Ensure Claude CLI is installed",
"Use Register to add UnityMCP (or run claude mcp add UnityMCP)",
"Use Configure to add UnityMCP (or run claude mcp add UnityMCP)",
"Restart Claude Code"
};
/// <summary>
/// Removes UnityMCP registration from all Claude Code configuration scopes (local, user, project).
/// This ensures no stale or conflicting configurations remain across different scopes.
/// Also handles legacy "unityMCP" naming convention.
/// </summary>
private static void RemoveFromAllScopes(string claudePath, string projectDir, string pathPrepend)
{
// Remove from all three scopes to prevent stale configs causing connection issues.
// See GitHub issue #664 - conflicting configs at different scopes can cause
// Claude Code to connect with outdated/incorrect configuration.
string[] scopes = { "local", "user", "project" };
string[] names = { "UnityMCP", "unityMCP" }; // Include legacy naming
foreach (var scope in scopes)
{
foreach (var name in names)
{
ExecPath.TryRun(claudePath, $"mcp remove --scope {scope} {name}", projectDir, out _, out _, 5000, pathPrepend);
}
}
}
/// <summary>
/// Sanitizes a value for safe inclusion inside a double-quoted shell argument.
/// Escapes characters that are special within double quotes (", \, `, $, !)
@ -921,5 +1123,134 @@ namespace MCPForUnity.Editor.Clients
return null;
}
/// <summary>
/// Reads Claude Code configuration directly from ~/.claude.json file.
/// This is much faster than running `claude mcp list` which does health checks on all servers.
/// </summary>
private static (JObject serverConfig, string error) ReadClaudeCodeConfig(string projectDir)
{
try
{
// Find the Claude config file
string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configPath = Path.Combine(homeDir, ".claude.json");
if (!File.Exists(configPath))
{
// Missing config file is "not configured", not an error
// (Claude Code may not be installed or just hasn't been configured yet)
return (null, null);
}
string configJson = File.ReadAllText(configPath);
var config = JObject.Parse(configJson);
var projects = config["projects"] as JObject;
if (projects == null)
{
return (null, null); // No projects configured
}
// Build a dictionary of normalized paths for quick lookup
// Use last entry for duplicates (forward/backslash variants) as it's typically more recent
var normalizedProjects = new Dictionary<string, JObject>(StringComparer.OrdinalIgnoreCase);
foreach (var project in projects.Properties())
{
string normalizedPath = NormalizePath(project.Name);
normalizedProjects[normalizedPath] = project.Value as JObject;
}
// Walk up the directory tree to find a matching project config
// Claude Code may be configured at a parent directory (e.g., repo root)
// while Unity project is in a subdirectory (e.g., TestProjects/UnityMCPTests)
string currentDir = NormalizePath(projectDir);
while (!string.IsNullOrEmpty(currentDir))
{
if (normalizedProjects.TryGetValue(currentDir, out var projectConfig))
{
var mcpServers = projectConfig?["mcpServers"] as JObject;
if (mcpServers != null)
{
// Look for UnityMCP (case-insensitive)
foreach (var server in mcpServers.Properties())
{
if (string.Equals(server.Name, "UnityMCP", StringComparison.OrdinalIgnoreCase))
{
return (server.Value as JObject, null);
}
}
}
// Found the project but no UnityMCP - don't continue walking up
return (null, null);
}
// Move up one directory
int lastSlash = currentDir.LastIndexOf('/');
if (lastSlash <= 0)
break;
currentDir = currentDir.Substring(0, lastSlash);
}
return (null, null); // Project not found in config
}
catch (Exception ex)
{
return (null, $"Error reading Claude config: {ex.Message}");
}
}
/// <summary>
/// Normalizes a file path for comparison (handles forward/back slashes, trailing slashes).
/// </summary>
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
// Replace backslashes with forward slashes and remove trailing slashes
return path.Replace('\\', '/').TrimEnd('/');
}
/// <summary>
/// Extracts the package source from Claude Code JSON config.
/// For stdio servers, this is in the args array after "--from".
/// </summary>
private static string ExtractPackageSourceFromConfig(JObject serverConfig)
{
if (serverConfig == null)
return null;
var args = serverConfig["args"] as JArray;
if (args == null)
return null;
// Look for --from argument (either "--from VALUE" or "--from=VALUE" format)
bool foundFrom = false;
foreach (var arg in args)
{
string argStr = arg?.ToString();
if (argStr == null)
continue;
if (foundFrom)
{
// This is the package source following --from
return argStr;
}
if (argStr == "--from")
{
foundFrom = true;
}
else if (argStr.StartsWith("--from=", StringComparison.OrdinalIgnoreCase))
{
// Handle --from=VALUE format
return argStr.Substring(7).Trim('"', '\'');
}
}
return null;
}
}
}

View File

@ -46,6 +46,7 @@ namespace MCPForUnity.Editor.Constants
internal const string ResourceEnabledPrefix = "MCPForUnity.ResourceEnabled.";
internal const string ResourceFoldoutStatePrefix = "MCPForUnity.ResourceFoldout.";
internal const string EditorWindowActivePanel = "MCPForUnity.EditorWindow.ActivePanel";
internal const string LastSelectedClientId = "MCPForUnity.LastSelectedClientId";
internal const string SetupCompleted = "MCPForUnity.SetupCompleted";
internal const string SetupDismissed = "MCPForUnity.SetupDismissed";

View File

@ -248,21 +248,38 @@ namespace MCPForUnity.Editor.Helpers
/// Handles beta server mode (prerelease from PyPI) vs standard mode (pinned version or override).
/// Centralizes the prerelease logic to avoid duplication between HTTP and stdio transports.
/// Priority: explicit fromUrl override > beta server mode > default package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <param name="quoteFromPath">Whether to quote the --from path (needed for command-line strings, not for arg lists)</param>
/// <returns>The package source arguments (e.g., "--prerelease explicit --from mcpforunityserver>=0.0.0a0")</returns>
public static string GetBetaServerFromArgs(bool quoteFromPath = false)
{
// Read values from cache/EditorPrefs on main thread
bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer;
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgs(useBetaServer, gitUrlOverride, packageSource, quoteFromPath);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="useBetaServer">Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer</param>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
/// <param name="quoteFromPath">Whether to quote the --from path</param>
public static string GetBetaServerFromArgs(bool useBetaServer, string gitUrlOverride, string packageSource, bool quoteFromPath = false)
{
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
if (!string.IsNullOrEmpty(gitUrlOverride))
{
return $"--from {fromUrl}";
string fromValue = quoteFromPath ? $"\"{gitUrlOverride}\"" : gitUrlOverride;
return $"--from {fromValue}";
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
// Use --prerelease explicit with version specifier to only get prereleases of our package,
@ -272,9 +289,10 @@ namespace MCPForUnity.Editor.Helpers
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
if (!string.IsNullOrEmpty(packageSource))
{
return $"--from {fromUrl}";
string fromValue = quoteFromPath ? $"\"{packageSource}\"" : packageSource;
return $"--from {fromValue}";
}
return string.Empty;
@ -283,24 +301,39 @@ namespace MCPForUnity.Editor.Helpers
/// <summary>
/// Builds the uvx package source arguments as a list (for JSON config builders).
/// Priority: explicit fromUrl override > beta server mode > default package.
/// NOTE: This overload reads from EditorPrefs/cache and MUST be called from the main thread.
/// For background threads, use the overload that accepts pre-captured parameters.
/// </summary>
/// <returns>List of arguments to add to uvx command</returns>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList()
{
// Read values from cache/EditorPrefs on main thread
bool useBetaServer = Services.EditorConfigurationCache.Instance.UseBetaServer;
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
string packageSource = GetMcpServerPackageSource();
return GetBetaServerFromArgsList(useBetaServer, gitUrlOverride, packageSource);
}
/// <summary>
/// Thread-safe overload that accepts pre-captured values.
/// Use this when calling from background threads.
/// </summary>
/// <param name="useBetaServer">Pre-captured value from EditorConfigurationCache.Instance.UseBetaServer</param>
/// <param name="gitUrlOverride">Pre-captured value from EditorPrefs GitUrlOverride</param>
/// <param name="packageSource">Pre-captured value from GetMcpServerPackageSource()</param>
public static System.Collections.Generic.IList<string> GetBetaServerFromArgsList(bool useBetaServer, string gitUrlOverride, string packageSource)
{
var args = new System.Collections.Generic.List<string>();
// Explicit override (local path, git URL, etc.) always wins
string fromUrl = GetMcpServerPackageSource();
string overrideUrl = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(overrideUrl))
if (!string.IsNullOrEmpty(gitUrlOverride))
{
args.Add("--from");
args.Add(fromUrl);
args.Add(gitUrlOverride);
return args;
}
// Beta server mode: use prerelease from PyPI
bool useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
if (useBetaServer)
{
args.Add("--prerelease");
@ -311,10 +344,10 @@ namespace MCPForUnity.Editor.Helpers
}
// Standard mode: use pinned version from package.json
if (!string.IsNullOrEmpty(fromUrl))
if (!string.IsNullOrEmpty(packageSource))
{
args.Add("--from");
args.Add(fromUrl);
args.Add(packageSource);
}
return args;
@ -426,5 +459,31 @@ namespace MCPForUnity.Editor.Helpers
return "unknown";
}
}
/// <summary>
/// Returns true if the installed package version is a prerelease (beta, alpha, rc, etc.).
/// Used to auto-enable beta server mode for beta package users.
/// </summary>
public static bool IsPreReleaseVersion()
{
try
{
string version = GetPackageVersion();
if (string.IsNullOrEmpty(version) || version == "unknown")
return false;
// Check for common prerelease indicators in semver format
// e.g., "9.3.0-beta.1", "9.3.0-alpha", "9.3.0-rc.2", "9.3.0-preview"
return version.Contains("-beta", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-alpha", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-rc", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-preview", StringComparison.OrdinalIgnoreCase) ||
version.Contains("-pre", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
}
}

View File

@ -35,6 +35,7 @@ namespace MCPForUnity.Editor.Models
McpStatus.UnsupportedOS => "Unsupported OS",
McpStatus.MissingConfig => "Missing MCPForUnity Config",
McpStatus.Error => configStatus?.StartsWith("Error:") == true ? configStatus : "Error",
McpStatus.VersionMismatch => "Version Mismatch",
_ => "Unknown",
};
}
@ -44,9 +45,9 @@ namespace MCPForUnity.Editor.Models
{
status = newStatus;
if (newStatus == McpStatus.Error && !string.IsNullOrEmpty(errorDetails))
if ((newStatus == McpStatus.Error || newStatus == McpStatus.VersionMismatch) && !string.IsNullOrEmpty(errorDetails))
{
configStatus = $"Error: {errorDetails}";
configStatus = errorDetails;
}
else
{

View File

@ -13,6 +13,7 @@ namespace MCPForUnity.Editor.Models
MissingConfig, // Config file exists but missing required elements
UnsupportedOS, // OS is not supported
Error, // General error state
VersionMismatch, // Configuration version doesn't match expected version
}
/// <summary>

View File

@ -124,6 +124,23 @@ namespace MCPForUnity.Editor.Services
/// </summary>
public int UnitySocketPort => _unitySocketPort;
/// <summary>
/// Gets UseBetaServer value with dynamic default based on package version.
/// If the pref hasn't been explicitly set, defaults to true for prerelease packages
/// (beta, alpha, rc, etc.) and false for stable releases.
/// </summary>
private static bool GetUseBetaServerWithDynamicDefault()
{
// If user has explicitly set the pref, use that value
if (EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer))
{
return EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, false);
}
// Otherwise, default based on whether this is a prerelease package
return Helpers.AssetPathUtility.IsPreReleaseVersion();
}
private EditorConfigurationCache()
{
Refresh();
@ -137,7 +154,7 @@ namespace MCPForUnity.Editor.Services
{
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
_useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
_useBetaServer = GetUseBetaServerWithDynamicDefault();
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
@ -312,7 +329,7 @@ namespace MCPForUnity.Editor.Services
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
break;
case nameof(UseBetaServer):
_useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
_useBetaServer = GetUseBetaServerWithDynamicDefault();
break;
case nameof(DevModeForceServerRefresh):
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -21,13 +20,6 @@ using UnityEngine;
namespace MCPForUnity.Editor.Services.Transport.Transports
{
class Outbound
{
public byte[] Payload;
public string Tag;
public int? ReqId;
}
class QueuedCommand
{
public string CommandJson;
@ -44,7 +36,6 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
private static readonly object startStopLock = new();
private static readonly object clientsLock = new();
private static readonly HashSet<TcpClient> activeClients = new();
private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
private static CancellationTokenSource cts;
private static Task listenerTask;
private static int processingCommands = 0;
@ -61,7 +52,6 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
private const ulong MaxFrameBytes = 64UL * 1024 * 1024;
private const int FrameIOTimeoutMs = 30000;
private static long _ioSeq = 0;
private static void IoInfo(string s) { McpLog.Info(s, always: false); }
private static bool IsDebugEnabled()
@ -123,30 +113,6 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
static StdioBridgeHost()
{
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
try
{
var writerThread = new Thread(() =>
{
foreach (var item in _outbox.GetConsumingEnumerable())
{
try
{
long seq = Interlocked.Increment(ref _ioSeq);
IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
var sw = System.Diagnostics.Stopwatch.StartNew();
sw.Stop();
IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
}
}
})
{ IsBackground = true, Name = "MCP-Writer" };
writerThread.Start();
}
catch { }
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{
@ -633,12 +599,10 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
{
try { McpLog.Info("[MCP] sending framed response", always: false); } catch { }
}
long seq = Interlocked.Increment(ref _ioSeq);
byte[] responseBytes;
try
{
responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
}
catch (Exception ex)
{
@ -646,12 +610,9 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
throw;
}
var swDirect = System.Diagnostics.Stopwatch.StartNew();
try
{
await WriteFrameAsync(stream, responseBytes);
swDirect.Stop();
IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{

View File

@ -138,7 +138,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
McpLog.SetDebugLoggingEnabled(debugEnabled);
devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
useBetaServerToggle.value = EditorConfigurationCache.Instance.UseBetaServer;
UpdatePathOverrides();
UpdateDeploymentSection();
}
@ -185,7 +185,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
useBetaServerToggle.RegisterValueChangedCallback(evt =>
{
EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, evt.newValue);
EditorConfigurationCache.Instance.SetUseBetaServer(evt.newValue);
OnHttpServerCommandUpdateRequested?.Invoke();
OnBetaModeChanged?.Invoke(evt.newValue);
});
@ -292,7 +292,7 @@ namespace MCPForUnity.Editor.Windows.Components.Advanced
gitUrlOverride.value = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
debugLogsToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
devModeForceRefreshToggle.value = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
useBetaServerToggle.value = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
useBetaServerToggle.value = EditorConfigurationCache.Instance.UseBetaServer;
UpdateDeploymentSection();
}

View File

@ -53,6 +53,12 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
/// </summary>
public event Action<string, ConfiguredTransport> OnClientTransportDetected;
/// <summary>
/// Fired when a config mismatch is detected (e.g., version mismatch).
/// The parameter contains the client name and the mismatch message (null if no mismatch).
/// </summary>
public event Action<string, string> OnClientConfigMismatch;
public VisualElement Root { get; private set; }
public McpClientConfigSection(VisualElement root)
@ -95,7 +101,22 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
clientDropdown.choices = clientNames;
if (clientNames.Count > 0)
{
clientDropdown.index = 0;
// Restore last selected client from EditorPrefs
string lastClientId = EditorPrefs.GetString(EditorPrefKeys.LastSelectedClientId, string.Empty);
int restoredIndex = 0;
if (!string.IsNullOrEmpty(lastClientId))
{
for (int i = 0; i < configurators.Count; i++)
{
if (string.Equals(configurators[i].Id, lastClientId, StringComparison.OrdinalIgnoreCase))
{
restoredIndex = i;
break;
}
}
}
clientDropdown.index = restoredIndex;
selectedClientIndex = restoredIndex;
}
claudeCliPathRow.style.display = DisplayStyle.None;
@ -111,6 +132,11 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
clientDropdown.RegisterValueChangedCallback(evt =>
{
selectedClientIndex = clientDropdown.index;
// Persist the selected client so it's restored on next window open
if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count)
{
EditorPrefs.SetString(EditorPrefKeys.LastSelectedClientId, configurators[selectedClientIndex].Id);
}
UpdateClientStatus();
UpdateManualConfiguration();
UpdateClaudeCliPathVisibility();
@ -147,6 +173,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
McpStatus.UnsupportedOS => "Unsupported OS",
McpStatus.MissingConfig => "Missing MCPForUnity Config",
McpStatus.Error => "Error",
McpStatus.VersionMismatch => "Version Mismatch",
_ => "Unknown",
};
}
@ -266,14 +293,15 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
statusRefreshInFlight.Add(client);
bool isCurrentlyConfigured = client.Status == McpStatus.Configured;
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Registering...");
ApplyStatusToUi(client, showChecking: true, customMessage: isCurrentlyConfigured ? "Unregistering..." : "Configuring...");
// Capture ALL main-thread-only values before async task
string projectDir = Path.GetDirectoryName(Application.dataPath);
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
string httpUrl = HttpEndpointUtility.GetMcpRpcUrl();
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
var (uvxPath, _, packageName) = AssetPathUtility.GetUvxCommandParts();
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
bool shouldForceRefresh = AssetPathUtility.ShouldForceUvxRefresh();
string apiKey = EditorPrefs.GetString(EditorPrefKeys.ApiKey, string.Empty);
@ -301,7 +329,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
cliConfigurator.ConfigureWithCapturedValues(
projectDir, claudePath, pathPrepend,
useHttpTransport, httpUrl,
uvxPath, gitUrl, packageName, shouldForceRefresh,
uvxPath, fromArgs, packageName, shouldForceRefresh,
apiKey, serverTransport);
}
return (success: true, error: (string)null);
@ -458,6 +486,10 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
string projectDir = Path.GetDirectoryName(Application.dataPath);
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
string claudePath = MCPServiceLocator.Paths.GetClaudeCliPath();
RuntimePlatform platform = Application.platform;
bool isRemoteScope = HttpEndpointUtility.IsRemoteScope();
// Get expected package source considering beta mode (bypass cache for fresh read)
string expectedPackageSource = GetExpectedPackageSourceForBetaMode();
Task.Run(() =>
{
@ -466,7 +498,7 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
if (client is ClaudeCliMcpConfigurator claudeConfigurator)
{
// Use thread-safe version with captured main-thread values
claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, attemptAutoRewrite: false);
claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, claudePath, platform, isRemoteScope, expectedPackageSource, attemptAutoRewrite: false);
}
}).ContinueWith(t =>
{
@ -556,6 +588,8 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
case McpStatus.IncorrectPath:
case McpStatus.CommunicationError:
case McpStatus.NoResponse:
case McpStatus.Error:
case McpStatus.VersionMismatch:
clientStatusIndicator.AddToClassList("warning");
break;
default:
@ -569,6 +603,55 @@ namespace MCPForUnity.Editor.Windows.Components.ClientConfig
// Notify listeners about the client's configured transport
OnClientTransportDetected?.Invoke(client.DisplayName, client.ConfiguredTransport);
// Notify listeners about version mismatch if applicable
if (client.Status == McpStatus.VersionMismatch && client is McpClientConfiguratorBase baseConfigurator)
{
// Get the mismatch reason from the configStatus field
string mismatchReason = baseConfigurator.Client.configStatus;
OnClientConfigMismatch?.Invoke(client.DisplayName, mismatchReason);
}
else
{
// Clear any previous mismatch warning
OnClientConfigMismatch?.Invoke(client.DisplayName, null);
}
}
/// <summary>
/// Gets the expected package source for validation, accounting for beta mode.
/// Uses the same logic as registration to ensure validation matches what was registered.
/// MUST be called from the main thread due to EditorPrefs access.
/// </summary>
private static string GetExpectedPackageSourceForBetaMode()
{
// Check for explicit override first
string gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, "");
if (!string.IsNullOrEmpty(gitUrlOverride))
{
return gitUrlOverride;
}
// Check beta mode using the same logic as GetUseBetaServerWithDynamicDefault
// (bypass cache to ensure fresh read)
bool useBetaServer;
if (EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer))
{
useBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, false);
}
else
{
// Dynamic default based on package version
useBetaServer = AssetPathUtility.IsPreReleaseVersion();
}
if (useBetaServer)
{
return "mcpforunityserver>=0.0.0a0";
}
// Standard mode uses exact version from package.json
return AssetPathUtility.GetMcpServerPackageSource();
}
}
}

View File

@ -30,6 +30,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
private EnumField transportDropdown;
private VisualElement transportMismatchWarning;
private Label transportMismatchText;
private VisualElement versionMismatchWarning;
private Label versionMismatchText;
private VisualElement httpUrlRow;
private VisualElement httpServerControlRow;
private Foldout manualCommandFoldout;
@ -86,6 +88,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
transportDropdown = Root.Q<EnumField>("transport-dropdown");
transportMismatchWarning = Root.Q<VisualElement>("transport-mismatch-warning");
transportMismatchText = Root.Q<Label>("transport-mismatch-text");
versionMismatchWarning = Root.Q<VisualElement>("version-mismatch-warning");
versionMismatchText = Root.Q<Label>("version-mismatch-text");
httpUrlRow = Root.Q<VisualElement>("http-url-row");
httpServerControlRow = Root.Q<VisualElement>("http-server-control-row");
manualCommandFoldout = Root.Q<Foldout>("manual-command-foldout");
@ -1023,6 +1027,35 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
transportMismatchWarning?.RemoveFromClassList("visible");
}
/// <summary>
/// Updates the version mismatch warning banner based on the client's configuration status.
/// Shows a warning if the client is registered with a different package version than expected.
/// </summary>
/// <param name="clientName">The display name of the client being checked.</param>
/// <param name="mismatchMessage">The mismatch message, or null if no mismatch.</param>
public void UpdateVersionMismatchWarning(string clientName, string mismatchMessage)
{
if (versionMismatchWarning == null || versionMismatchText == null)
return;
if (string.IsNullOrEmpty(mismatchMessage))
{
versionMismatchWarning.RemoveFromClassList("visible");
return;
}
versionMismatchText.text = $"⚠ {clientName}: {mismatchMessage}";
versionMismatchWarning.AddToClassList("visible");
}
/// <summary>
/// Clears the version mismatch warning banner.
/// </summary>
public void ClearVersionMismatchWarning()
{
versionMismatchWarning?.RemoveFromClassList("visible");
}
private static string TransportDisplayName(ConfiguredTransport transport)
{
return transport switch

View File

@ -10,6 +10,9 @@
<ui:VisualElement name="transport-mismatch-warning" class="warning-banner">
<ui:Label name="transport-mismatch-text" class="warning-banner-text" />
</ui:VisualElement>
<ui:VisualElement name="version-mismatch-warning" class="warning-banner">
<ui:Label name="version-mismatch-text" class="warning-banner-text" />
</ui:VisualElement>
<ui:VisualElement class="setting-row" name="http-url-row">
<ui:Label text="HTTP URL:" class="setting-label" />
<ui:TextField name="http-url" class="url-field" />

View File

@ -92,6 +92,9 @@ namespace MCPForUnity.Editor.Windows
public void CreateGUI()
{
// Clear search filter on GUI recreation to avoid stale filtered results
searchFilter = "";
string basePath = AssetPathUtility.GetMcpPackageRootPath();
// Load UXML
@ -245,24 +248,27 @@ namespace MCPForUnity.Editor.Windows
// Check if we know the type of this pref
if (knownPrefTypes.TryGetValue(key, out var knownType))
{
// Check if the key actually exists
item.IsUnset = !EditorPrefs.HasKey(key);
// Use the known type
switch (knownType)
{
case EditorPrefType.Bool:
item.Type = EditorPrefType.Bool;
item.Value = EditorPrefs.GetBool(key, false).ToString();
item.Value = item.IsUnset ? "Unset. Default: False" : EditorPrefs.GetBool(key, false).ToString();
break;
case EditorPrefType.Int:
item.Type = EditorPrefType.Int;
item.Value = EditorPrefs.GetInt(key, 0).ToString();
item.Value = item.IsUnset ? "Unset. Default: 0" : EditorPrefs.GetInt(key, 0).ToString();
break;
case EditorPrefType.Float:
item.Type = EditorPrefType.Float;
item.Value = EditorPrefs.GetFloat(key, 0f).ToString();
item.Value = item.IsUnset ? "Unset. Default: 0" : EditorPrefs.GetFloat(key, 0f).ToString();
break;
case EditorPrefType.String:
item.Type = EditorPrefType.String;
item.Value = EditorPrefs.GetString(key, "");
item.Value = item.IsUnset ? "Unset. Default: (empty)" : EditorPrefs.GetString(key, "");
break;
}
}
@ -324,6 +330,14 @@ namespace MCPForUnity.Editor.Windows
// Buttons
var saveButton = itemElement.Q<Button>("save-button");
// Style unset items
if (item.IsUnset)
{
valueField.SetEnabled(false);
valueField.style.opacity = 0.6f;
saveButton.SetEnabled(false);
}
// Callbacks
saveButton.clicked += () => SavePref(item, valueField.value, (EditorPrefType)typeDropdown.index);
@ -389,6 +403,7 @@ namespace MCPForUnity.Editor.Windows
public string Value { get; set; }
public EditorPrefType Type { get; set; }
public bool IsKnown { get; set; }
public bool IsUnset { get; set; }
}
/// <summary>

View File

@ -196,7 +196,7 @@ namespace MCPForUnity.Editor.Windows
}
// Initialize version label
UpdateVersionLabel(EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true));
UpdateVersionLabel(EditorConfigurationCache.Instance.UseBetaServer);
SetupTabs();
@ -229,6 +229,11 @@ namespace MCPForUnity.Editor.Windows
// update the connection section's warning banner if there's a mismatch
clientConfigSection.OnClientTransportDetected += (clientName, transport) =>
connectionSection?.UpdateTransportMismatchWarning(clientName, transport);
// Wire up version mismatch detection: when client status is checked,
// update the connection section's warning banner if there's a version mismatch
clientConfigSection.OnClientConfigMismatch += (clientName, mismatchMessage) =>
connectionSection?.UpdateVersionMismatchWarning(clientName, mismatchMessage);
}
// Load and initialize Validation section
@ -263,6 +268,7 @@ namespace MCPForUnity.Editor.Windows
await connectionSection.VerifyBridgeConnectionAsync();
};
advancedSection.OnBetaModeChanged += UpdateVersionLabel;
advancedSection.OnBetaModeChanged += _ => clientConfigSection?.RefreshSelectedClient(forceImmediate: true);
// Wire up health status updates from Connection to Advanced
connectionSection?.SetHealthStatusUpdateCallback((isHealthy, statusText) =>
@ -327,7 +333,7 @@ namespace MCPForUnity.Editor.Windows
}
string version = AssetPathUtility.GetPackageVersion();
versionLabel.text = useBetaServer ? $"v{version} β" : $"v{version}";
versionLabel.text = $"v{version}";
versionLabel.tooltip = useBetaServer
? "Beta server mode - fetching pre-release server versions from PyPI"
: $"MCP For Unity v{version}";
@ -552,6 +558,8 @@ namespace MCPForUnity.Editor.Windows
{
case ActivePanel.Clients:
if (clientsPanel != null) clientsPanel.style.display = DisplayStyle.Flex;
// Refresh client status when switching to Connect tab (e.g., after changing beta mode in Advanced)
clientConfigSection?.RefreshSelectedClient(forceImmediate: true);
break;
case ActivePanel.Validation:
if (validationPanel != null) validationPanel.style.display = DisplayStyle.Flex;

View File

@ -1,6 +1,6 @@
{
"name": "com.coplaydev.unity-mcp",
"version": "9.4.0",
"version": "9.4.1-beta.1",
"displayName": "MCP for Unity",
"description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
"unity": "2021.3",

View File

@ -0,0 +1 @@
# MCP for Unity Server

View File

@ -256,9 +256,10 @@ class TestUnityInstanceMiddlewareInjection:
async def mock_call_next(_ctx):
return {"status": "ok"}
# Mock PluginHub as unavailable - this is sufficient for auto-select to fail
# Mock PluginHub as unavailable AND legacy connection pool to prevent fallback discovery
with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=False):
await middleware.on_call_tool(middleware_ctx, mock_call_next)
with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None):
await middleware.on_call_tool(middleware_ctx, mock_call_next)
# set_state should not be called for unity_instance if no instance found
calls = [c for c in mock_context.set_state.call_args_list
@ -329,9 +330,10 @@ class TestAutoSelectInstance:
with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True):
with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get:
mock_get.return_value = fake_sessions
with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None):
mock_get.return_value = fake_sessions
instance = await middleware._maybe_autoselect_instance(mock_context)
instance = await middleware._maybe_autoselect_instance(mock_context)
assert instance is None
@ -345,10 +347,11 @@ class TestAutoSelectInstance:
with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True):
with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = ConnectionError("Plugin hub unavailable")
with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None):
mock_get.side_effect = ConnectionError("Plugin hub unavailable")
# When PluginHub fails, auto-select returns None (graceful fallback)
instance = await middleware._maybe_autoselect_instance(mock_context)
# When PluginHub fails, auto-select returns None (graceful fallback)
instance = await middleware._maybe_autoselect_instance(mock_context)
# Should return None since both PluginHub failed
assert instance is None
@ -916,10 +919,11 @@ class TestTransportEdgeCases:
with patch("transport.unity_instance_middleware.PluginHub.is_configured", return_value=True):
with patch("transport.unity_instance_middleware.PluginHub.get_sessions", new_callable=AsyncMock) as mock_get:
mock_get.side_effect = RuntimeError("Unexpected error")
with patch("transport.legacy.unity_connection.get_unity_connection_pool", return_value=None):
mock_get.side_effect = RuntimeError("Unexpected error")
# Should not raise, just return None
instance = await middleware._maybe_autoselect_instance(mock_context)
# Should not raise, just return None
instance = await middleware._maybe_autoselect_instance(mock_context)
assert instance is None

View File

@ -34,6 +34,8 @@ namespace MCPForUnityTests.Editor.Helpers
private bool _originalHttpTransport;
private bool _hadDevForceRefresh;
private bool _originalDevForceRefresh;
private bool _hadUseBetaServer;
private bool _originalUseBetaServer;
private IPlatformService _originalPlatformService;
[OneTimeSetUp]
@ -45,6 +47,8 @@ namespace MCPForUnityTests.Editor.Helpers
_originalHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_hadDevForceRefresh = EditorPrefs.HasKey(EditorPrefKeys.DevModeForceServerRefresh);
_originalDevForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_hadUseBetaServer = EditorPrefs.HasKey(EditorPrefKeys.UseBetaServer);
_originalUseBetaServer = EditorPrefs.GetBool(EditorPrefKeys.UseBetaServer, true);
_originalPlatformService = MCPServiceLocator.Platform;
}
@ -58,6 +62,10 @@ namespace MCPForUnityTests.Editor.Helpers
// Ensure deterministic uvx args ordering for these tests regardless of editor settings
// (dev-mode inserts --no-cache/--refresh, which changes the first args).
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
// Tests expect beta server mode (--prerelease explicit --from mcpforunityserver>=0.0.0a0)
EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, true);
// Refresh the cache so it picks up the test's pref values
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
@ -108,6 +116,15 @@ namespace MCPForUnityTests.Editor.Helpers
{
EditorPrefs.DeleteKey(EditorPrefKeys.DevModeForceServerRefresh);
}
if (_hadUseBetaServer)
{
EditorPrefs.SetBool(EditorPrefKeys.UseBetaServer, _originalUseBetaServer);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.UseBetaServer);
}
}
[Test]

214
unity-mcp-skill/SKILL.md Normal file
View File

@ -0,0 +1,214 @@
---
name: unity-mcp-orchestrator
description: Orchestrate Unity Editor via MCP (Model Context Protocol) tools and resources. Use when working with Unity projects through MCP for Unity - creating/modifying GameObjects, editing scripts, managing scenes, running tests, or any Unity Editor automation. Provides best practices, tool schemas, and workflow patterns for effective Unity-MCP integration.
---
# Unity-MCP Operator Guide
This skill helps you effectively use the Unity Editor with MCP tools and resources.
## Quick Start: Resource-First Workflow
**Always read relevant resources before using tools.** This prevents errors and provides the necessary context.
```
1. Check editor state → mcpforunity://editor/state
2. Understand the scene → mcpforunity://scene/gameobject-api
3. Find what you need → find_gameobjects or resources
4. Take action → tools (manage_gameobject, create_script, script_apply_edits, apply_text_edits, validate_script, delete_script, get_sha, etc.)
5. Verify results → read_console, capture_screenshot (in manage_scene), resources
```
## Critical Best Practices
### 1. After Writing/Editing Scripts: Always Refresh and Check Console
```python
# After create_script or script_apply_edits:
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
read_console(types=["error"], count=10, include_stacktrace=True)
```
**Why:** Unity must compile scripts before they're usable. Compilation errors block all tool execution.
### 2. Use `batch_execute` for Multiple Operations
```python
# 10-100x faster than sequential calls
batch_execute(
commands=[
{"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube1", "primitive_type": "Cube"}},
{"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube2", "primitive_type": "Cube"}},
{"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube3", "primitive_type": "Cube"}}
],
parallel=True # Read-only operations can run in parallel
)
```
**Max 25 commands per batch.** Use `fail_fast=True` for dependent operations.
### 3. Use `screenshot` in manage_scene to Verify Visual Results
```python
# Via manage_scene
manage_scene(action="screenshot") # Returns base64 image
# After creating/modifying objects, verify visually:
# 1. Create objects
# 2. capture screenshot
# 3. Analyze if result matches intent
```
### 4. Check Console After Major Changes
```python
read_console(
action="get",
types=["error", "warning"], # Focus on problems
count=10,
format="detailed"
)
```
### 5. Always Check `editor_state` Before Complex Operations
```python
# Read mcpforunity://editor/state to check:
# - is_compiling: Wait if true
# - is_domain_reload_pending: Wait if true
# - ready_for_tools: Only proceed if true
# - blocking_reasons: Why tools might fail
```
## Parameter Type Conventions
### Vectors (position, rotation, scale, color)
```python
# Both forms accepted:
position=[1.0, 2.0, 3.0] # List
position="[1.0, 2.0, 3.0]" # JSON string
```
### Booleans
```python
# Both forms accepted:
include_inactive=True # Boolean
include_inactive="true" # String
```
### Colors
```python
# Auto-detected format:
color=[255, 0, 0, 255] # 0-255 range
color=[1.0, 0.0, 0.0, 1.0] # 0.0-1.0 normalized (auto-converted)
```
### Paths
```python
# Assets-relative (default):
path="Assets/Scripts/MyScript.cs"
# URI forms:
uri="mcpforunity://path/Assets/Scripts/MyScript.cs"
uri="file:///full/path/to/file.cs"
```
## Core Tool Categories
| Category | Key Tools | Use For |
|----------|-----------|---------|
| **Scene** | `manage_scene`, `find_gameobjects` | Scene operations, finding objects |
| **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects |
| **Scripts** | `create_script`, `script_apply_edits`, `refresh_unity` | C# code management |
| **Assets** | `manage_asset`, `manage_prefabs` | Asset operations |
| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control |
| **Testing** | `run_tests`, `get_test_job` | Unity Test Framework |
| **Batch** | `batch_execute` | Parallel/bulk operations |
## Common Workflows
### Creating a New Script and Using It
```python
# 1. Create the script
create_script(
path="Assets/Scripts/PlayerController.cs",
contents="using UnityEngine;\n\npublic class PlayerController : MonoBehaviour\n{\n void Update() { }\n}"
)
# 2. CRITICAL: Refresh and wait for compilation
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
# 3. Check for compilation errors
read_console(types=["error"], count=10)
# 4. Only then attach to GameObject
manage_gameobject(action="modify", target="Player", components_to_add=["PlayerController"])
```
### Finding and Modifying GameObjects
```python
# 1. Find by name/tag/component (returns IDs only)
result = find_gameobjects(search_term="Enemy", search_method="by_tag", page_size=50)
# 2. Get full data via resource
# mcpforunity://scene/gameobject/{instance_id}
# 3. Modify using the ID
manage_gameobject(action="modify", target=instance_id, position=[10, 0, 0])
```
### Running and Monitoring Tests
```python
# 1. Start test run (async)
result = run_tests(mode="EditMode", test_names=["MyTests.TestSomething"])
job_id = result["job_id"]
# 2. Poll for completion
result = get_test_job(job_id=job_id, wait_timeout=60, include_failed_tests=True)
```
## Pagination Pattern
Large queries return paginated results. Always follow `next_cursor`:
```python
cursor = 0
all_items = []
while True:
result = manage_scene(action="get_hierarchy", page_size=50, cursor=cursor)
all_items.extend(result["data"]["items"])
if not result["data"].get("next_cursor"):
break
cursor = result["data"]["next_cursor"]
```
## Multi-Instance Workflow
When multiple Unity Editors are running:
```python
# 1. List instances via resource: mcpforunity://instances
# 2. Set active instance
set_active_instance(instance="MyProject@abc123")
# 3. All subsequent calls route to that instance
```
## Error Recovery
| Symptom | Cause | Solution |
|---------|-------|----------|
| Tools return "busy" | Compilation in progress | Wait, check `editor_state` |
| "stale_file" error | File changed since SHA | Re-fetch SHA with `get_sha`, retry |
| Connection lost | Domain reload | Wait ~5s, reconnect |
| Commands fail silently | Wrong instance | Check `set_active_instance` |
## Reference Files
For detailed schemas and examples:
- **[tools-reference.md](references/tools-reference.md)**: Complete tool documentation with all parameters
- **[resources-reference.md](references/resources-reference.md)**: All available resources and their data
- **[workflows.md](references/workflows.md)**: Extended workflow examples and patterns

View File

@ -0,0 +1,493 @@
# Unity-MCP Resources Reference
Resources provide read-only access to Unity state. Use resources to inspect before using tools to modify.
## Table of Contents
- [Editor State Resources](#editor-state-resources)
- [Scene & GameObject Resources](#scene--gameobject-resources)
- [Prefab Resources](#prefab-resources)
- [Project Resources](#project-resources)
- [Instance Resources](#instance-resources)
- [Test Resources](#test-resources)
---
## URI Scheme
All resources use `mcpforunity://` scheme:
```
mcpforunity://{category}/{resource_path}[?query_params]
```
**Categories:** `editor`, `scene`, `prefab`, `project`, `menu-items`, `custom-tools`, `tests`, `instances`
---
## Editor State Resources
### mcpforunity://editor/state
**Purpose:** Editor readiness snapshot - check before tool operations.
**Returns:**
```json
{
"unity_version": "2022.3.10f1",
"is_compiling": false,
"is_domain_reload_pending": false,
"play_mode": {
"is_playing": false,
"is_paused": false
},
"active_scene": {
"path": "Assets/Scenes/Main.unity",
"name": "Main"
},
"ready_for_tools": true,
"blocking_reasons": [],
"recommended_retry_after_ms": null,
"staleness": {
"age_ms": 150,
"is_stale": false
}
}
```
**Key Fields:**
- `ready_for_tools`: Only proceed if `true`
- `is_compiling`: Wait if `true`
- `blocking_reasons`: Array explaining why tools might fail
- `recommended_retry_after_ms`: Suggested wait time
### mcpforunity://editor/selection
**Purpose:** Currently selected objects.
**Returns:**
```json
{
"activeObject": "Player",
"activeGameObject": "Player",
"activeInstanceID": 12345,
"count": 3,
"gameObjects": ["Player", "Enemy", "Wall"],
"assetGUIDs": []
}
```
### mcpforunity://editor/active-tool
**Purpose:** Current editor tool state.
**Returns:**
```json
{
"activeTool": "Move",
"isCustom": false,
"pivotMode": "Center",
"pivotRotation": "Global"
}
```
### mcpforunity://editor/windows
**Purpose:** All open editor windows.
**Returns:**
```json
{
"windows": [
{
"title": "Scene",
"typeName": "UnityEditor.SceneView",
"isFocused": true,
"position": {"x": 0, "y": 0, "width": 800, "height": 600}
}
]
}
```
### mcpforunity://editor/prefab-stage
**Purpose:** Current prefab editing context.
**Returns:**
```json
{
"isOpen": true,
"assetPath": "Assets/Prefabs/Player.prefab",
"prefabRootName": "Player",
"isDirty": false
}
```
---
## Scene & GameObject Resources
### mcpforunity://scene/gameobject-api
**Purpose:** Documentation for GameObject resources (read this first).
### mcpforunity://scene/gameobject/{instance_id}
**Purpose:** Basic GameObject data (metadata, no component properties).
**Parameters:**
- `instance_id` (int): GameObject instance ID from `find_gameobjects`
**Returns:**
```json
{
"instanceID": 12345,
"name": "Player",
"tag": "Player",
"layer": 8,
"layerName": "Player",
"active": true,
"activeInHierarchy": true,
"isStatic": false,
"transform": {
"position": [0, 1, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
},
"parent": {"instanceID": 0},
"children": [{"instanceID": 67890}],
"componentTypes": ["Transform", "Rigidbody", "PlayerController"],
"path": "/Player"
}
```
### mcpforunity://scene/gameobject/{instance_id}/components
**Purpose:** All components with full property serialization (paginated).
**Parameters:**
- `instance_id` (int): GameObject instance ID
- `page_size` (int): Default 25, max 100
- `cursor` (int): Pagination cursor
- `include_properties` (bool): Default true, set false for just types
**Returns:**
```json
{
"gameObjectID": 12345,
"gameObjectName": "Player",
"components": [
{
"type": "Transform",
"properties": {
"position": {"x": 0, "y": 1, "z": 0},
"rotation": {"x": 0, "y": 0, "z": 0, "w": 1}
}
},
{
"type": "Rigidbody",
"properties": {
"mass": 1.0,
"useGravity": true
}
}
],
"cursor": 0,
"pageSize": 25,
"nextCursor": null,
"hasMore": false
}
```
### mcpforunity://scene/gameobject/{instance_id}/component/{component_name}
**Purpose:** Single component with full properties.
**Parameters:**
- `instance_id` (int): GameObject instance ID
- `component_name` (string): e.g., "Rigidbody", "Camera", "Transform"
**Returns:**
```json
{
"gameObjectID": 12345,
"gameObjectName": "Player",
"component": {
"type": "Rigidbody",
"properties": {
"mass": 1.0,
"drag": 0,
"angularDrag": 0.05,
"useGravity": true,
"isKinematic": false
}
}
}
```
---
## Prefab Resources
### mcpforunity://prefab-api
**Purpose:** Documentation for prefab resources.
### mcpforunity://prefab/{encoded_path}
**Purpose:** Prefab asset information.
**Parameters:**
- `encoded_path` (string): URL-encoded path, e.g., `Assets%2FPrefabs%2FPlayer.prefab`
**Path Encoding:**
```
Assets/Prefabs/Player.prefab → Assets%2FPrefabs%2FPlayer.prefab
```
**Returns:**
```json
{
"assetPath": "Assets/Prefabs/Player.prefab",
"guid": "abc123...",
"prefabType": "Regular",
"rootObjectName": "Player",
"rootComponentTypes": ["Transform", "PlayerController"],
"childCount": 5,
"isVariant": false,
"parentPrefab": null
}
```
### mcpforunity://prefab/{encoded_path}/hierarchy
**Purpose:** Full prefab hierarchy with nested prefab info.
**Returns:**
```json
{
"prefabPath": "Assets/Prefabs/Player.prefab",
"total": 6,
"items": [
{
"name": "Player",
"instanceId": 12345,
"path": "/Player",
"activeSelf": true,
"childCount": 2,
"componentTypes": ["Transform", "PlayerController"]
},
{
"name": "Model",
"path": "/Player/Model",
"isNestedPrefab": true,
"nestedPrefabPath": "Assets/Prefabs/PlayerModel.prefab"
}
]
}
```
---
## Project Resources
### mcpforunity://project/info
**Purpose:** Static project configuration.
**Returns:**
```json
{
"projectRoot": "/Users/dev/MyProject",
"projectName": "MyProject",
"unityVersion": "2022.3.10f1",
"platform": "StandaloneWindows64",
"assetsPath": "/Users/dev/MyProject/Assets"
}
```
### mcpforunity://project/tags
**Purpose:** All tags defined in TagManager.
**Returns:**
```json
["Untagged", "Respawn", "Finish", "EditorOnly", "MainCamera", "Player", "GameController", "Enemy"]
```
### mcpforunity://project/layers
**Purpose:** All layers with indices (0-31).
**Returns:**
```json
{
"0": "Default",
"1": "TransparentFX",
"2": "Ignore Raycast",
"4": "Water",
"5": "UI",
"8": "Player",
"9": "Enemy"
}
```
### mcpforunity://menu-items
**Purpose:** All available Unity menu items.
**Returns:**
```json
[
"File/New Scene",
"File/Open Scene",
"File/Save",
"Edit/Undo",
"Edit/Redo",
"GameObject/Create Empty",
"GameObject/3D Object/Cube",
"Window/General/Console"
]
```
### mcpforunity://custom-tools
**Purpose:** Custom tools available in the active Unity project.
**Returns:**
```json
{
"project_id": "MyProject",
"tool_count": 3,
"tools": [
{
"name": "capture_screenshot",
"description": "Capture screenshots in Unity",
"parameters": [
{"name": "filename", "type": "string", "required": true},
{"name": "width", "type": "int", "required": false},
{"name": "height", "type": "int", "required": false}
]
}
]
}
```
---
## Instance Resources
### mcpforunity://instances
**Purpose:** All running Unity Editor instances (for multi-instance workflows).
**Returns:**
```json
{
"transport": "http",
"instance_count": 2,
"instances": [
{
"id": "MyProject@abc123",
"name": "MyProject",
"hash": "abc123",
"unity_version": "2022.3.10f1",
"connected_at": "2024-01-15T10:30:00Z"
},
{
"id": "TestProject@def456",
"name": "TestProject",
"hash": "def456",
"unity_version": "2022.3.10f1",
"connected_at": "2024-01-15T11:00:00Z"
}
],
"warnings": []
}
```
**Use with:** `set_active_instance(instance="MyProject@abc123")`
---
## Test Resources
### mcpforunity://tests
**Purpose:** All tests in the project.
**Returns:**
```json
[
{
"name": "TestSomething",
"full_name": "MyTests.TestSomething",
"mode": "EditMode"
},
{
"name": "TestOther",
"full_name": "MyTests.TestOther",
"mode": "PlayMode"
}
]
```
### mcpforunity://tests/{mode}
**Purpose:** Tests filtered by mode.
**Parameters:**
- `mode` (string): "EditMode" or "PlayMode"
**Example:** `mcpforunity://tests/EditMode`
---
## Best Practices
### 1. Check Editor State First
```python
# Before any complex operation:
# Read mcpforunity://editor/state
# Check ready_for_tools == true
```
### 2. Use Find Then Read Pattern
```python
# 1. find_gameobjects to get IDs
result = find_gameobjects(search_term="Player")
# 2. Read resource for full data
# mcpforunity://scene/gameobject/{id}
```
### 3. Paginate Large Queries
```python
# Start with include_properties=false for component lists
# mcpforunity://scene/gameobject/{id}/components?include_properties=false&page_size=25
# Then read specific components as needed
# mcpforunity://scene/gameobject/{id}/component/Rigidbody
```
### 4. URL-Encode Prefab Paths
```python
# Wrong:
# mcpforunity://prefab/Assets/Prefabs/Player.prefab
# Correct:
# mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab
```
### 5. Multi-Instance Awareness
```python
# Always check mcpforunity://instances when:
# - First connecting
# - Commands fail unexpectedly
# - Working with multiple projects
```

View File

@ -0,0 +1,606 @@
# Unity-MCP Tools Reference
Complete reference for all MCP tools. Each tool includes parameters, types, and usage examples.
## Table of Contents
- [Infrastructure Tools](#infrastructure-tools)
- [Scene Tools](#scene-tools)
- [GameObject Tools](#gameobject-tools)
- [Script Tools](#script-tools)
- [Asset Tools](#asset-tools)
- [Material & Shader Tools](#material--shader-tools)
- [Editor Control Tools](#editor-control-tools)
- [Testing Tools](#testing-tools)
---
## Infrastructure Tools
### batch_execute
Execute multiple MCP commands in a single batch (10-100x faster).
```python
batch_execute(
commands=[ # list[dict], required, max 25
{"tool": "tool_name", "params": {...}},
...
],
parallel=False, # bool, optional - run read-only ops in parallel
fail_fast=False, # bool, optional - stop on first failure
max_parallelism=None # int, optional - max parallel workers
)
```
### set_active_instance
Route commands to a specific Unity instance (multi-instance workflows).
```python
set_active_instance(
instance="ProjectName@abc123" # str, required - Name@hash or hash prefix
)
```
### refresh_unity
Refresh asset database and trigger script compilation.
```python
refresh_unity(
mode="if_dirty", # "if_dirty" | "force"
scope="all", # "assets" | "scripts" | "all"
compile="none", # "none" | "request"
wait_for_ready=True # bool - wait until editor ready
)
```
---
## Scene Tools
### manage_scene
Scene CRUD operations and hierarchy queries.
```python
# Get hierarchy (paginated)
manage_scene(
action="get_hierarchy",
page_size=50, # int, default 50, max 500
cursor=0, # int, pagination cursor
parent=None, # str|int, optional - filter by parent
include_transform=False # bool - include local transforms
)
# Screenshot
manage_scene(action="screenshot") # Returns base64 PNG
# Other actions
manage_scene(action="get_active") # Current scene info
manage_scene(action="get_build_settings") # Build settings
manage_scene(action="create", name="NewScene", path="Assets/Scenes/")
manage_scene(action="load", path="Assets/Scenes/Main.unity")
manage_scene(action="save")
```
### find_gameobjects
Search for GameObjects (returns instance IDs only).
```python
find_gameobjects(
search_term="Player", # str, required
search_method="by_name", # "by_name"|"by_tag"|"by_layer"|"by_component"|"by_path"|"by_id"
include_inactive=False, # bool|str
page_size=50, # int, default 50, max 500
cursor=0 # int, pagination cursor
)
# Returns: {"ids": [12345, 67890], "next_cursor": 50, ...}
```
---
## GameObject Tools
### manage_gameobject
Create, modify, delete, duplicate GameObjects.
```python
# Create
manage_gameobject(
action="create",
name="MyCube", # str, required
primitive_type="Cube", # "Cube"|"Sphere"|"Capsule"|"Cylinder"|"Plane"|"Quad"
position=[0, 1, 0], # list[float] or JSON string "[0,1,0]"
rotation=[0, 45, 0], # euler angles
scale=[1, 1, 1],
components_to_add=["Rigidbody", "BoxCollider"],
save_as_prefab=False,
prefab_path="Assets/Prefabs/MyCube.prefab"
)
# Modify
manage_gameobject(
action="modify",
target="Player", # name, path, or instance ID
search_method="by_name", # how to find target
position=[10, 0, 0],
rotation=[0, 90, 0],
scale=[2, 2, 2],
set_active=True,
layer="Player",
components_to_add=["AudioSource"],
components_to_remove=["OldComponent"],
component_properties={ # nested dict for property setting
"Rigidbody": {
"mass": 10.0,
"useGravity": True
}
}
)
# Delete
manage_gameobject(action="delete", target="OldObject")
# Duplicate
manage_gameobject(
action="duplicate",
target="Player",
new_name="Player2",
offset=[5, 0, 0] # position offset from original
)
# Move relative
manage_gameobject(
action="move_relative",
target="Player",
reference_object="Enemy", # optional reference
direction="left", # "left"|"right"|"up"|"down"|"forward"|"back"
distance=5.0,
world_space=True
)
```
### manage_components
Add, remove, or set properties on components.
```python
# Add component
manage_components(
action="add",
target=12345, # instance ID (preferred) or name
component_type="Rigidbody",
search_method="by_id"
)
# Remove component
manage_components(
action="remove",
target="Player",
component_type="OldScript"
)
# Set single property
manage_components(
action="set_property",
target=12345,
component_type="Rigidbody",
property="mass",
value=5.0
)
# Set multiple properties
manage_components(
action="set_property",
target=12345,
component_type="Transform",
properties={
"position": [1, 2, 3],
"localScale": [2, 2, 2]
}
)
```
---
## Script Tools
### create_script
Create a new C# script.
```python
create_script(
path="Assets/Scripts/MyScript.cs", # str, required
contents='''using UnityEngine;
public class MyScript : MonoBehaviour
{
void Start() { }
void Update() { }
}''',
script_type="MonoBehaviour", # optional hint
namespace="MyGame" # optional namespace
)
```
### script_apply_edits
Apply structured edits to C# scripts (safer than raw text edits).
```python
script_apply_edits(
name="MyScript", # script name (no .cs)
path="Assets/Scripts", # folder path
edits=[
# Replace entire method
{
"op": "replace_method",
"methodName": "Update",
"replacement": "void Update() { transform.Rotate(Vector3.up); }"
},
# Insert new method
{
"op": "insert_method",
"afterMethod": "Start",
"code": "void OnEnable() { Debug.Log(\"Enabled\"); }"
},
# Delete method
{
"op": "delete_method",
"methodName": "OldMethod"
},
# Anchor-based insert
{
"op": "anchor_insert",
"anchor": "void Start()",
"position": "before", # "before" | "after"
"text": "// Called before Start\n"
},
# Regex replace
{
"op": "regex_replace",
"pattern": "Debug\\.Log\\(",
"text": "Debug.LogWarning("
},
# Prepend/append to file
{"op": "prepend", "text": "// File header\n"},
{"op": "append", "text": "\n// File footer"}
]
)
```
### apply_text_edits
Apply precise character-position edits (1-indexed lines/columns).
```python
apply_text_edits(
uri="mcpforunity://path/Assets/Scripts/MyScript.cs",
edits=[
{
"startLine": 10,
"startCol": 5,
"endLine": 10,
"endCol": 20,
"newText": "replacement text"
}
],
precondition_sha256="abc123...", # optional, prevents stale edits
strict=True # optional, stricter validation
)
```
### validate_script
Check script for syntax/semantic errors.
```python
validate_script(
uri="mcpforunity://path/Assets/Scripts/MyScript.cs",
level="standard", # "basic" | "standard"
include_diagnostics=True # include full error details
)
```
### get_sha
Get file hash without content (for preconditions).
```python
get_sha(uri="mcpforunity://path/Assets/Scripts/MyScript.cs")
# Returns: {"sha256": "...", "lengthBytes": 1234, "lastModifiedUtc": "..."}
```
### delete_script
Delete a script file.
```python
delete_script(uri="mcpforunity://path/Assets/Scripts/OldScript.cs")
```
---
## Asset Tools
### manage_asset
Asset operations: search, import, create, modify, delete.
```python
# Search assets (paginated)
manage_asset(
action="search",
path="Assets", # search scope
search_pattern="*.prefab", # glob or "t:MonoScript" filter
filter_type="Prefab", # optional type filter
page_size=25, # keep small to avoid large payloads
page_number=1, # 1-based
generate_preview=False # avoid base64 bloat
)
# Get asset info
manage_asset(action="get_info", path="Assets/Prefabs/Player.prefab")
# Create asset
manage_asset(
action="create",
path="Assets/Materials/NewMaterial.mat",
asset_type="Material",
properties={"color": [1, 0, 0, 1]}
)
# Duplicate/move/rename
manage_asset(action="duplicate", path="Assets/A.prefab", destination="Assets/B.prefab")
manage_asset(action="move", path="Assets/A.prefab", destination="Assets/Prefabs/A.prefab")
manage_asset(action="rename", path="Assets/A.prefab", destination="Assets/B.prefab")
# Create folder
manage_asset(action="create_folder", path="Assets/NewFolder")
# Delete
manage_asset(action="delete", path="Assets/OldAsset.asset")
```
### manage_prefabs
Headless prefab operations.
```python
# Get prefab info
manage_prefabs(action="get_info", prefab_path="Assets/Prefabs/Player.prefab")
# Get prefab hierarchy
manage_prefabs(action="get_hierarchy", prefab_path="Assets/Prefabs/Player.prefab")
# Create prefab from scene GameObject
manage_prefabs(
action="create_from_gameobject",
target="Player", # GameObject in scene
prefab_path="Assets/Prefabs/Player.prefab",
allow_overwrite=False
)
# Modify prefab contents (headless)
manage_prefabs(
action="modify_contents",
prefab_path="Assets/Prefabs/Player.prefab",
target="ChildObject", # object within prefab
position=[0, 1, 0],
components_to_add=["AudioSource"]
)
```
---
## Material & Shader Tools
### manage_material
Create and modify materials.
```python
# Create material
manage_material(
action="create",
material_path="Assets/Materials/Red.mat",
shader="Standard",
properties={"_Color": [1, 0, 0, 1]}
)
# Get material info
manage_material(action="get_material_info", material_path="Assets/Materials/Red.mat")
# Set shader property
manage_material(
action="set_material_shader_property",
material_path="Assets/Materials/Red.mat",
property="_Metallic",
value=0.8
)
# Set color
manage_material(
action="set_material_color",
material_path="Assets/Materials/Red.mat",
property="_BaseColor",
color=[0, 1, 0, 1] # RGBA
)
# Assign to renderer
manage_material(
action="assign_material_to_renderer",
target="MyCube",
material_path="Assets/Materials/Red.mat",
slot=0 # material slot index
)
# Set renderer color directly
manage_material(
action="set_renderer_color",
target="MyCube",
color=[1, 0, 0, 1],
mode="instance" # "shared"|"instance"|"property_block"
)
```
### manage_texture
Create procedural textures.
```python
manage_texture(
action="create",
path="Assets/Textures/Checker.png",
width=64,
height=64,
fill_color=[255, 255, 255, 255] # or [1.0, 1.0, 1.0, 1.0]
)
# Apply pattern
manage_texture(
action="apply_pattern",
path="Assets/Textures/Checker.png",
pattern="checkerboard", # "checkerboard"|"stripes"|"dots"|"grid"|"brick"
palette=[[0,0,0,255], [255,255,255,255]],
pattern_size=8
)
# Apply gradient
manage_texture(
action="apply_gradient",
path="Assets/Textures/Gradient.png",
gradient_type="linear", # "linear"|"radial"
gradient_angle=45,
palette=[[255,0,0,255], [0,0,255,255]]
)
```
---
## Editor Control Tools
### manage_editor
Control Unity Editor state.
```python
manage_editor(action="play") # Enter play mode
manage_editor(action="pause") # Pause play mode
manage_editor(action="stop") # Exit play mode
manage_editor(action="set_active_tool", tool_name="Move") # Move/Rotate/Scale/etc.
manage_editor(action="add_tag", tag_name="Enemy")
manage_editor(action="remove_tag", tag_name="OldTag")
manage_editor(action="add_layer", layer_name="Projectiles")
manage_editor(action="remove_layer", layer_name="OldLayer")
```
### execute_menu_item
Execute any Unity menu item.
```python
execute_menu_item(menu_path="File/Save Project")
execute_menu_item(menu_path="GameObject/3D Object/Cube")
execute_menu_item(menu_path="Window/General/Console")
```
### read_console
Read or clear Unity console messages.
```python
# Get recent messages
read_console(
action="get",
types=["error", "warning", "log"], # or ["all"]
count=10, # max messages (ignored with paging)
filter_text="NullReference", # optional text filter
since_timestamp="2024-01-01T00:00:00Z", # optional time filter
page_size=50,
cursor=0,
format="detailed", # "plain"|"detailed"|"json"
include_stacktrace=True
)
# Clear console
read_console(action="clear")
```
---
## Testing Tools
### run_tests
Start async test execution.
```python
result = run_tests(
mode="EditMode", # "EditMode"|"PlayMode"
test_names=["MyTests.TestA", "MyTests.TestB"], # specific tests
group_names=["Integration*"], # regex patterns
category_names=["Unit"], # NUnit categories
assembly_names=["Tests"], # assembly filter
include_failed_tests=True, # include failure details
include_details=False # include all test details
)
# Returns: {"job_id": "abc123", ...}
```
### get_test_job
Poll test job status.
```python
result = get_test_job(
job_id="abc123",
wait_timeout=60, # wait up to N seconds
include_failed_tests=True,
include_details=False
)
# Returns: {"status": "complete"|"running"|"failed", "results": {...}}
```
---
## Search Tools
### find_in_file
Search file contents with regex.
```python
find_in_file(
uri="mcpforunity://path/Assets/Scripts/MyScript.cs",
pattern="public void \\w+", # regex pattern
max_results=200,
ignore_case=True
)
# Returns: line numbers, content excerpts, match positions
```
---
## Custom Tools
### execute_custom_tool
Execute project-specific custom tools.
```python
execute_custom_tool(
tool_name="my_custom_tool",
parameters={"param1": "value", "param2": 42}
)
```
Discover available custom tools via `mcpforunity://custom-tools` resource.

View File

@ -0,0 +1,609 @@
# Unity-MCP Workflow Patterns
Common workflows and patterns for effective Unity-MCP usage.
## Table of Contents
- [Setup & Verification](#setup--verification)
- [Scene Creation Workflows](#scene-creation-workflows)
- [Script Development Workflows](#script-development-workflows)
- [Asset Management Workflows](#asset-management-workflows)
- [Testing Workflows](#testing-workflows)
- [Debugging Workflows](#debugging-workflows)
- [Batch Operations](#batch-operations)
---
## Setup & Verification
### Initial Connection Verification
```python
# 1. Check editor state
# Read mcpforunity://editor/state
# 2. Verify ready_for_tools == true
# If false, wait for recommended_retry_after_ms
# 3. Check active scene
# Read mcpforunity://editor/state → active_scene
# 4. List available instances (multi-instance)
# Read mcpforunity://instances
```
### Before Any Operation
```python
# Quick readiness check pattern:
editor_state = read_resource("mcpforunity://editor/state")
if not editor_state["ready_for_tools"]:
# Check blocking_reasons
# Wait recommended_retry_after_ms
pass
if editor_state["is_compiling"]:
# Wait for compilation to complete
pass
```
---
## Scene Creation Workflows
### Create Complete Scene from Scratch
```python
# 1. Create new scene
manage_scene(action="create", name="GameLevel", path="Assets/Scenes/")
# 2. Batch create environment objects
batch_execute(commands=[
{"tool": "manage_gameobject", "params": {
"action": "create", "name": "Ground", "primitive_type": "Plane",
"position": [0, 0, 0], "scale": [10, 1, 10]
}},
{"tool": "manage_gameobject", "params": {
"action": "create", "name": "Light", "primitive_type": "Cube"
}},
{"tool": "manage_gameobject", "params": {
"action": "create", "name": "Player", "primitive_type": "Capsule",
"position": [0, 1, 0]
}}
])
# 3. Add light component (delete cube mesh, add light)
manage_components(action="remove", target="Light", component_type="MeshRenderer")
manage_components(action="remove", target="Light", component_type="MeshFilter")
manage_components(action="remove", target="Light", component_type="BoxCollider")
manage_components(action="add", target="Light", component_type="Light")
manage_components(action="set_property", target="Light", component_type="Light",
property="type", value="Directional")
# 4. Set up camera
manage_gameobject(action="modify", target="Main Camera", position=[0, 5, -10],
rotation=[30, 0, 0])
# 5. Verify with screenshot
manage_scene(action="screenshot")
# 6. Save scene
manage_scene(action="save")
```
### Populate Scene with Grid of Objects
```python
# Create 5x5 grid of cubes using batch
commands = []
for x in range(5):
for z in range(5):
commands.append({
"tool": "manage_gameobject",
"params": {
"action": "create",
"name": f"Cube_{x}_{z}",
"primitive_type": "Cube",
"position": [x * 2, 0, z * 2]
}
})
# Execute in batches of 25
batch_execute(commands=commands[:25], parallel=True)
```
### Clone and Arrange Objects
```python
# Find template object
result = find_gameobjects(search_term="Template", search_method="by_name")
template_id = result["ids"][0]
# Duplicate in a line
for i in range(10):
manage_gameobject(
action="duplicate",
target=template_id,
new_name=f"Instance_{i}",
offset=[i * 2, 0, 0]
)
```
---
## Script Development Workflows
### Create New Script and Attach
```python
# 1. Create script
create_script(
path="Assets/Scripts/EnemyAI.cs",
contents='''using UnityEngine;
public class EnemyAI : MonoBehaviour
{
public float speed = 5f;
public Transform target;
void Update()
{
if (target != null)
{
Vector3 direction = (target.position - transform.position).normalized;
transform.position += direction * speed * Time.deltaTime;
}
}
}'''
)
# 2. CRITICAL: Refresh and compile
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
# 3. Check for errors
console = read_console(types=["error"], count=10)
if console["messages"]:
# Handle compilation errors
print("Compilation errors:", console["messages"])
else:
# 4. Attach to GameObject
manage_gameobject(action="modify", target="Enemy", components_to_add=["EnemyAI"])
# 5. Set component properties
manage_components(
action="set_property",
target="Enemy",
component_type="EnemyAI",
properties={
"speed": 10.0
}
)
```
### Edit Existing Script Safely
```python
# 1. Get current SHA
sha_info = get_sha(uri="mcpforunity://path/Assets/Scripts/PlayerController.cs")
# 2. Find the method to edit
matches = find_in_file(
uri="mcpforunity://path/Assets/Scripts/PlayerController.cs",
pattern="void Update\\(\\)"
)
# 3. Apply structured edit
script_apply_edits(
name="PlayerController",
path="Assets/Scripts",
edits=[{
"op": "replace_method",
"methodName": "Update",
"replacement": '''void Update()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
transform.Translate(new Vector3(h, 0, v) * speed * Time.deltaTime);
}'''
}]
)
# 4. Validate
validate_script(
uri="mcpforunity://path/Assets/Scripts/PlayerController.cs",
level="standard"
)
# 5. Refresh
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
# 6. Check console
read_console(types=["error"], count=10)
```
### Add Method to Existing Class
```python
script_apply_edits(
name="GameManager",
path="Assets/Scripts",
edits=[
{
"op": "insert_method",
"afterMethod": "Start",
"code": '''
public void ResetGame()
{
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}'''
},
{
"op": "anchor_insert",
"anchor": "using UnityEngine;",
"position": "after",
"text": "\nusing UnityEngine.SceneManagement;"
}
]
)
```
---
## Asset Management Workflows
### Create and Apply Material
```python
# 1. Create material
manage_material(
action="create",
material_path="Assets/Materials/PlayerMaterial.mat",
shader="Standard",
properties={
"_Color": [0.2, 0.5, 1.0, 1.0],
"_Metallic": 0.5,
"_Glossiness": 0.8
}
)
# 2. Assign to renderer
manage_material(
action="assign_material_to_renderer",
target="Player",
material_path="Assets/Materials/PlayerMaterial.mat",
slot=0
)
# 3. Verify visually
manage_scene(action="screenshot")
```
### Create Procedural Texture
```python
# 1. Create base texture
manage_texture(
action="create",
path="Assets/Textures/Checkerboard.png",
width=256,
height=256,
fill_color=[255, 255, 255, 255]
)
# 2. Apply checkerboard pattern
manage_texture(
action="apply_pattern",
path="Assets/Textures/Checkerboard.png",
pattern="checkerboard",
palette=[[0, 0, 0, 255], [255, 255, 255, 255]],
pattern_size=32
)
# 3. Create material with texture
manage_material(
action="create",
material_path="Assets/Materials/CheckerMaterial.mat",
shader="Standard"
)
# 4. Assign texture to material (via manage_material set_material_shader_property)
```
### Organize Assets into Folders
```python
# 1. Create folder structure
batch_execute(commands=[
{"tool": "manage_asset", "params": {"action": "create_folder", "path": "Assets/Prefabs"}},
{"tool": "manage_asset", "params": {"action": "create_folder", "path": "Assets/Materials"}},
{"tool": "manage_asset", "params": {"action": "create_folder", "path": "Assets/Scripts"}},
{"tool": "manage_asset", "params": {"action": "create_folder", "path": "Assets/Textures"}}
])
# 2. Move existing assets
manage_asset(action="move", path="Assets/MyMaterial.mat", destination="Assets/Materials/MyMaterial.mat")
manage_asset(action="move", path="Assets/MyScript.cs", destination="Assets/Scripts/MyScript.cs")
```
### Search and Process Assets
```python
# Find all prefabs
result = manage_asset(
action="search",
path="Assets",
search_pattern="*.prefab",
page_size=50,
generate_preview=False
)
# Process each prefab
for asset in result["assets"]:
prefab_path = asset["path"]
# Get prefab info
info = manage_prefabs(action="get_info", prefab_path=prefab_path)
print(f"Prefab: {prefab_path}, Children: {info['childCount']}")
```
---
## Testing Workflows
### Run Specific Tests
```python
# 1. List available tests
# Read mcpforunity://tests/EditMode
# 2. Run specific tests
result = run_tests(
mode="EditMode",
test_names=["MyTests.TestPlayerMovement", "MyTests.TestEnemySpawn"],
include_failed_tests=True
)
job_id = result["job_id"]
# 3. Wait for results
final_result = get_test_job(
job_id=job_id,
wait_timeout=60,
include_failed_tests=True
)
# 4. Check results
if final_result["status"] == "complete":
for test in final_result.get("failed_tests", []):
print(f"FAILED: {test['name']}: {test['message']}")
```
### Run Tests by Category
```python
# Run all unit tests
result = run_tests(
mode="EditMode",
category_names=["Unit"],
include_failed_tests=True
)
# Poll until complete
while True:
status = get_test_job(job_id=result["job_id"], wait_timeout=30)
if status["status"] in ["complete", "failed"]:
break
```
### Test-Driven Development Pattern
```python
# 1. Write test first
create_script(
path="Assets/Tests/Editor/PlayerTests.cs",
contents='''using NUnit.Framework;
using UnityEngine;
public class PlayerTests
{
[Test]
public void TestPlayerStartsAtOrigin()
{
var player = new GameObject("TestPlayer");
Assert.AreEqual(Vector3.zero, player.transform.position);
Object.DestroyImmediate(player);
}
}'''
)
# 2. Refresh
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
# 3. Run test (expect pass for this simple test)
result = run_tests(mode="EditMode", test_names=["PlayerTests.TestPlayerStartsAtOrigin"])
get_test_job(job_id=result["job_id"], wait_timeout=30)
```
---
## Debugging Workflows
### Diagnose Compilation Errors
```python
# 1. Check console for errors
errors = read_console(
types=["error"],
count=20,
include_stacktrace=True,
format="detailed"
)
# 2. For each error, find the file and line
for error in errors["messages"]:
# Parse error message for file:line info
# Use find_in_file to locate the problematic code
pass
# 3. After fixing, refresh and check again
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
read_console(types=["error"], count=10)
```
### Investigate Missing References
```python
# 1. Find the GameObject
result = find_gameobjects(search_term="Player", search_method="by_name")
# 2. Get all components
# Read mcpforunity://scene/gameobject/{id}/components
# 3. Check for null references in serialized fields
# Look for fields with null/missing values
# 4. Find the referenced object
result = find_gameobjects(search_term="Target", search_method="by_name")
# 5. Set the reference
manage_components(
action="set_property",
target="Player",
component_type="PlayerController",
property="target",
value={"instanceID": result["ids"][0]} # Reference by ID
)
```
### Check Scene State
```python
# 1. Get hierarchy
hierarchy = manage_scene(action="get_hierarchy", page_size=100, include_transform=True)
# 2. Find objects at unexpected positions
for item in hierarchy["data"]["items"]:
if item.get("transform", {}).get("position", [0,0,0])[1] < -100:
print(f"Object {item['name']} fell through floor!")
# 3. Visual verification
manage_scene(action="screenshot")
```
---
## Batch Operations
### Mass Property Update
```python
# Find all enemies
enemies = find_gameobjects(search_term="Enemy", search_method="by_tag")
# Update health on all enemies
commands = []
for enemy_id in enemies["ids"]:
commands.append({
"tool": "manage_components",
"params": {
"action": "set_property",
"target": enemy_id,
"component_type": "EnemyHealth",
"property": "maxHealth",
"value": 100
}
})
# Execute in batches
for i in range(0, len(commands), 25):
batch_execute(commands=commands[i:i+25], parallel=True)
```
### Mass Object Creation with Variations
```python
import random
commands = []
for i in range(20):
commands.append({
"tool": "manage_gameobject",
"params": {
"action": "create",
"name": f"Tree_{i}",
"primitive_type": "Capsule",
"position": [random.uniform(-50, 50), 0, random.uniform(-50, 50)],
"scale": [1, random.uniform(2, 5), 1]
}
})
batch_execute(commands=commands, parallel=True)
```
### Cleanup Pattern
```python
# Find all temporary objects
temps = find_gameobjects(search_term="Temp_", search_method="by_name")
# Delete in batch
commands = [
{"tool": "manage_gameobject", "params": {"action": "delete", "target": id}}
for id in temps["ids"]
]
batch_execute(commands=commands, fail_fast=False)
```
---
## Error Recovery Patterns
### Stale File Recovery
```python
try:
apply_text_edits(uri=script_uri, edits=[...], precondition_sha256=old_sha)
except Exception as e:
if "stale_file" in str(e):
# Re-fetch SHA
new_sha = get_sha(uri=script_uri)
# Retry with new SHA
apply_text_edits(uri=script_uri, edits=[...], precondition_sha256=new_sha["sha256"])
```
### Domain Reload Recovery
```python
# After domain reload, connection may be lost
# Wait and retry pattern:
import time
max_retries = 5
for attempt in range(max_retries):
try:
editor_state = read_resource("mcpforunity://editor/state")
if editor_state["ready_for_tools"]:
break
except:
time.sleep(2 ** attempt) # Exponential backoff
```
### Compilation Block Recovery
```python
# If tools fail due to compilation:
# 1. Check console for errors
errors = read_console(types=["error"], count=20)
# 2. Fix the script errors
# ... edit scripts ...
# 3. Force refresh
refresh_unity(mode="force", scope="scripts", compile="request", wait_for_ready=True)
# 4. Verify clean console
errors = read_console(types=["error"], count=5)
if not errors["messages"]:
# Safe to proceed with tools
pass
```