chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
# MCP for Unity Server (Docker Image)
[![MCP](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
[![License](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT)
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
Model Context Protocol server for Unity Editor integration. Control Unity through natural language using AI assistants like Claude, Cursor, and more.
**Maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp)** - This project is not affiliated with Unity Technologies.
💬 **Join our community:** [Discord Server](https://discord.gg/y4p8KfzrN4)
**Required:** Install the [Unity MCP Plugin](https://github.com/CoplayDev/unity-mcp?tab=readme-ov-file#-step-1-install-the-unity-package) to connect Unity Editor with this MCP server.
---
## Quick Start
### 1. Pull the image
```bash
docker pull msanatan/mcp-for-unity-server:latest
```
### 2. Run the server
```bash
docker run -p 8080:8080 msanatan/mcp-for-unity-server:latest
```
This starts the MCP server on port 8080.
### 3. Configure your MCP Client
Add the following configuration to your MCP client (e.g., Claude Desktop config, Cursor settings):
```json
{
"mcpServers": {
"UnityMCP": {
"url": "http://localhost:8080/mcp"
}
}
}
```
---
## Configuration
The server connects to the Unity Editor automatically when both are running. No additional configuration is needed.
**Environment Variables:**
- `DISABLE_TELEMETRY=true` - Opt out of anonymous usage analytics
- `LOG_LEVEL=DEBUG` - Enable detailed logging (default: INFO)
Example running with environment variables:
```bash
docker run -p 8080:8080 -e LOG_LEVEL=DEBUG msanatan/mcp-for-unity-server:latest
```
---
## Remote-Hosted Mode
To deploy as a shared remote service with API key authentication and per-user session isolation, pass `--http-remote-hosted` along with an API key validation URL:
```bash
docker run -p 8080:8080 \
-e UNITY_MCP_HTTP_REMOTE_HOSTED=true \
-e UNITY_MCP_API_KEY_VALIDATION_URL=https://auth.example.com/api/validate-key \
-e UNITY_MCP_API_KEY_LOGIN_URL=https://app.example.com/api-keys \
msanatan/mcp-for-unity-server:latest
```
In this mode:
- All MCP tool/resource calls and Unity plugin WebSocket connections require a valid `X-API-Key` header.
- Each user only sees Unity instances that connected with their API key.
- Users must explicitly call `set_active_instance` to select a Unity instance.
**Remote-hosted environment variables:**
| Variable | Description |
|----------|-------------|
| `UNITY_MCP_HTTP_REMOTE_HOSTED` | Enable remote-hosted mode (`true`, `1`, or `yes`) |
| `UNITY_MCP_API_KEY_VALIDATION_URL` | External endpoint to validate API keys (required) |
| `UNITY_MCP_API_KEY_LOGIN_URL` | URL where users can obtain/manage API keys |
| `UNITY_MCP_API_KEY_CACHE_TTL` | Cache TTL for validated keys in seconds (default: `300`) |
| `UNITY_MCP_API_KEY_SERVICE_TOKEN_HEADER` | Header name for server-to-auth-service authentication |
| `UNITY_MCP_API_KEY_SERVICE_TOKEN` | Token value sent to the auth service |
**MCP client config with API key:**
```json
{
"mcpServers": {
"UnityMCP": {
"url": "http://your-server:8080/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
For full details, see the [Remote Server Auth Guide](https://github.com/CoplayDev/unity-mcp/blob/main/docs/guides/REMOTE_SERVER_AUTH.md).
---
## Example Prompts
Once connected, try these commands in your AI assistant:
- "Create a 3D player controller with WASD movement"
- "Add a rotating cube to the scene with a red material"
- "Create a simple platformer level with obstacles"
- "Generate a shader that creates a holographic effect"
- "List all GameObjects in the current scene"
---
## Documentation
For complete documentation, troubleshooting, and advanced usage, please visit the GitHub repository:
📖 **[Full Documentation](https://github.com/CoplayDev/unity-mcp#readme)**
---
## License
MIT License - See [LICENSE](https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE)
+36
View File
@@ -0,0 +1,36 @@
FROM python:3.13-slim
# Keep Python output unbuffered and disable pip's cache to shrink layers
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# Make uv copy packages into the venv (safer in read-only layers) and avoid
# auto-downloading Python runtimes because a system interpreter already exists
ENV UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN pip install uv
COPY . /app
WORKDIR /app/Server
RUN uv sync --frozen --no-dev
EXPOSE 8080
ENV PYTHONPATH=/app/Server/src
# ENTRYPOINT allows override via docker run arguments
# Default: stdio transport (Docker MCP Gateway compatible)
# For HTTP: docker run -p 8080:8080 <image> --transport http --http-host 0.0.0.0 --http-port 8080
# If hosting remotely, you should add the --project-scoped-tools flag
ENTRYPOINT ["uv", "run", "mcp-for-unity"]
CMD []
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 CoplayDev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+290
View File
@@ -0,0 +1,290 @@
# MCP for Unity Server
[![MCP](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
[![python](https://img.shields.io/badge/Python-3.10+-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![License](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT)
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
[![PyPI version](https://img.shields.io/pypi/v/mcpforunityserver?label=PyPI)](https://pypi.org/project/mcpforunityserver/)
[![Downloads](https://static.pepy.tech/badge/mcpforunityserver)](https://pepy.tech/project/mcpforunityserver)
Model Context Protocol server for Unity Editor integration. Control Unity through natural language using AI assistants like Claude, Cursor, and more.
**Maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp)** - This project is not affiliated with Unity Technologies.
💬 **Join our community:** [Discord Server](https://discord.gg/y4p8KfzrN4)
**Required:** Install the [Unity MCP Plugin](https://github.com/CoplayDev/unity-mcp?tab=readme-ov-file#-step-1-install-the-unity-package) to connect Unity Editor with this MCP server. You also need `uvx` (requires [uv](https://docs.astral.sh/uv/)) to run the server.
---
## Installation
### Option 1: PyPI
Install and run directly from PyPI using `uvx`.
**Run Server (HTTP):**
```bash
uvx --from mcpforunityserver mcp-for-unity --transport http --http-url http://localhost:8080
```
**MCP Client Configuration (HTTP):**
```json
{
"mcpServers": {
"UnityMCP": {
"url": "http://localhost:8080/mcp"
}
}
}
```
**MCP Client Configuration (stdio):**
```json
{
"mcpServers": {
"UnityMCP": {
"command": "uvx",
"args": [
"--from",
"mcpforunityserver",
"mcp-for-unity",
"--transport",
"stdio"
]
}
}
}
```
### Option 2: From GitHub Source
Use this to run the latest released version from the repository. Change the version to `main` to run the latest unreleased changes from the repository.
```json
{
"mcpServers": {
"UnityMCP": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/CoplayDev/unity-mcp@v10.0.0#subdirectory=Server",
"mcp-for-unity",
"--transport",
"stdio"
]
}
}
}
```
### Option 3: Docker
**Use Pre-built Image:**
```bash
docker run -p 8080:8080 msanatan/mcp-for-unity-server:latest --transport http --http-url http://0.0.0.0:8080
```
**Build Locally:**
```bash
docker build -t unity-mcp-server .
docker run -p 8080:8080 unity-mcp-server --transport http --http-url http://0.0.0.0:8080
```
Configure your MCP client with `"url": "http://localhost:8080/mcp"`.
### Option 4: Local Development
For contributing or modifying the server code:
```bash
# Clone the repository
git clone https://github.com/CoplayDev/unity-mcp.git
cd unity-mcp/Server
# Run with uv
uv run src/main.py --transport stdio
```
---
## Configuration
The server connects to Unity Editor automatically when both are running. Most users do not need to change any settings.
### CLI options
These options apply to the `mcp-for-unity` command (whether run via `uvx`, Docker, or `python src/main.py`).
- `--transport {stdio,http}` - Transport protocol (default: `stdio`)
- `--http-url URL` - Base URL used to derive host/port defaults (default: `http://localhost:8080`)
- `--http-host HOST` - Override HTTP bind host (overrides URL host)
- `--http-port PORT` - Override HTTP bind port (overrides URL port)
- `--http-remote-hosted` - Treat HTTP transport as remotely hosted
- Requires API key authentication (see below)
- Disables local/CLI-only HTTP routes (`/api/command`, `/api/instances`, `/api/custom-tools`)
- Forces explicit Unity instance selection for MCP tool/resource calls
- Isolates Unity sessions per user
- `--api-key-validation-url URL` - External endpoint to validate API keys (required when `--http-remote-hosted` is set)
- `--api-key-login-url URL` - URL where users can obtain/manage API keys (served by `/api/auth/login-url`)
- `--api-key-cache-ttl SECONDS` - Cache duration for validated keys (default: `300`)
- `--api-key-service-token-header HEADER` - Header name for server-to-auth-service authentication (e.g. `X-Service-Token`)
- `--api-key-service-token TOKEN` - Token value sent to the auth service for server authentication
- `--default-instance INSTANCE` - Default Unity instance to target (project name, hash, or `Name@hash`)
- `--project-scoped-tools` - Keep custom tools scoped to the active Unity project and enable the custom tools resource
- `--unity-instance-token TOKEN` - Optional per-launch token set by Unity for deterministic lifecycle management
- `--pidfile PATH` - Optional path where the server writes its PID on startup (used by Unity-managed terminal launches)
### Environment variables
- `UNITY_MCP_TRANSPORT` - Transport protocol: `stdio` or `http`
- `UNITY_MCP_HTTP_URL` - HTTP server URL (default: `http://localhost:8080`)
- `UNITY_MCP_HTTP_HOST` - HTTP bind host (overrides URL host)
- `UNITY_MCP_HTTP_PORT` - HTTP bind port (overrides URL port)
- `UNITY_MCP_HTTP_REMOTE_HOSTED` - Enable remote-hosted mode (`true`, `1`, or `yes`)
- `UNITY_MCP_DEFAULT_INSTANCE` - Default Unity instance to target (project name, hash, or `Name@hash`)
- `UNITY_MCP_SKIP_STARTUP_CONNECT=1` - Skip initial Unity connection attempt on startup
- `UNITY_MCP_LOG_DIR` - Override the rotating server log directory. Default: `%LOCALAPPDATA%\UnityMCP\Logs` (Windows), `~/Library/Application Support/UnityMCP/Logs` (macOS), `$XDG_STATE_HOME/UnityMCP/Logs` (Linux/BSD, defaults to `~/.local/state/UnityMCP/Logs`).
API key authentication (remote-hosted mode):
- `UNITY_MCP_API_KEY_VALIDATION_URL` - External endpoint to validate API keys
- `UNITY_MCP_API_KEY_LOGIN_URL` - URL where users can obtain/manage API keys
- `UNITY_MCP_API_KEY_CACHE_TTL` - Cache TTL for validated keys in seconds (default: `300`)
- `UNITY_MCP_API_KEY_SERVICE_TOKEN_HEADER` - Header name for server-to-auth-service authentication
- `UNITY_MCP_API_KEY_SERVICE_TOKEN` - Token value sent to the auth service for server authentication
Telemetry:
- `DISABLE_TELEMETRY=1` - Disable anonymous telemetry (opt-out)
- `UNITY_MCP_DISABLE_TELEMETRY=1` - Same as `DISABLE_TELEMETRY`
- `MCP_DISABLE_TELEMETRY=1` - Same as `DISABLE_TELEMETRY`
- `UNITY_MCP_TELEMETRY_ENDPOINT` - Override telemetry endpoint URL
- `UNITY_MCP_TELEMETRY_TIMEOUT` - Override telemetry request timeout (seconds)
### Examples
**Stdio (default):**
```bash
uvx --from mcpforunityserver mcp-for-unity --transport stdio
```
**HTTP (local):**
```bash
uvx --from mcpforunityserver mcp-for-unity --transport http --http-host 127.0.0.1 --http-port 8080
```
**HTTP (remote-hosted with API key auth):**
```bash
uvx --from mcpforunityserver mcp-for-unity \
--transport http \
--http-host 0.0.0.0 \
--http-port 8080 \
--http-remote-hosted \
--api-key-validation-url https://auth.example.com/api/validate-key \
--api-key-login-url https://app.example.com/api-keys
```
**Disable telemetry:**
```bash
DISABLE_TELEMETRY=1 uvx --from mcpforunityserver mcp-for-unity --transport stdio
```
---
## Remote-Hosted Mode
When deploying the server as a shared remote service (e.g. for a team or Asset Store users), enable `--http-remote-hosted` to activate API key authentication and per-user session isolation.
**Requirements:**
- An external HTTP endpoint that validates API keys. The server POSTs `{"api_key": "..."}` and expects `{"valid": true, "user_id": "..."}` or `{"valid": false}` in response.
- `--api-key-validation-url` must be provided (or `UNITY_MCP_API_KEY_VALIDATION_URL`). The server exits with code 1 if this is missing.
**What changes in remote-hosted mode:**
- All MCP tool/resource calls and Unity plugin WebSocket connections require a valid `X-API-Key` header.
- Each user only sees Unity instances that connected with their API key (session isolation).
- Auto-selection of a sole Unity instance is disabled; users must explicitly call `set_active_instance`.
- CLI REST routes (`/api/command`, `/api/instances`, `/api/custom-tools`) are disabled.
- `/health` and `/api/auth/login-url` remain accessible without authentication.
**MCP client config with API key:**
```json
{
"mcpServers": {
"UnityMCP": {
"url": "http://remote-server:8080/mcp",
"headers": {
"X-API-Key": "<your-api-key>"
}
}
}
}
```
For full details, see [Remote Server Auth Guide](../docs/guides/REMOTE_SERVER_AUTH.md) and [Architecture Reference](../docs/reference/REMOTE_SERVER_AUTH_ARCHITECTURE.md).
---
## MCP Resources
The server provides read-only MCP resources for querying Unity Editor state. Resources provide up-to-date information about your Unity project without modifying it.
**Accessing Resources:**
Resources are accessed by their URI (not their name). Always use `ListMcpResources` to get the correct URI format.
**Example URIs:**
- `mcpforunity://editor/state` - Editor readiness snapshot
- `mcpforunity://project/tags` - All project tags
- `mcpforunity://scene/gameobject/{instance_id}` - GameObject details by ID
- `mcpforunity://prefab/{encoded_path}` - Prefab info by asset path
**Important:** Resource names use underscores (e.g., `editor_state`) but URIs use slashes/hyphens (e.g., `mcpforunity://editor/state`). Always use the URI from `ListMcpResources()` when reading resources.
**All resource descriptions now include their URI** for easy reference. List available resources to see the complete catalog with URIs.
---
## Example Prompts
Once connected, try these commands in your AI assistant:
- "Create a 3D player controller with WASD movement"
- "Add a rotating cube to the scene with a red material"
- "Create a simple platformer level with obstacles"
- "Generate a shader that creates a holographic effect"
- "List all GameObjects in the current scene"
---
## Documentation
For complete documentation, troubleshooting, and advanced usage:
📖 **[Full Documentation](https://coplaydev.github.io/unity-mcp/)**
---
## Requirements
- **Python:** 3.10 or newer
- **Unity Editor:** 2021.3 LTS or newer
- **uv:** Python package manager ([Installation Guide](https://docs.astral.sh/uv/getting-started/installation/))
---
## License
MIT License - See [LICENSE](https://github.com/CoplayDev/unity-mcp/blob/main/LICENSE)
View File
+94
View File
@@ -0,0 +1,94 @@
[project]
name = "mcpforunityserver"
version = "10.0.0"
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
license = "MIT"
authors = [
{name = "Marcus Sanatan", email = "msanatan@gmail.com"},
{name = "David Sarno", email = "david.sarno@gmail.com"},
{name = "Wu Shutong", email = "martinwfire@gmail.com"}
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"Natural Language :: English",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Games/Entertainment",
"Topic :: Software Development :: Code Generators",
]
keywords = ["mcp", "unity", "ai", "model context protocol", "gamedev", "unity3d", "automation", "llm", "agent"]
requires-python = ">=3.10"
dependencies = [
"httpx>=0.27.2",
"fastmcp>=3.0.2,<4",
"mcp>=1.16.0",
"pydantic>=2.12.5",
"tomli>=2.3.0",
"fastapi>=0.104.0",
"uvicorn>=0.35.0",
"click>=8.1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23",
"pytest-cov>=4.1.0",
]
[project.urls]
Homepage = "https://coplaydev.github.io/unity-mcp/"
Documentation = "https://coplaydev.github.io/unity-mcp/"
Repository = "https://github.com/CoplayDev/unity-mcp.git"
Issues = "https://github.com/CoplayDev/unity-mcp/issues"
Changelog = "https://github.com/CoplayDev/unity-mcp/releases"
[project.scripts]
mcp-for-unity = "main:main"
unity-mcp = "cli.main:main"
[build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-dir]
"" = "src"
[tool.setuptools]
py-modules = ["main"]
[tool.coverage.run]
source = ["src"]
omit = [
"*/tests/*",
"*/test_*.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == \"__main__\":",
"if TYPE_CHECKING:",
"@abstractmethod",
]
precision = 2
show_missing = true
[tool.coverage.html]
directory = "htmlcov"
+11
View File
@@ -0,0 +1,11 @@
{
"typeCheckingMode": "basic",
"reportMissingImports": "none",
"pythonVersion": "3.11",
"executionEnvironments": [
{
"root": ".",
"pythonVersion": "3.11"
}
]
}
+1
View File
@@ -0,0 +1 @@
# MCP for Unity Server
+945
View File
@@ -0,0 +1,945 @@
# Unity MCP CLI Usage Guide
> **For AI Assistants and Developers**: This document explains the correct syntax and common pitfalls when using the Unity MCP CLI.
## Table of Contents
1. [Installation](#installation)
2. [Quick Start](#quick-start)
3. [Command Structure](#command-structure)
4. [Global Options](#global-options)
5. [Argument vs Option Syntax](#argument-vs-option-syntax)
6. [Common Mistakes and Corrections](#common-mistakes-and-corrections)
7. [Output Formats](#output-formats)
8. [Command Reference by Category](#command-reference-by-category)
---
## Installation
### Prerequisites
- **Python 3.10+** installed
- **Unity Editor** running with the MCP plugin enabled
- **MCP Server** running (HTTP transport on port 8080)
### Install via pip (from source)
```bash
# Navigate to the Server directory
cd /path/to/unity-mcp/Server
# Install in development mode
pip install -e .
# Or install with uv (recommended)
uv pip install -e .
```
### Install via uv tool
```bash
# Run directly without installing
uvx --from /path/to/unity-mcp/Server unity-mcp --help
# Or install as a tool
uv tool install /path/to/unity-mcp/Server
```
### Verify Installation
```bash
# Check version
unity-mcp --version
# Check help
unity-mcp --help
# Test connection to Unity
unity-mcp status
```
---
## Quick Start
### 1. Start the MCP Server
Make sure the Unity MCP server is running with HTTP transport:
```bash
# The server is typically started via the Unity-MCP window, select HTTP local, and start server, or try this manually:
cd /path/to/unity-mcp/Server
uv run mcp-for-unity --transport http --http-url http://localhost:8080
```
### 2. Verify Connection
```bash
unity-mcp status
```
Expected output:
```
Checking connection to 127.0.0.1:8080...
✅ Connected to Unity MCP server at 127.0.0.1:8080
Connected Unity instances:
• MyProject (Unity 6000.2.10f1) [09abcc51]
```
### 3. Run Your First Commands
```bash
# Get scene hierarchy
unity-mcp scene hierarchy
# Create a cube
unity-mcp gameobject create "MyCube" --primitive Cube
# Move the cube
unity-mcp gameobject modify "MyCube" --position 0 2 0
# Take a screenshot
unity-mcp camera screenshot
# Enter play mode
unity-mcp editor play
```
### 4. Get Help on Any Command
```bash
# List all commands
unity-mcp --help
# Help for a command group
unity-mcp gameobject --help
# Help for a specific command
unity-mcp gameobject create --help
```
---
## Command Structure
The CLI follows this general pattern:
```
unity-mcp [GLOBAL_OPTIONS] COMMAND_GROUP [SUBCOMMAND] [ARGUMENTS] [OPTIONS]
```
**Example breakdown:**
```bash
unity-mcp -f json gameobject create "MyCube" --primitive Cube --position 0 1 0
# ^^^^^^^ ^^^^^^^^^^^ ^^^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
# global cmd group subcmd argument option multi-value option
```
---
## Global Options
Global options come **BEFORE** the command group:
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
| `--host` | `-h` | MCP server host | `127.0.0.1` |
| `--port` | `-p` | MCP server port | `8080` |
| `--format` | `-f` | Output format: `text`, `json`, `table` | `text` |
| `--timeout` | `-t` | Command timeout in seconds | `30` |
| `--instance` | `-i` | Target Unity instance (hash or Name@hash) | auto |
| `--verbose` | `-v` | Enable verbose output | `false` |
**✅ Correct:**
```bash
unity-mcp -f json scene hierarchy
unity-mcp --format json --timeout 60 gameobject find "Player"
```
**❌ Wrong:**
```bash
unity-mcp scene hierarchy -f json # Global option after command
```
---
## Argument vs Option Syntax
### Arguments (Positional)
Arguments are **required values** that come in a specific order, **without** flags.
```bash
unity-mcp gameobject find "Player"
# ^^^^^^^^ This is an ARGUMENT (positional)
```
### Options (Named)
Options use `--name` or `-n` flags and can appear in any order after arguments.
```bash
unity-mcp gameobject create "MyCube" --primitive Cube
# ^^^^^^^^^^^ ^^^^ This is an OPTION with value
```
### Multi-Value Options
Some options accept multiple values. **Do NOT use commas** - use spaces:
**✅ Correct:**
```bash
unity-mcp gameobject modify "Cube" --position 1 2 3
unity-mcp gameobject modify "Cube" --rotation 0 45 0
unity-mcp gameobject modify "Cube" --scale 2 2 2
```
**❌ Wrong:**
```bash
unity-mcp gameobject modify "Cube" --position "1,2,3" # Wrong: comma-separated string
unity-mcp gameobject modify "Cube" --position 1,2,3 # Wrong: comma-separated
unity-mcp gameobject modify "Cube" -pos "1 2 3" # Wrong: quoted as single string
```
---
## Common Mistakes and Corrections
### 1. Multi-Value Options (Position, Rotation, Scale, Color)
These options expect **separate float arguments**, not comma-separated strings:
| Option | ❌ Wrong | ✅ Correct |
|--------|----------|-----------|
| `--position` | `--position "2,1,0"` | `--position 2 1 0` |
| `--rotation` | `--rotation "0,45,0"` | `--rotation 0 45 0` |
| `--scale` | `--scale "1,1,1"` | `--scale 1 1 1` |
| Color args | `1,0,0,1` | `1 0 0 1` |
**Example - Moving a GameObject:**
```bash
# Wrong - will error "requires 3 arguments"
unity-mcp gameobject modify "Cube" --position "2,1,0"
# Correct
unity-mcp gameobject modify "Cube" --position 2 1 0
```
**Example - Setting material color:**
```bash
# Wrong
unity-mcp material set-color "Assets/Mat.mat" 1,0,0,1
# Correct (R G B or R G B A as separate args)
unity-mcp material set-color "Assets/Mat.mat" 1 0 0
unity-mcp material set-color "Assets/Mat.mat" 1 0 0 1
```
### 2. Argument Order Matters
Some commands have multiple positional arguments. Check `--help` to see the order:
**Material assign:**
```bash
# Wrong - arguments in wrong order
unity-mcp material assign "TestCube" "Assets/Materials/Red.mat"
# ✅ Correct - MATERIAL_PATH comes before TARGET
unity-mcp material assign "Assets/Materials/Red.mat" "TestCube"
```
**Prefab create:**
```bash
# Wrong - using --path option that doesn't exist
unity-mcp prefab create "Cube" --path "Assets/Prefabs/Cube.prefab"
# Correct - PATH is a positional argument
unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab"
```
### 3. Using Options That Don't Exist
Always check `--help` before assuming an option exists:
```bash
# Check available options for any command
unity-mcp gameobject modify --help
unity-mcp material assign --help
unity-mcp prefab create --help
```
### 4. Property Names for Materials
Different shaders use different property names. Use `material info` to discover them:
```bash
# First, check what properties exist
unity-mcp material info "Assets/Materials/MyMat.mat"
# Then use the correct property name
# For URP shaders, often "_BaseColor" instead of "_Color"
unity-mcp material set-color "Assets/Mat.mat" 1 0 0 --property "_BaseColor"
```
### 5. Search Methods
When targeting GameObjects, specify how to search:
```bash
# By name (default)
unity-mcp gameobject modify "Player" --position 0 0 0
# By instance ID (use --search-method)
unity-mcp gameobject modify "-81840" --search-method by_id --position 0 0 0
# By path
unity-mcp gameobject modify "/Canvas/Panel/Button" --search-method by_path --active
# By tag
unity-mcp gameobject find "Player" --search-method by_tag
```
---
## Output Formats
### Text (Default)
Human-readable nested format:
```bash
unity-mcp scene active
# Output:
# status: success
# result:
# name: New Scene
# path: Assets/Scenes/New Scene.unity
# ...
```
### JSON
Machine-readable JSON:
```bash
unity-mcp -f json scene active
# Output: {"status": "success", "result": {...}}
```
### Table
Key-value table format:
```bash
unity-mcp -f table scene active
# Output:
# Key | Value
# -------+------
# status | success
# ...
```
---
## Command Reference by Category
### Status & Connection
```bash
# Check server connection and Unity instances
unity-mcp status
# List connected Unity instances
unity-mcp instances
```
### Scene Commands
```bash
# Get scene hierarchy
unity-mcp scene hierarchy
# Get active scene info
unity-mcp scene active
# Get build settings
unity-mcp scene build-settings
# Create new scene
unity-mcp scene create "MyScene"
# Load scene
unity-mcp scene load "Assets/Scenes/MyScene.unity"
# Save current scene
unity-mcp scene save
# Take screenshot (use camera command)
unity-mcp camera screenshot
unity-mcp camera screenshot --file-name "my_screenshot" --super-size 2
```
### GameObject Commands
```bash
# Find GameObjects
unity-mcp gameobject find "Player"
unity-mcp gameobject find "Enemy" --method by_tag
unity-mcp gameobject find "-81840" --method by_id
unity-mcp gameobject find "Rigidbody" --method by_component
# Create GameObject
unity-mcp gameobject create "Empty" # Empty object
unity-mcp gameobject create "MyCube" --primitive Cube # Primitive
unity-mcp gameobject create "MyObj" --position 0 5 0 # With position
unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider" # With components
# Modify GameObject
unity-mcp gameobject modify "Cube" --position 1 2 3
unity-mcp gameobject modify "Cube" --rotation 0 45 0
unity-mcp gameobject modify "Cube" --scale 2 2 2
unity-mcp gameobject modify "Cube" --name "NewName"
unity-mcp gameobject modify "Cube" --active # Enable
unity-mcp gameobject modify "Cube" --inactive # Disable
unity-mcp gameobject modify "Cube" --tag "Player"
unity-mcp gameobject modify "Cube" --parent "Parent"
# Delete GameObject
unity-mcp gameobject delete "Cube"
unity-mcp gameobject delete "Cube" --force # Skip confirmation
# Duplicate GameObject
unity-mcp gameobject duplicate "Cube"
# Move relative to another object
unity-mcp gameobject move "Cube" --reference "Player" --direction up --distance 2
```
### Component Commands
```bash
# Add component
unity-mcp component add "Cube" Rigidbody
unity-mcp component add "Cube" BoxCollider
# Remove component
unity-mcp component remove "Cube" Rigidbody
unity-mcp component remove "Cube" Rigidbody --force # Skip confirmation
# Set single property
unity-mcp component set "Cube" Rigidbody mass 5
unity-mcp component set "Cube" Rigidbody useGravity false
unity-mcp component set "Cube" Light intensity 2.5
# Set multiple properties at once
unity-mcp component modify "Cube" Rigidbody --properties '{"mass": 5, "drag": 0.5}'
```
### Asset Commands
```bash
# Search assets
unity-mcp asset search "Player"
unity-mcp asset search "t:Material" # By type
unity-mcp asset search "t:Prefab Player" # Combined
# Get asset info
unity-mcp asset info "Assets/Materials/Red.mat"
# Create asset
unity-mcp asset create "Assets/Materials/New.mat" Material
# Delete asset
unity-mcp asset delete "Assets/Materials/Old.mat"
unity-mcp asset delete "Assets/Materials/Old.mat" --force # Skip confirmation
# Move/Rename asset
unity-mcp asset move "Assets/Old/Mat.mat" "Assets/New/Mat.mat"
unity-mcp asset rename "Assets/Materials/Old.mat" "New"
# Create folder
unity-mcp asset mkdir "Assets/NewFolder"
# Import/reimport
unity-mcp asset import "Assets/Textures/image.png"
```
### Script Commands
```bash
# Create script
unity-mcp script create "MyScript" --path "Assets/Scripts"
unity-mcp script create "MyScript" --path "Assets/Scripts" --type MonoBehaviour
# Read script
unity-mcp script read "Assets/Scripts/MyScript.cs"
# Delete script
unity-mcp script delete "Assets/Scripts/MyScript.cs"
# Validate script
unity-mcp script validate "Assets/Scripts/MyScript.cs"
```
### Material Commands
```bash
# Create material
unity-mcp material create "Assets/Materials/New.mat"
unity-mcp material create "Assets/Materials/New.mat" --shader "Standard"
# Get material info
unity-mcp material info "Assets/Materials/Mat.mat"
# Set color (R G B or R G B A)
unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0
unity-mcp material set-color "Assets/Materials/Mat.mat" 1 0 0 --property "_BaseColor"
# Set shader property
unity-mcp material set-property "Assets/Materials/Mat.mat" "_Metallic" 0.5
# Assign to GameObject
unity-mcp material assign "Assets/Materials/Mat.mat" "Cube"
unity-mcp material assign "Assets/Materials/Mat.mat" "Cube" --slot 1
# Set renderer color directly
unity-mcp material set-renderer-color "Cube" 1 0 0 1
```
### Editor Commands
```bash
# Play mode control
unity-mcp editor play
unity-mcp editor pause
unity-mcp editor stop
# Console
unity-mcp editor console # Read console
unity-mcp editor console --count 20 # Last 20 entries
unity-mcp editor console --clear # Clear console
unity-mcp editor console --types error,warning # Filter by type
# Menu items
unity-mcp editor menu "Edit/Preferences"
unity-mcp editor menu "GameObject/Create Empty"
# Tags and Layers
unity-mcp editor add-tag "Enemy"
unity-mcp editor remove-tag "Enemy"
unity-mcp editor add-layer "Interactable"
unity-mcp editor remove-layer "Interactable"
# Editor tool
unity-mcp editor tool View
unity-mcp editor tool Move
unity-mcp editor tool Rotate
# Run tests
unity-mcp editor tests
unity-mcp editor tests --mode PlayMode
```
### Custom Tools
```bash
# List custom tools / default tools for the active Unity project
unity-mcp tool list
unity-mcp custom_tool list
# Execute a custom tool by name
unity-mcp editor custom-tool "MyBuildTool"
unity-mcp editor custom-tool "Deploy" --params '{"target": "Android"}'
```
### Prefab Commands
```bash
# Create prefab from scene object
unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab"
unity-mcp prefab create "Cube" "Assets/Prefabs/Cube.prefab" --overwrite
# Open prefab for editing
unity-mcp prefab open "Assets/Prefabs/Player.prefab"
# Save open prefab
unity-mcp prefab save
# Close prefab stage
unity-mcp prefab close
# Modify prefab contents (headless)
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2"
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1 --delete-child "Turret/Barrel"
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5"
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --add-component BoxCollider --remove-component SphereCollider
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}'
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --name NewName --tag Player --layer UI
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --inactive
```
### UI Commands
```bash
# Create a Canvas (adds Canvas, CanvasScaler, GraphicRaycaster)
unity-mcp ui create-canvas "MainCanvas"
unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace
# Create UI elements (must have a parent Canvas)
unity-mcp ui create-text "TitleText" --parent "MainCanvas" --text "Hello World"
unity-mcp ui create-button "StartButton" --parent "MainCanvas" --text "Click Me"
unity-mcp ui create-image "Background" --parent "MainCanvas"
```
### Lighting Commands
```bash
# Create lights with type, color, intensity
unity-mcp lighting create "Sun" --type Directional
unity-mcp lighting create "Lamp" --type Point --intensity 2 --position 0 5 0
unity-mcp lighting create "Spot" --type Spot --color 1 0 0 --intensity 3
unity-mcp lighting create "GreenLight" --type Point --color 0 1 0
```
### Audio Commands
```bash
# Control AudioSource (target must have AudioSource component)
unity-mcp audio play "MusicPlayer"
unity-mcp audio stop "MusicPlayer"
unity-mcp audio volume "MusicPlayer" 0.5
```
### Animation Commands
```bash
# Control Animator (target must have Animator component)
unity-mcp animation play "Character" "Walk"
unity-mcp animation set-parameter "Character" "Speed" 1.5 --type float
unity-mcp animation set-parameter "Character" "IsRunning" true --type bool
unity-mcp animation set-parameter "Character" "Jump" "" --type trigger
```
### Camera Commands
```bash
# Check Cinemachine availability
unity-mcp camera ping
# List all cameras in scene
unity-mcp camera list
# Create cameras (plain or with Cinemachine presets)
unity-mcp camera create # Basic camera
unity-mcp camera create --name "FollowCam" --preset follow --follow "Player" --look-at "Player"
unity-mcp camera create --preset third_person --follow "Player" --fov 50
unity-mcp camera create --preset dolly --look-at "Player"
unity-mcp camera create --preset top_down --follow "Player"
unity-mcp camera create --preset side_scroller --follow "Player"
unity-mcp camera create --preset static --fov 40
# Set targets on existing camera
unity-mcp camera set-target "FollowCam" --follow "Player" --look-at "Enemy"
# Lens settings
unity-mcp camera set-lens "MainCam" --fov 60 --near 0.1 --far 1000
unity-mcp camera set-lens "OrthoCamera" --ortho-size 10
# Priority (higher = preferred by CinemachineBrain)
unity-mcp camera set-priority "FollowCam" --priority 15
# Cinemachine Body/Aim/Noise configuration
unity-mcp camera set-body "FollowCam" --body-type "CinemachineFollow"
unity-mcp camera set-body "FollowCam" --body-type "CinemachineFollow" --props '{"TrackerSettings": {"BindingMode": 1}}'
unity-mcp camera set-aim "FollowCam" --aim-type "CinemachineRotationComposer"
unity-mcp camera set-noise "FollowCam" --amplitude 1.5 --frequency 0.5
# Extensions
unity-mcp camera add-extension "FollowCam" CinemachineConfiner3D
unity-mcp camera remove-extension "FollowCam" CinemachineConfiner3D
# Brain (ensure Brain exists on main camera, set default blend)
unity-mcp camera ensure-brain
unity-mcp camera ensure-brain --blend-style "EaseInOut" --blend-duration 1.5
unity-mcp camera brain-status
unity-mcp camera set-blend --style "Cut" --duration 0
# Force/release camera override
unity-mcp camera force "FollowCam"
unity-mcp camera release
# Screenshots
unity-mcp camera screenshot
unity-mcp camera screenshot --file-name "my_capture" --super-size 2
unity-mcp camera screenshot --camera-ref "SecondCamera" --include-image
unity-mcp camera screenshot --max-resolution 256
unity-mcp camera screenshot --batch surround --max-resolution 256
unity-mcp camera screenshot --batch orbit --view-target "Player"
unity-mcp camera screenshot --capture-source scene_view --view-target "Canvas" --include-image
unity-mcp camera screenshot-multiview --view-target "Player" --max-resolution 480
```
### Graphics Commands
```bash
# Check graphics system status
unity-mcp graphics ping
# --- Volumes ---
# Create a Volume (global or local)
unity-mcp graphics volume-create --name "PostProcessing" --global
unity-mcp graphics volume-create --name "LocalFog" --local --weight 0.8 --priority 1
# Add/remove/configure effects on a Volume
unity-mcp graphics volume-add-effect --target "PostProcessing" --effect "Bloom"
unity-mcp graphics volume-set-effect --target "PostProcessing" --effect "Bloom" -p intensity 1.5 -p threshold 0.9
unity-mcp graphics volume-remove-effect --target "PostProcessing" --effect "Bloom"
unity-mcp graphics volume-info --target "PostProcessing"
unity-mcp graphics volume-set-properties --target "PostProcessing" --weight 0.5 --priority 2 --local
unity-mcp graphics volume-list-effects
unity-mcp graphics volume-create-profile --path "Assets/Profiles/MyProfile.asset" --name "MyProfile"
# --- Render Pipeline ---
unity-mcp graphics pipeline-info
unity-mcp graphics pipeline-settings
unity-mcp graphics pipeline-set-quality --level "High"
unity-mcp graphics pipeline-set-settings -s renderScale 1.5 -s msaaSampleCount 4
# --- Light Baking ---
unity-mcp graphics bake-start
unity-mcp graphics bake-start --sync # Wait for completion
unity-mcp graphics bake-status
unity-mcp graphics bake-cancel
unity-mcp graphics bake-clear
unity-mcp graphics bake-settings
unity-mcp graphics bake-set-settings -s lightmapResolution 64 -s directSamples 32
unity-mcp graphics bake-reflection-probe --target "ReflectionProbe1"
unity-mcp graphics bake-create-probes --name "LightProbes" --spacing 5
unity-mcp graphics bake-create-reflection --name "ReflProbe" --resolution 512 --mode Realtime
# --- Rendering Stats ---
unity-mcp graphics stats
unity-mcp graphics stats-memory
unity-mcp graphics stats-debug-mode --mode "Wireframe"
# --- URP Renderer Features ---
unity-mcp graphics feature-list
unity-mcp graphics feature-add --type "ScreenSpaceAmbientOcclusion" --name "SSAO"
unity-mcp graphics feature-remove --name "SSAO"
unity-mcp graphics feature-configure --name "SSAO" -p Intensity 1.5 -p Radius 0.3
unity-mcp graphics feature-reorder --order "0,2,1,3"
unity-mcp graphics feature-toggle --name "SSAO" --active
unity-mcp graphics feature-toggle --name "SSAO" --inactive
# --- Skybox & Environment ---
unity-mcp graphics skybox-info
unity-mcp graphics skybox-set-material --material "Assets/Materials/NightSky.mat"
unity-mcp graphics skybox-set-properties -p _Tint "0.5,0.5,1,1" -p _Exposure 1.2
unity-mcp graphics skybox-set-ambient --mode Flat --color "0.2,0.2,0.3"
unity-mcp graphics skybox-set-ambient --mode Trilight --color "0.4,0.6,0.8" --equator-color "0.3,0.3,0.3" --ground-color "0.1,0.1,0.1"
unity-mcp graphics skybox-set-fog --enable --mode ExponentialSquared --color "0.7,0.8,0.9" --density 0.02
unity-mcp graphics skybox-set-fog --disable
unity-mcp graphics skybox-set-reflection --intensity 1.0 --bounces 2 --mode Custom --resolution 256
unity-mcp graphics skybox-set-sun --target "DirectionalLight"
```
### Package Commands
```bash
# Check package manager status
unity-mcp packages ping
# List installed packages
unity-mcp packages list
# Search Unity registry
unity-mcp packages search "cinemachine"
unity-mcp packages search "probuilder"
# Get package details
unity-mcp packages info "com.unity.cinemachine"
# Install / remove packages
unity-mcp packages add "com.unity.cinemachine"
unity-mcp packages add "com.unity.cinemachine@4.1.1"
unity-mcp packages remove "com.unity.cinemachine"
unity-mcp packages remove "com.unity.cinemachine" --force # Skip confirmation
# Embed package for local editing
unity-mcp packages embed "com.unity.cinemachine"
# Force package re-resolution
unity-mcp packages resolve
# Check async operation status
unity-mcp packages status <job_id>
# Scoped registries
unity-mcp packages list-registries
unity-mcp packages add-registry "My Registry" --url "https://registry.example.com" -s "com.example"
unity-mcp packages remove-registry "My Registry"
```
### Texture Commands
```bash
# Create procedural textures
unity-mcp texture create "Assets/Textures/Red.png" --width 128 --height 128 --color "1,0,0,1"
unity-mcp texture create "Assets/Textures/Check.png" --pattern checkerboard --palette "1,0,0,1;0,0,1,1"
unity-mcp texture create "Assets/Textures/Brick.png" --width 256 --height 256 --pattern brick
unity-mcp texture create "Assets/Textures/Grid.png" --pattern grid --width 512 --height 512
# Available patterns: checkerboard, stripes, stripes_h, stripes_v, stripes_diag, dots, grid, brick
# Create from image file
unity-mcp texture create "Assets/Textures/Photo.png" --image-path "/path/to/source.png"
# Create with custom import settings
unity-mcp texture create "Assets/Textures/Normal.png" --import-settings '{"textureType": "NormalMap", "filterMode": "Trilinear"}'
# Create sprites (auto-configures import settings for 2D)
unity-mcp texture sprite "Assets/Sprites/Player.png" --width 32 --height 32 --color "0,0.5,1,1"
unity-mcp texture sprite "Assets/Sprites/Tile.png" --pattern checkerboard --ppu 16 --pivot "0.5,0"
# Modify existing texture pixels
unity-mcp texture modify "Assets/Textures/Existing.png" --set-pixels '{"x":0,"y":0,"width":16,"height":16,"color":[1,0,0,1]}'
# Delete texture
unity-mcp texture delete "Assets/Textures/Old.png"
unity-mcp texture delete "Assets/Textures/Old.png" --force
```
### Code Commands
```bash
# Read source files
unity-mcp code read "Assets/Scripts/Player.cs"
unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20
# Search with regex
unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs"
unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs"
unity-mcp code search "void Update" "Assets/Scripts/Game.cs" --max-results 20
```
### Raw Commands
For advanced usage, send raw tool calls:
```bash
# Send any MCP tool directly
unity-mcp raw manage_scene '{"action": "get_active"}'
unity-mcp raw manage_gameobject '{"action": "create", "name": "Test"}'
unity-mcp raw manage_components '{"action": "add", "target": "Test", "componentType": "Rigidbody"}'
unity-mcp raw manage_editor '{"action": "play"}'
unity-mcp raw manage_camera '{"action": "screenshot", "include_image": true}'
unity-mcp raw manage_graphics '{"action": "volume_get_info", "target": "PostProcessing"}'
unity-mcp raw manage_packages '{"action": "list_packages"}'
```
---
## Known Behaviors
### Component Creation
When creating GameObjects with components, the CLI creates the object first, then adds components separately. This is the correct workflow for Unity MCP.
```bash
# This works correctly - creates object then adds components
unity-mcp gameobject create "Player" --components "Rigidbody,BoxCollider"
# Equivalent to:
unity-mcp gameobject create "Player"
unity-mcp component add "Player" Rigidbody
unity-mcp component add "Player" BoxCollider
```
### Light Creation
The `lighting create` command creates a complete light with the specified type, color, and intensity:
```bash
# Creates Point light with green color and intensity 5
unity-mcp lighting create "GreenLight" --type Point --color 0 1 0 --intensity 5
```
### UI Element Creation
UI commands automatically add the required components:
```bash
# create-canvas adds: Canvas, CanvasScaler, GraphicRaycaster
unity-mcp ui create-canvas "MainUI"
# create-button adds: Image, Button
unity-mcp ui create-button "MyButton" --parent "MainUI"
```
---
## Quick Reference Card
### Multi-Value Syntax
```bash
--position X Y Z # not "X,Y,Z"
--rotation X Y Z # not "X,Y,Z"
--scale X Y Z # not "X,Y,Z"
--color R G B # not "R,G,B"
```
### Argument Order (check --help)
```bash
material assign MATERIAL_PATH TARGET
prefab create TARGET PATH
component set TARGET COMPONENT PROPERTY VALUE
```
### Search Methods
```bash
--method by_name # default for gameobject find
--method by_id
--method by_path
--method by_tag
--method by_component
```
### Global Options Position
```bash
unity-mcp [GLOBAL_OPTIONS] command subcommand [ARGS] [OPTIONS]
# ^^^^^^^^^^^^^^^^
# Must come BEFORE command!
```
---
## Debugging Tips
1. **Always check `--help`** for any command:
```bash
unity-mcp gameobject --help
unity-mcp gameobject modify --help
```
2. **Use verbose mode** to see what's happening:
```bash
unity-mcp -v scene hierarchy
```
3. **Use JSON output** for programmatic parsing:
```bash
unity-mcp -f json gameobject find "Player" | jq '.result'
```
4. **Check connection first**:
```bash
unity-mcp status
```
5. **When in doubt about properties**, use info commands:
```bash
unity-mcp material info "Assets/Materials/Mat.mat"
unity-mcp asset info "Assets/Prefabs/Player.prefab"
```
+3
View File
@@ -0,0 +1,3 @@
"""Unity MCP Command Line Interface."""
__version__ = "1.0.0"
+3
View File
@@ -0,0 +1,3 @@
"""CLI command modules."""
# Commands will be registered in main.py
+933
View File
@@ -0,0 +1,933 @@
"""Animation CLI commands - control Animator and manage AnimationClips."""
import json
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_list_or_exit, parse_json_dict_or_exit, parse_value_safe
from cli.utils.constants import SEARCH_METHOD_CHOICE_BASIC
_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "clipPath", "controllerPath", "properties"}
def _normalize_params(params: dict[str, Any]) -> dict[str, Any]:
params = dict(params)
properties: dict[str, Any] = {}
for key in list(params.keys()):
if key in _TOP_LEVEL_KEYS:
continue
properties[key] = params.pop(key)
if properties:
existing = params.get("properties")
if isinstance(existing, dict):
params["properties"] = {**properties, **existing}
else:
params["properties"] = properties
return {k: v for k, v in params.items() if v is not None}
@click.group()
def animation():
"""Animation operations - control Animator, manage AnimationClips."""
pass
# =============================================================================
# Animator Commands
# =============================================================================
@animation.group()
def animator():
"""Animator component operations."""
pass
@animator.command("info")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_info(target: str, search_method: Optional[str]):
"""Get Animator state, parameters, clips, and layers.
\b
Examples:
unity-mcp animation animator info "Player"
unity-mcp animation animator info "-12345" --search-method by_id
"""
config = get_config()
params: dict[str, Any] = {"action": "animator_get_info", "target": target}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@animator.command("play")
@click.argument("target")
@click.argument("state_name")
@click.option("--layer", "-l", default=-1, type=int, help="Animator layer index (-1 for default).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_play(target: str, state_name: str, layer: int, search_method: Optional[str]):
"""Play an animation state on a target's Animator.
\b
Examples:
unity-mcp animation animator play "Player" "Walk"
unity-mcp animation animator play "Enemy" "Attack" --layer 1
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_play",
"target": target,
"stateName": state_name,
"layer": layer,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Playing state '{state_name}' on {target}")
@animator.command("crossfade")
@click.argument("target")
@click.argument("state_name")
@click.option("--duration", "-d", default=0.25, type=float, help="Crossfade duration in seconds.")
@click.option("--layer", "-l", default=-1, type=int, help="Animator layer index (-1 for default).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_crossfade(target: str, state_name: str, duration: float, layer: int, search_method: Optional[str]):
"""Crossfade to an animation state.
\b
Examples:
unity-mcp animation animator crossfade "Player" "Run" --duration 0.5
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_crossfade",
"target": target,
"stateName": state_name,
"duration": duration,
"layer": layer,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@animator.command("set-parameter")
@click.argument("target")
@click.argument("param_name")
@click.argument("value")
@click.option(
"--type", "-t", "param_type",
type=click.Choice(["float", "int", "bool", "trigger"]),
default=None,
help="Parameter type (auto-detected if omitted)."
)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_set_parameter(target: str, param_name: str, value: str, param_type: Optional[str], search_method: Optional[str]):
"""Set an Animator parameter.
\b
Examples:
unity-mcp animation animator set-parameter "Player" "Speed" 5.0
unity-mcp animation animator set-parameter "Player" "IsRunning" true --type bool
unity-mcp animation animator set-parameter "Player" "Jump" "" --type trigger
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_set_parameter",
"target": target,
"parameterName": param_name,
"value": parse_value_safe(value),
}
if param_type:
params["parameterType"] = param_type
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@animator.command("get-parameter")
@click.argument("target")
@click.argument("param_name")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_get_parameter(target: str, param_name: str, search_method: Optional[str]):
"""Get the current value of an Animator parameter.
\b
Examples:
unity-mcp animation animator get-parameter "Player" "Speed"
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_get_parameter",
"target": target,
"parameterName": param_name,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@animator.command("set-speed")
@click.argument("target")
@click.argument("speed", type=float)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_set_speed(target: str, speed: float, search_method: Optional[str]):
"""Set Animator playback speed.
\b
Examples:
unity-mcp animation animator set-speed "Player" 2.0
unity-mcp animation animator set-speed "Player" 0 # pause
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_set_speed",
"target": target,
"speed": speed,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@animator.command("set-enabled")
@click.argument("target")
@click.argument("enabled", type=bool)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animator_set_enabled(target: str, enabled: bool, search_method: Optional[str]):
"""Enable or disable an Animator component.
\b
Examples:
unity-mcp animation animator set-enabled "Player" true
unity-mcp animation animator set-enabled "Player" false
"""
config = get_config()
params: dict[str, Any] = {
"action": "animator_set_enabled",
"target": target,
"enabled": enabled,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
# =============================================================================
# AnimationClip Commands
# =============================================================================
@animation.group()
def clip():
"""AnimationClip operations."""
pass
@clip.command("create")
@click.argument("clip_path")
@click.option("--name", default=None, help="Clip name (defaults to filename).")
@click.option("--length", "-l", default=1.0, type=float, help="Clip length in seconds.")
@click.option("--loop/--no-loop", default=False, help="Whether clip loops.")
@click.option("--frame-rate", default=60.0, type=float, help="Frame rate.")
@handle_unity_errors
def clip_create(clip_path: str, name: Optional[str], length: float, loop: bool, frame_rate: float):
"""Create a new AnimationClip asset.
\b
Examples:
unity-mcp animation clip create "Assets/Animations/Bounce.anim" --length 2.0 --loop
unity-mcp animation clip create "Assets/Anim/Walk.anim" --frame-rate 30
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_create",
"clipPath": clip_path,
"length": length,
"loop": loop,
"frameRate": frame_rate,
}
if name:
params["name"] = name
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created clip at {clip_path}")
@clip.command("info")
@click.argument("clip_path")
@handle_unity_errors
def clip_info(clip_path: str):
"""Get AnimationClip info (curves, length, events).
\b
Examples:
unity-mcp animation clip info "Assets/Animations/Walk.anim"
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_get_info",
"clipPath": clip_path,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@clip.command("add-curve")
@click.argument("clip_path")
@click.option("--property", "-p", "property_path", required=True, help="Property path (e.g. 'localPosition.x').")
@click.option("--type", "-t", "component_type", default="Transform", help="Component type name.")
@click.option("--keys", "-k", required=True, help='Keyframes as JSON: [[0,0],[0.5,1],[1,0]] or [{"time":0,"value":0},...]')
@handle_unity_errors
def clip_add_curve(clip_path: str, property_path: str, component_type: str, keys: str):
"""Add a keyframe curve to an AnimationClip.
\b
Examples:
unity-mcp animation clip add-curve "Assets/Anim/Bounce.anim" \\
--property "localPosition.y" --type Transform \\
--keys "[[0,0],[0.5,2],[1,0]]"
"""
config = get_config()
keys_parsed = parse_json_list_or_exit(keys, "keys")
params: dict[str, Any] = {
"action": "clip_add_curve",
"clipPath": clip_path,
"propertyPath": property_path,
"type": component_type,
"keys": keys_parsed,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@clip.command("set-curve")
@click.argument("clip_path")
@click.option("--property", "-p", "property_path", required=True, help="Property path (e.g. 'localPosition.x').")
@click.option("--type", "-t", "component_type", default="Transform", help="Component type name.")
@click.option("--keys", "-k", required=True, help='Keyframes as JSON: [[0,0],[0.5,1],[1,0]]')
@handle_unity_errors
def clip_set_curve(clip_path: str, property_path: str, component_type: str, keys: str):
"""Replace all keyframes on a curve in an AnimationClip.
\b
Examples:
unity-mcp animation clip set-curve "Assets/Anim/Bounce.anim" \\
--property "localPosition.y" --type Transform \\
--keys "[[0,0],[1,3]]"
"""
config = get_config()
keys_parsed = parse_json_list_or_exit(keys, "keys")
params: dict[str, Any] = {
"action": "clip_set_curve",
"clipPath": clip_path,
"propertyPath": property_path,
"type": component_type,
"keys": keys_parsed,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@clip.command("set-vector-curve")
@click.argument("clip_path")
@click.option("--property", "-p", "vector_property", required=True, help="Property group (e.g. 'localPosition', 'localEulerAngles', 'localScale').")
@click.option("--type", "-t", "component_type", default="Transform", help="Component type name.")
@click.option("--keys", "-k", required=True, help='Vector3 keyframes as JSON: [{"time":0,"value":[0,1,0]},...]')
@handle_unity_errors
def clip_set_vector_curve(clip_path: str, vector_property: str, component_type: str, keys: str):
"""Set 3 curves (x/y/z) from Vector3 keyframes in one call.
\b
Examples:
unity-mcp animation clip set-vector-curve "Assets/Anim/Move.anim" \\
--property "localPosition" \\
--keys '[{"time":0,"value":[0,1,-10]},{"time":1,"value":[2,1,-10]}]'
"""
config = get_config()
keys_parsed = parse_json_list_or_exit(keys, "keys")
params: dict[str, Any] = {
"action": "clip_set_vector_curve",
"clipPath": clip_path,
"property": vector_property,
"type": component_type,
"keys": keys_parsed,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@clip.command("create-preset")
@click.argument("clip_path")
@click.argument("preset", type=click.Choice(["bounce", "rotate", "pulse", "fade", "shake", "hover", "spin", "sway", "bob", "wiggle", "blink", "slide_in", "elastic"]))
@click.option("--duration", "-d", default=1.0, type=float, help="Duration in seconds.")
@click.option("--amplitude", "-a", default=1.0, type=float, help="Amplitude/intensity multiplier.")
@click.option("--loop/--no-loop", default=True, help="Whether clip loops.")
@handle_unity_errors
def clip_create_preset(clip_path: str, preset: str, duration: float, amplitude: float, loop: bool):
"""Create an AnimationClip from a named preset.
\b
Presets: bounce, rotate, pulse, fade, shake, hover, spin, sway, bob, wiggle, blink, slide_in, elastic
\b
Examples:
unity-mcp animation clip create-preset "Assets/Anim/Bounce.anim" bounce --duration 2.0
unity-mcp animation clip create-preset "Assets/Anim/Spin.anim" spin --amplitude 2 --no-loop
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_create_preset",
"clipPath": clip_path,
"preset": preset,
"duration": duration,
"amplitude": amplitude,
"loop": loop,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created '{preset}' preset at {clip_path}")
@clip.command("assign")
@click.argument("target")
@click.argument("clip_path")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def clip_assign(target: str, clip_path: str, search_method: Optional[str]):
"""Assign an AnimationClip to a GameObject.
Adds an Animation component if the GameObject has no Animator or Animation.
\b
Examples:
unity-mcp animation clip assign "Cube" "Assets/Animations/Bounce.anim"
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_assign",
"target": target,
"clipPath": clip_path,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@clip.command("add-event")
@click.argument("clip_path")
@click.option("--function", "function_name", required=True, help="Function name to call.")
@click.option("--time", type=float, required=True, help="Time in seconds.")
@click.option("--string-param", default="", help="String parameter to pass.")
@click.option("--float-param", type=float, default=0.0, help="Float parameter to pass.")
@click.option("--int-param", type=int, default=0, help="Int parameter to pass.")
@handle_unity_errors
def clip_add_event(clip_path: str, function_name: str, time: float, string_param: str, float_param: float, int_param: int):
"""Add an animation event to a clip.
\b
Examples:
unity-mcp animation clip add-event "Assets/Anim/Attack.anim" \\
--function "OnAttackHit" --time 0.5
unity-mcp animation clip add-event "Assets/Anim/Footstep.anim" \\
--function "PlaySound" --time 0.3 --string-param "footstep"
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_add_event",
"clipPath": clip_path,
"functionName": function_name,
"time": time,
"stringParameter": string_param,
"floatParameter": float_param,
"intParameter": int_param,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Added event '{function_name}' at time {time}")
@clip.command("remove-event")
@click.argument("clip_path")
@click.option("--event-index", type=int, default=None, help="Index of event to remove.")
@click.option("--function", "function_name", default=None, help="Remove events by function name.")
@click.option("--time", type=float, default=None, help="Filter by time when removing by function name.")
@handle_unity_errors
def clip_remove_event(clip_path: str, event_index: Optional[int], function_name: Optional[str], time: Optional[float]):
"""Remove animation event(s) from a clip.
\b
Examples:
unity-mcp animation clip remove-event "Assets/Anim/Attack.anim" --event-index 0
unity-mcp animation clip remove-event "Assets/Anim/Attack.anim" --function "OnAttackHit"
unity-mcp animation clip remove-event "Assets/Anim/Attack.anim" --function "OnAttackHit" --time 0.5
"""
config = get_config()
params: dict[str, Any] = {
"action": "clip_remove_event",
"clipPath": clip_path,
}
if event_index is not None:
params["eventIndex"] = event_index
if function_name:
params["functionName"] = function_name
if time is not None:
params["time"] = time
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Event(s) removed")
# =============================================================================
# AnimatorController Commands
# =============================================================================
@animation.group()
def controller():
"""AnimatorController operations."""
pass
@controller.command("create")
@click.argument("controller_path")
@handle_unity_errors
def controller_create(controller_path: str):
"""Create a new AnimatorController asset.
\b
Examples:
unity-mcp animation controller create "Assets/Animations/Player.controller"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_create",
"controllerPath": controller_path,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created controller at {controller_path}")
@controller.command("add-state")
@click.argument("controller_path")
@click.argument("state_name")
@click.option("--clip-path", default=None, help="AnimationClip to assign as motion.")
@click.option("--speed", default=1.0, type=float, help="State playback speed.")
@click.option("--is-default/--no-default", default=False, help="Set as default state.")
@click.option("--layer-index", default=0, type=int, help="Layer index.")
@handle_unity_errors
def controller_add_state(controller_path: str, state_name: str, clip_path: Optional[str], speed: float, is_default: bool, layer_index: int):
"""Add a state to an AnimatorController.
\b
Examples:
unity-mcp animation controller add-state "Assets/Anim/Player.controller" "Walk" \\
--clip-path "Assets/Anim/Walk.anim"
unity-mcp animation controller add-state "Assets/Anim/Player.controller" "Idle" --is-default
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_add_state",
"controllerPath": controller_path,
"stateName": state_name,
"speed": speed,
"isDefault": is_default,
"layerIndex": layer_index,
}
if clip_path:
params["clipPath"] = clip_path
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@controller.command("add-transition")
@click.argument("controller_path")
@click.argument("from_state")
@click.argument("to_state")
@click.option("--has-exit-time/--no-exit-time", default=True, help="Whether transition uses exit time.")
@click.option("--duration", "-d", default=0.25, type=float, help="Transition duration.")
@click.option("--conditions", "-c", default=None, help='Conditions as JSON: [{"parameter":"Speed","mode":"greater","threshold":0.1}]')
@click.option("--layer-index", default=0, type=int, help="Layer index.")
@handle_unity_errors
def controller_add_transition(controller_path: str, from_state: str, to_state: str, has_exit_time: bool, duration: float, conditions: Optional[str], layer_index: int):
"""Add a transition between states in an AnimatorController.
\b
Examples:
unity-mcp animation controller add-transition "Assets/Anim/Player.controller" "Idle" "Walk" \\
--no-exit-time --duration 0.25 \\
--conditions '[{"parameter":"Speed","mode":"greater","threshold":0.1}]'
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_add_transition",
"controllerPath": controller_path,
"fromState": from_state,
"toState": to_state,
"hasExitTime": has_exit_time,
"duration": duration,
"layerIndex": layer_index,
}
if conditions:
params["conditions"] = parse_json_list_or_exit(conditions, "conditions")
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@controller.command("add-parameter")
@click.argument("controller_path")
@click.argument("param_name")
@click.option(
"--type", "-t", "param_type",
type=click.Choice(["float", "int", "bool", "trigger"]),
default="float",
help="Parameter type.",
)
@click.option("--default-value", default=None, help="Default value for the parameter.")
@handle_unity_errors
def controller_add_parameter(controller_path: str, param_name: str, param_type: str, default_value: Optional[str]):
"""Add a parameter to an AnimatorController.
\b
Examples:
unity-mcp animation controller add-parameter "Assets/Anim/Player.controller" "Speed" --type float --default-value 0.0
unity-mcp animation controller add-parameter "Assets/Anim/Player.controller" "Jump" --type trigger
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_add_parameter",
"controllerPath": controller_path,
"parameterName": param_name,
"parameterType": param_type,
}
if default_value is not None:
params["defaultValue"] = parse_value_safe(default_value)
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@controller.command("info")
@click.argument("controller_path")
@handle_unity_errors
def controller_info(controller_path: str):
"""Get AnimatorController info (states, transitions, parameters).
\b
Examples:
unity-mcp animation controller info "Assets/Animations/Player.controller"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_get_info",
"controllerPath": controller_path,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
@controller.command("assign")
@click.argument("controller_path")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def controller_assign(controller_path: str, target: str, search_method: Optional[str]):
"""Assign an AnimatorController to a GameObject.
Adds an Animator component if needed.
\b
Examples:
unity-mcp animation controller assign "Assets/Animations/Player.controller" "Player"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_assign",
"controllerPath": controller_path,
"target": target,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Assigned controller to {target}")
@controller.command("add-layer")
@click.argument("controller_path")
@click.argument("layer_name")
@click.option("--weight", type=float, default=1.0, help="Layer weight (default: 1.0).")
@click.option("--blending-mode", type=click.Choice(["override", "additive"]), default="override", help="Blending mode.")
@handle_unity_errors
def controller_add_layer(controller_path: str, layer_name: str, weight: float, blending_mode: str):
"""Add a layer to an AnimatorController.
\b
Examples:
unity-mcp animation controller add-layer "Assets/Anim/Player.controller" "UpperBody" --weight 0.8
unity-mcp animation controller add-layer "Assets/Anim/Player.controller" "Effects" --blending-mode additive
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_add_layer",
"controllerPath": controller_path,
"layerName": layer_name,
"weight": weight,
"blendingMode": blending_mode,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Added layer '{layer_name}'")
@controller.command("remove-layer")
@click.argument("controller_path")
@click.option("--layer-index", type=int, default=None, help="Layer index to remove.")
@click.option("--layer-name", default=None, help="Layer name to remove.")
@handle_unity_errors
def controller_remove_layer(controller_path: str, layer_index: Optional[int], layer_name: Optional[str]):
"""Remove a layer from an AnimatorController.
\b
Examples:
unity-mcp animation controller remove-layer "Assets/Anim/Player.controller" --layer-index 1
unity-mcp animation controller remove-layer "Assets/Anim/Player.controller" --layer-name "UpperBody"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_remove_layer",
"controllerPath": controller_path,
}
if layer_index is not None:
params["layerIndex"] = layer_index
if layer_name:
params["layerName"] = layer_name
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Layer removed")
@controller.command("set-layer-weight")
@click.argument("controller_path")
@click.argument("weight", type=float)
@click.option("--layer-index", type=int, default=None, help="Layer index.")
@click.option("--layer-name", default=None, help="Layer name.")
@handle_unity_errors
def controller_set_layer_weight(controller_path: str, weight: float, layer_index: Optional[int], layer_name: Optional[str]):
"""Set the weight of a layer in an AnimatorController.
\b
Examples:
unity-mcp animation controller set-layer-weight "Assets/Anim/Player.controller" 0.5 --layer-index 1
unity-mcp animation controller set-layer-weight "Assets/Anim/Player.controller" 0.8 --layer-name "UpperBody"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_set_layer_weight",
"controllerPath": controller_path,
"weight": weight,
}
if layer_index is not None:
params["layerIndex"] = layer_index
if layer_name:
params["layerName"] = layer_name
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set layer weight to {weight}")
@controller.command("create-blend-tree-1d")
@click.argument("controller_path")
@click.argument("state_name")
@click.option("--blend-param", required=True, help="Blend parameter name.")
@click.option("--layer-index", type=int, default=0, help="Layer index.")
@handle_unity_errors
def controller_create_blend_tree_1d(controller_path: str, state_name: str, blend_param: str, layer_index: int):
"""Create a 1D blend tree state in an AnimatorController.
\b
Examples:
unity-mcp animation controller create-blend-tree-1d "Assets/Anim/Player.controller" "Locomotion" --blend-param "Speed"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_create_blend_tree_1d",
"controllerPath": controller_path,
"stateName": state_name,
"blendParameter": blend_param,
"layerIndex": layer_index,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created 1D blend tree state '{state_name}'")
@controller.command("create-blend-tree-2d")
@click.argument("controller_path")
@click.argument("state_name")
@click.option("--blend-param-x", required=True, help="X-axis blend parameter name.")
@click.option("--blend-param-y", required=True, help="Y-axis blend parameter name.")
@click.option("--blend-type", type=click.Choice(["simpledirectional2d", "freeformdirectional2d", "freeformcartesian2d"]), default="simpledirectional2d", help="Blend tree type.")
@click.option("--layer-index", type=int, default=0, help="Layer index.")
@handle_unity_errors
def controller_create_blend_tree_2d(controller_path: str, state_name: str, blend_param_x: str, blend_param_y: str, blend_type: str, layer_index: int):
"""Create a 2D blend tree state in an AnimatorController.
\b
Examples:
unity-mcp animation controller create-blend-tree-2d "Assets/Anim/Player.controller" "Movement" \\
--blend-param-x "VelocityX" --blend-param-y "VelocityZ"
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_create_blend_tree_2d",
"controllerPath": controller_path,
"stateName": state_name,
"blendParameterX": blend_param_x,
"blendParameterY": blend_param_y,
"blendType": blend_type,
"layerIndex": layer_index,
}
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created 2D blend tree state '{state_name}'")
@controller.command("add-blend-tree-child")
@click.argument("controller_path")
@click.argument("state_name")
@click.option("--clip-path", required=True, help="AnimationClip path.")
@click.option("--threshold", type=float, default=None, help="Threshold for 1D blend tree.")
@click.option("--position", type=(float, float), default=None, help="Position (x, y) for 2D blend tree.")
@click.option("--layer-index", type=int, default=0, help="Layer index.")
@handle_unity_errors
def controller_add_blend_tree_child(controller_path: str, state_name: str, clip_path: str, threshold: Optional[float], position: Optional[tuple], layer_index: int):
"""Add a child motion to a blend tree.
\b
Examples:
unity-mcp animation controller add-blend-tree-child "Assets/Anim/Player.controller" "Locomotion" \\
--clip-path "Assets/Anim/Walk.anim" --threshold 1.0
unity-mcp animation controller add-blend-tree-child "Assets/Anim/Player.controller" "Movement" \\
--clip-path "Assets/Anim/WalkForward.anim" --position 0 1
"""
config = get_config()
params: dict[str, Any] = {
"action": "controller_add_blend_tree_child",
"controllerPath": controller_path,
"stateName": state_name,
"clipPath": clip_path,
"layerIndex": layer_index,
}
if threshold is not None:
params["threshold"] = threshold
if position is not None:
params["position"] = list(position)
result = run_command("manage_animation", _normalize_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Added blend tree child")
# =============================================================================
# Raw Command (escape hatch for all animation actions)
# =============================================================================
@animation.command("raw")
@click.argument("action")
@click.argument("target", required=False)
@click.option("--clip-path", default=None, help="AnimationClip asset path.")
@click.option("--params", "-p", "extra_params", default="{}", help="Additional parameters as JSON.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def animation_raw(action: str, target: Optional[str], clip_path: Optional[str], extra_params: str, search_method: Optional[str]):
"""Execute any animation action directly.
\b
Actions include:
animator_*: animator_get_info, animator_play, animator_crossfade, ...
controller_*: controller_create, controller_add_state, controller_add_transition, ...
clip_*: clip_create, clip_get_info, clip_add_curve, clip_set_curve, clip_set_vector_curve, clip_create_preset, clip_assign
\b
Examples:
unity-mcp animation raw animator_play "Player" --params '{"stateName": "Walk"}'
unity-mcp animation raw clip_create --clip-path "Assets/Anim/Test.anim" --params '{"length": 2.0, "loop": true}'
"""
config = get_config()
parsed = parse_json_dict_or_exit(extra_params, "params")
request_params: dict[str, Any] = {"action": action}
if target:
request_params["target"] = target
if clip_path:
request_params["clipPath"] = clip_path
if search_method:
request_params["searchMethod"] = search_method
request_params.update(parsed)
result = run_command("manage_animation", _normalize_params(request_params), config)
click.echo(format_output(result, config.format))
+280
View File
@@ -0,0 +1,280 @@
"""Asset CLI commands."""
import sys
import json
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_dict_or_exit
from cli.utils.confirmation import confirm_destructive_action
@click.group()
def asset():
"""Asset operations - search, import, create, delete assets."""
pass
@asset.command("search")
@click.argument("pattern", default="*")
@click.option(
"--path", "-p",
default="Assets",
help="Folder path to search in."
)
@click.option(
"--type", "-t",
"filter_type",
default=None,
help="Filter by asset type (e.g., Material, Prefab, MonoScript)."
)
@click.option(
"--limit", "-l",
default=25,
type=int,
help="Maximum results per page."
)
@click.option(
"--page",
default=1,
type=int,
help="Page number (1-based)."
)
@handle_unity_errors
def search(pattern: str, path: str, filter_type: Optional[str], limit: int, page: int):
"""Search for assets.
\b
Examples:
unity-mcp asset search "*.prefab"
unity-mcp asset search "Player*" --path "Assets/Characters"
unity-mcp asset search "*" --type Material
unity-mcp asset search "t:MonoScript" --path "Assets/Scripts"
"""
config = get_config()
params: dict[str, Any] = {
"action": "search",
"path": path,
"searchPattern": pattern,
"pageSize": limit,
"pageNumber": page,
}
if filter_type:
params["filterType"] = filter_type
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
@asset.command("info")
@click.argument("path")
@click.option(
"--preview",
is_flag=True,
help="Generate preview thumbnail (may be large)."
)
@handle_unity_errors
def info(path: str, preview: bool):
"""Get detailed information about an asset.
\b
Examples:
unity-mcp asset info "Assets/Materials/Red.mat"
unity-mcp asset info "Assets/Prefabs/Player.prefab" --preview
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_info",
"path": path,
"generatePreview": preview,
}
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
@asset.command("create")
@click.argument("path")
@click.argument("asset_type")
@click.option(
"--properties", "-p",
default=None,
help='Initial properties as JSON.'
)
@handle_unity_errors
def create(path: str, asset_type: str, properties: Optional[str]):
"""Create a new asset.
\b
Examples:
unity-mcp asset create "Assets/Materials/Blue.mat" Material
unity-mcp asset create "Assets/NewFolder" Folder
unity-mcp asset create "Assets/Materials/Custom.mat" Material --properties '{"color": [0,0,1,1]}'
"""
config = get_config()
params: dict[str, Any] = {
"action": "create",
"path": path,
"assetType": asset_type,
}
if properties:
params["properties"] = parse_json_dict_or_exit(properties, "properties")
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created {asset_type}: {path}")
@asset.command("delete")
@click.argument("path")
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@handle_unity_errors
def delete(path: str, force: bool):
"""Delete an asset.
\b
Examples:
unity-mcp asset delete "Assets/OldMaterial.mat"
unity-mcp asset delete "Assets/Unused" --force
"""
config = get_config()
confirm_destructive_action("Delete", "asset", path, force)
result = run_command(
"manage_asset", {"action": "delete", "path": path}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Deleted: {path}")
@asset.command("duplicate")
@click.argument("source")
@click.argument("destination")
@handle_unity_errors
def duplicate(source: str, destination: str):
"""Duplicate an asset.
\b
Examples:
unity-mcp asset duplicate "Assets/Materials/Red.mat" "Assets/Materials/RedCopy.mat"
"""
config = get_config()
params: dict[str, Any] = {
"action": "duplicate",
"path": source,
"destination": destination,
}
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Duplicated to: {destination}")
@asset.command("move")
@click.argument("source")
@click.argument("destination")
@handle_unity_errors
def move(source: str, destination: str):
"""Move an asset to a new location.
\b
Examples:
unity-mcp asset move "Assets/Old/Material.mat" "Assets/New/Material.mat"
"""
config = get_config()
params: dict[str, Any] = {
"action": "move",
"path": source,
"destination": destination,
}
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Moved to: {destination}")
@asset.command("rename")
@click.argument("path")
@click.argument("new_name")
@handle_unity_errors
def rename(path: str, new_name: str):
"""Rename an asset.
\b
Examples:
unity-mcp asset rename "Assets/Materials/Old.mat" "New.mat"
"""
config = get_config()
# Construct destination path
import os
dir_path = os.path.dirname(path)
destination = os.path.join(dir_path, new_name).replace("\\", "/")
params: dict[str, Any] = {
"action": "rename",
"path": path,
"destination": destination,
}
result = run_command("manage_asset", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Renamed to: {new_name}")
@asset.command("import")
@click.argument("path")
@handle_unity_errors
def import_asset(path: str):
"""Import/reimport an asset.
\b
Examples:
unity-mcp asset import "Assets/Textures/NewTexture.png"
"""
config = get_config()
result = run_command(
"manage_asset", {"action": "import", "path": path}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Imported: {path}")
@asset.command("mkdir")
@click.argument("path")
@handle_unity_errors
def mkdir(path: str):
"""Create a folder.
\b
Examples:
unity-mcp asset mkdir "Assets/NewFolder"
unity-mcp asset mkdir "Assets/Levels/Chapter1"
"""
config = get_config()
result = run_command(
"manage_asset", {"action": "create_folder", "path": path}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created folder: {path}")
+212
View File
@@ -0,0 +1,212 @@
"""AI asset generation CLI commands (3D model gen/import, 2D image gen).
Thin pass-through to Unity over HTTP: these commands carry NO API keys and NO
file bytes. The C# side reads provider keys from the OS secure store, performs
the provider call, and imports the result.
"""
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_info
from cli.utils.connection import run_command, handle_unity_errors
@click.group(name="asset-gen")
def asset_gen():
"""AI asset generation - generate 3D models, import marketplace models, generate images."""
pass
def _emit(result, config, verb):
"""Echo the command result, then (on success with a job_id) print the status-poll hint."""
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"{verb} started. Poll with: unity-mcp asset-gen status --job-id {job_id}")
@asset_gen.command("generate-model")
@click.option("--provider", default=None, help="Provider id (tripo, meshy).")
@click.option("--mode", default=None, help="Generation mode: text or image.")
@click.option("--prompt", default=None, help="Text prompt for text->3D.")
@click.option("--image-path", default=None, help="Source image path for image->3D.")
@click.option("--image-url", default=None, help="Source image URL for image->3D.")
@click.option("--format", "fmt", default=None, help="Output format: glb, fbx, obj, usdz.")
@click.option("--target-size", default=None, type=float, help="Normalize largest dimension (meters).")
@click.option("--texture/--no-texture", "texture", default=None, help="Generate textures.")
@click.option("--tier", default=None, help="Provider quality/cost tier.")
@click.option("--name", default=None, help="Base name for the imported asset.")
@click.option("--output-folder", default=None, help="Destination folder under Assets/.")
@handle_unity_errors
def generate_model(
provider: Optional[str],
mode: Optional[str],
prompt: Optional[str],
image_path: Optional[str],
image_url: Optional[str],
fmt: Optional[str],
target_size: Optional[float],
texture: Optional[bool],
tier: Optional[str],
name: Optional[str],
output_folder: Optional[str],
):
"""Generate a 3D model with an AI provider.
\b
Examples:
unity-mcp asset-gen generate-model --provider tripo --mode text --prompt "a red chair"
unity-mcp asset-gen generate-model --provider meshy --mode image --image-path Assets/ref.png
"""
config = get_config()
params: dict[str, Any] = {"action": "generate"}
optional = {
"provider": provider,
"mode": mode,
"prompt": prompt,
"imagePath": image_path,
"imageUrl": image_url,
"format": fmt,
"targetSize": target_size,
"texture": texture,
"tier": tier,
"name": name,
"outputFolder": output_folder,
}
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("generate_model", params, config)
_emit(result, config, "Generation")
@asset_gen.command("import-model")
@click.option("--uid", required=True, help="Sketchfab model uid to import.")
@click.option("--target-size", default=None, type=float, help="Normalize largest dimension (meters).")
@click.option("--name", default=None, help="Base name for the imported asset.")
@click.option("--output-folder", default=None, help="Destination folder under Assets/.")
@handle_unity_errors
def import_model(
uid: str,
target_size: Optional[float],
name: Optional[str],
output_folder: Optional[str],
):
"""Import a 3D model from the Sketchfab marketplace by uid.
\b
Examples:
unity-mcp asset-gen import-model --uid abc123
unity-mcp asset-gen import-model --uid abc123 --name MyProp --output-folder Assets/Props
"""
config = get_config()
params: dict[str, Any] = {"action": "import", "uid": uid}
optional = {
"targetSize": target_size,
"name": name,
"outputFolder": output_folder,
}
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("import_model", params, config)
_emit(result, config, "Import")
@asset_gen.command("import-model-file")
@click.option("--source-path", "source_path", required=True,
help="Path to a local model file (.fbx/.obj/.glb/.gltf/.zip).")
@click.option("--name", default=None, help="Base name for the imported asset.")
@click.option("--output-folder", default=None, help="Destination folder under Assets/.")
@click.option("--target-size", default=None, type=float, help="Normalize largest dimension (meters).")
@click.option("--animation-type", "animation_type", default=None,
type=click.Choice(["none", "generic", "humanoid", "legacy"]),
help="FBX/OBJ rig mode: generic/humanoid surface animation clips; "
"legacy selects Unity's legacy Animation system (glTF ignores this).")
@handle_unity_errors
def import_model_file(source_path, name, output_folder, target_size, animation_type):
"""Import a local 3D model file (e.g. a Blender export) into the Unity project."""
config = get_config()
params = {
"sourcePath": source_path,
"name": name,
"outputFolder": output_folder,
"targetSize": target_size,
"animationType": animation_type,
}
params = {k: v for k, v in params.items() if v is not None}
result = run_command("import_model_file", params, config)
click.echo(format_output(result, config.format))
@asset_gen.command("generate-image")
@click.option("--provider", default=None, help="Provider id (fal, openrouter).")
@click.option("--mode", default=None, help="Generation mode: text or image.")
@click.option("--prompt", default=None, help="Text prompt for text->image.")
@click.option("--image-path", default=None, help="Source image path for image->image.")
@click.option("--image-url", default=None, help="Source image URL for image->image.")
@click.option("--model", default=None, help="Provider model id/slug.")
@click.option("--transparent/--no-transparent", "transparent", default=None, help="Request transparency.")
@click.option("--width", default=None, type=int, help="Output width in pixels.")
@click.option("--height", default=None, type=int, help="Output height in pixels.")
@click.option("--name", default=None, help="Base name for the imported asset.")
@click.option("--output-folder", default=None, help="Destination folder under Assets/.")
@handle_unity_errors
def generate_image(
provider: Optional[str],
mode: Optional[str],
prompt: Optional[str],
image_path: Optional[str],
image_url: Optional[str],
model: Optional[str],
transparent: Optional[bool],
width: Optional[int],
height: Optional[int],
name: Optional[str],
output_folder: Optional[str],
):
"""Generate a 2D image with an AI provider.
\b
Examples:
unity-mcp asset-gen generate-image --provider fal --prompt "a stone texture"
unity-mcp asset-gen generate-image --provider openrouter --prompt "logo" --transparent
"""
config = get_config()
params: dict[str, Any] = {"action": "generate"}
optional = {
"provider": provider,
"mode": mode,
"prompt": prompt,
"imagePath": image_path,
"imageUrl": image_url,
"model": model,
"transparent": transparent,
"width": width,
"height": height,
"name": name,
"outputFolder": output_folder,
}
params.update({k: v for k, v in optional.items() if v is not None})
result = run_command("generate_image", params, config)
_emit(result, config, "Generation")
@asset_gen.command("status")
@click.option("--job-id", "job_id", required=True, help="Job id returned by a generate/import command.")
@handle_unity_errors
def status(job_id: str):
"""Check the status of an asset generation/import job.
\b
Examples:
unity-mcp asset-gen status --job-id abc123
"""
config = get_config()
result = run_command("generate_model", {"action": "status", "jobId": job_id}, config)
click.echo(format_output(result, config.format))
+125
View File
@@ -0,0 +1,125 @@
"""Audio CLI commands - placeholder for future implementation."""
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_info
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.constants import SEARCH_METHOD_CHOICE_BASIC
@click.group()
def audio():
"""Audio operations - AudioSource control, audio settings."""
pass
@audio.command("play")
@click.argument("target")
@click.option(
"--clip", "-c",
default=None,
help="Audio clip path to play."
)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target."
)
@handle_unity_errors
def play(target: str, clip: Optional[str], search_method: Optional[str]):
"""Play audio on a target's AudioSource.
\b
Examples:
unity-mcp audio play "MusicPlayer"
unity-mcp audio play "SFXSource" --clip "Assets/Audio/explosion.wav"
"""
config = get_config()
params: dict[str, Any] = {
"action": "set_property",
"target": target,
"componentType": "AudioSource",
"property": "Play",
"value": True,
}
if clip:
params["clip"] = clip
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
@audio.command("stop")
@click.argument("target")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target."
)
@handle_unity_errors
def stop(target: str, search_method: Optional[str]):
"""Stop audio on a target's AudioSource.
\b
Examples:
unity-mcp audio stop "MusicPlayer"
"""
config = get_config()
params: dict[str, Any] = {
"action": "set_property",
"target": target,
"componentType": "AudioSource",
"property": "Stop",
"value": True,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
@audio.command("volume")
@click.argument("target")
@click.argument("level", type=float)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target."
)
@handle_unity_errors
def volume(target: str, level: float, search_method: Optional[str]):
"""Set audio volume on a target's AudioSource.
\b
Examples:
unity-mcp audio volume "MusicPlayer" 0.5
"""
config = get_config()
params: dict[str, Any] = {
"action": "set_property",
"target": target,
"componentType": "AudioSource",
"property": "volume",
"value": level,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
+171
View File
@@ -0,0 +1,171 @@
"""Batch CLI commands for executing multiple Unity operations efficiently."""
import sys
import json
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_info
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_list_or_exit
@click.group()
def batch():
"""Batch operations - execute multiple commands efficiently."""
pass
@batch.command("run")
@click.argument("file", type=click.Path(exists=True))
@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.")
@click.option("--fail-fast", is_flag=True, help="Stop on first failure.")
@handle_unity_errors
def batch_run(file: str, parallel: bool, fail_fast: bool):
"""Execute commands from a JSON file.
The JSON file should contain an array of command objects with 'tool' and 'params' keys.
\\b
File format:
[
{"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube1"}},
{"tool": "manage_gameobject", "params": {"action": "create", "name": "Cube2"}},
{"tool": "manage_components", "params": {"action": "add", "target": "Cube1", "componentType": "Rigidbody"}}
]
\\b
Examples:
unity-mcp batch run commands.json
unity-mcp batch run setup.json --parallel
unity-mcp batch run critical.json --fail-fast
"""
config = get_config()
try:
with open(file, 'r') as f:
commands = json.load(f)
except json.JSONDecodeError as e:
print_error(f"Invalid JSON in file: {e}")
sys.exit(1)
except IOError as e:
print_error(f"Error reading file: {e}")
sys.exit(1)
if not isinstance(commands, list):
print_error("JSON file must contain an array of commands")
sys.exit(1)
if len(commands) > 40:
print_error(f"Maximum 40 commands per batch, got {len(commands)}")
sys.exit(1)
params: dict[str, Any] = {"commands": commands}
if parallel:
params["parallel"] = True
if fail_fast:
params["failFast"] = True
click.echo(f"Executing {len(commands)} commands...")
result = run_command("batch_execute", params, config)
click.echo(format_output(result, config.format))
if isinstance(result, dict):
results = result.get("data", {}).get("results", [])
succeeded = sum(1 for r in results if r.get("success"))
failed = len(results) - succeeded
if failed == 0:
print_success(
f"All {succeeded} commands completed successfully")
else:
print_info(f"{succeeded} succeeded, {failed} failed")
@batch.command("inline")
@click.argument("commands_json")
@click.option("--parallel", is_flag=True, help="Execute read-only commands in parallel.")
@click.option("--fail-fast", is_flag=True, help="Stop on first failure.")
@handle_unity_errors
def batch_inline(commands_json: str, parallel: bool, fail_fast: bool):
"""Execute commands from inline JSON.
\\b
Examples:
unity-mcp batch inline '[{"tool": "manage_scene", "params": {"action": "get_active"}}]'
unity-mcp batch inline '[
{"tool": "manage_gameobject", "params": {"action": "create", "name": "A", "primitiveType": "Cube"}},
{"tool": "manage_gameobject", "params": {"action": "create", "name": "B", "primitiveType": "Sphere"}}
]'
"""
config = get_config()
commands = parse_json_list_or_exit(commands_json, "commands")
if len(commands) > 40:
print_error(f"Maximum 40 commands per batch, got {len(commands)}")
sys.exit(1)
params: dict[str, Any] = {"commands": commands}
if parallel:
params["parallel"] = True
if fail_fast:
params["failFast"] = True
result = run_command("batch_execute", params, config)
click.echo(format_output(result, config.format))
@batch.command("template")
@click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)")
def batch_template(output: Optional[str]):
"""Generate a sample batch commands file.
\\b
Examples:
unity-mcp batch template > commands.json
unity-mcp batch template -o my_batch.json
"""
template = [
{
"tool": "manage_scene",
"params": {"action": "get_active"}
},
{
"tool": "manage_gameobject",
"params": {
"action": "create",
"name": "BatchCube",
"primitiveType": "Cube",
"position": [0, 1, 0]
}
},
{
"tool": "manage_components",
"params": {
"action": "add",
"target": "BatchCube",
"componentType": "Rigidbody"
}
},
{
"tool": "manage_gameobject",
"params": {
"action": "modify",
"target": "BatchCube",
"position": [0, 5, 0]
}
}
]
json_output = json.dumps(template, indent=2)
if output:
with open(output, 'w') as f:
f.write(json_output)
print_success(f"Template written to: {output}")
else:
click.echo(json_output)
+228
View File
@@ -0,0 +1,228 @@
"""Build management CLI commands."""
import click
from typing import Optional
from cli.utils.config import get_config
from cli.utils.output import format_output, print_info
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def build():
"""Build management - player builds, platforms, settings, batch."""
pass
@build.command("run")
@click.option("--target", "-t", help="Build target: windows64, osx, linux64, android, ios, webgl")
@click.option("--output", "-o", "output_path", help="Output path")
@click.option("--development", "-d", is_flag=True, help="Development build")
@click.option("--backend", "scripting_backend", type=click.Choice(["mono", "il2cpp"]), help="Scripting backend")
@click.option("--subtarget", type=click.Choice(["player", "server"]), help="Build subtarget")
@click.option("--profile", help="Build Profile asset path (Unity 6+)")
@click.option("--clean", is_flag=True, help="Clean build cache")
@click.option("--auto-run", is_flag=True, help="Auto-run after build")
@handle_unity_errors
def run_build(target, output_path, development, scripting_backend, subtarget, profile, clean, auto_run):
"""Trigger a player build.
\b
Examples:
unity-mcp build run --target windows64 --development
unity-mcp build run --target android --backend il2cpp
unity-mcp build run --profile "Assets/Settings/Build Profiles/iOS.asset"
"""
config = get_config()
params = {"action": "build"}
if target:
params["target"] = target
if output_path:
params["output_path"] = output_path
if development:
params["development"] = True
if scripting_backend:
params["scripting_backend"] = scripting_backend
if subtarget:
params["subtarget"] = subtarget
if profile:
params["profile"] = profile
options = []
if clean:
options.append("clean_build")
if auto_run:
options.append("auto_run")
if options:
params["options"] = options
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Build started. Poll with: unity-mcp build status {job_id}")
@build.command("status")
@click.argument("job_id", required=False)
@handle_unity_errors
def status(job_id: Optional[str]):
"""Check build status or get last build report.
\b
Examples:
unity-mcp build status
unity-mcp build status build-abc123
"""
config = get_config()
params = {"action": "status"}
if job_id:
params["job_id"] = job_id
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
@build.command("platform")
@click.argument("target", required=False)
@click.option("--subtarget", type=click.Choice(["player", "server"]), help="Build subtarget")
@handle_unity_errors
def platform(target: Optional[str], subtarget: Optional[str]):
"""Read or switch the active build platform.
\b
Examples:
unity-mcp build platform
unity-mcp build platform android
unity-mcp build platform windows64 --subtarget server
"""
config = get_config()
params = {"action": "platform"}
if target:
params["target"] = target
if subtarget:
params["subtarget"] = subtarget
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
@build.command("settings")
@click.argument("property_name")
@click.option("--value", "-v", help="Value to set. Omit to read.")
@click.option("--target", "-t", help="Build target for platform-specific settings")
@handle_unity_errors
def settings(property_name: str, value: Optional[str], target: Optional[str]):
"""Read or write player settings.
\b
Properties: product_name, company_name, version, bundle_id,
scripting_backend, defines, architecture
\b
Examples:
unity-mcp build settings product_name
unity-mcp build settings product_name --value "My Game"
unity-mcp build settings scripting_backend --value il2cpp --target android
"""
config = get_config()
params = {"action": "settings", "property": property_name}
if value:
params["value"] = value
if target:
params["target"] = target
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
@build.command("scenes")
@click.option("--set", "scene_paths", help="Comma-separated scene paths to set")
@handle_unity_errors
def scenes(scene_paths: Optional[str]):
"""Read or update the build scene list.
\b
Examples:
unity-mcp build scenes
unity-mcp build scenes --set "Assets/Scenes/Main.unity,Assets/Scenes/Level1.unity"
"""
config = get_config()
params = {"action": "scenes"}
if scene_paths:
scene_list = [
{"path": p.strip(), "enabled": True}
for p in scene_paths.split(",")
]
params["scenes"] = scene_list
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
@build.command("profiles")
@click.argument("profile", required=False)
@click.option("--activate", is_flag=True, help="Activate the specified profile")
@handle_unity_errors
def profiles_cmd(profile: Optional[str], activate: bool):
"""List, inspect, or activate build profiles (Unity 6+).
\b
Examples:
unity-mcp build profiles
unity-mcp build profiles "Assets/Settings/Build Profiles/iOS.asset"
unity-mcp build profiles "Assets/Settings/Build Profiles/iOS.asset" --activate
"""
config = get_config()
params = {"action": "profiles"}
if profile:
params["profile"] = profile
if activate:
params["activate"] = True
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
@build.command("batch")
@click.option("--targets", "-t", help="Comma-separated targets: windows64,linux64,webgl")
@click.option("--profiles", "-p", "profile_paths", help="Comma-separated profile paths (Unity 6+)")
@click.option("--output-dir", "-o", help="Base output directory")
@click.option("--development", "-d", is_flag=True, help="Development build for all")
@handle_unity_errors
def batch(targets, profile_paths, output_dir, development):
"""Run batch builds across multiple platforms or profiles.
\b
Examples:
unity-mcp build batch --targets windows64,linux64,webgl
unity-mcp build batch --profiles "Assets/Profiles/A.asset,Assets/Profiles/B.asset"
unity-mcp build batch --targets windows64,android --development
"""
config = get_config()
params = {"action": "batch"}
if targets:
params["targets"] = [t.strip() for t in targets.split(",")]
if profile_paths:
params["profiles"] = [p.strip() for p in profile_paths.split(",")]
if output_dir:
params["output_dir"] = output_dir
if development:
params["development"] = True
result = run_command("manage_build", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Batch started. Poll with: unity-mcp build status {job_id}")
@build.command("cancel")
@click.argument("job_id")
@handle_unity_errors
def cancel(job_id: str):
"""Cancel a build or batch job (best-effort).
\b
Examples:
unity-mcp build cancel batch-xyz789
"""
config = get_config()
result = run_command("manage_build", {"action": "cancel", "job_id": job_id}, config)
click.echo(format_output(result, config.format))
+554
View File
@@ -0,0 +1,554 @@
"""Camera CLI commands for managing Unity Camera + Cinemachine."""
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_dict_or_exit
from cli.utils.constants import SEARCH_METHOD_CHOICE_BASIC
_CAM_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"}
def _normalize_cam_params(params: dict[str, Any]) -> dict[str, Any]:
params = dict(params)
properties: dict[str, Any] = {}
for key in list(params.keys()):
if key in _CAM_TOP_LEVEL_KEYS:
continue
properties[key] = params.pop(key)
if properties:
existing = params.get("properties")
if isinstance(existing, dict):
params["properties"] = {**properties, **existing}
else:
params["properties"] = properties
return {k: v for k, v in params.items() if v is not None}
@click.group()
def camera():
"""Camera operations - create, configure, and control cameras."""
pass
# =============================================================================
# Setup
# =============================================================================
@camera.command("ping")
@handle_unity_errors
def ping():
"""Check if Cinemachine is available.
\b
Examples:
unity-mcp camera ping
"""
config = get_config()
result = run_command(config, "manage_camera", {"action": "ping"})
format_output(result, config)
@camera.command("list")
@handle_unity_errors
def list_cameras():
"""List all cameras in the scene.
\b
Examples:
unity-mcp camera list
"""
config = get_config()
result = run_command(config, "manage_camera", {"action": "list_cameras"})
format_output(result, config)
@camera.command("brain-status")
@handle_unity_errors
def brain_status():
"""Get CinemachineBrain status.
\b
Examples:
unity-mcp camera brain-status
"""
config = get_config()
result = run_command(config, "manage_camera", {"action": "get_brain_status"})
format_output(result, config)
# =============================================================================
# Creation
# =============================================================================
@camera.command("create")
@click.option("--name", "-n", default=None, help="Name for the camera GameObject.")
@click.option("--preset", "-p", default=None,
type=click.Choice(["follow", "third_person", "freelook", "dolly",
"static", "top_down", "side_scroller"]),
help="Camera preset (Cinemachine only).")
@click.option("--follow", default=None, help="Follow target (name/path/ID).")
@click.option("--look-at", default=None, help="LookAt target (name/path/ID).")
@click.option("--priority", type=int, default=None, help="Camera priority.")
@click.option("--fov", type=float, default=None, help="Field of view.")
@handle_unity_errors
def create(name, preset, follow, look_at, priority, fov):
"""Create a new camera.
\b
Examples:
unity-mcp camera create --name "FollowCam" --preset third_person --follow Player
unity-mcp camera create --name "MainCam" --fov 50
"""
config = get_config()
props: dict[str, Any] = {}
if name:
props["name"] = name
if preset:
props["preset"] = preset
if follow:
props["follow"] = follow
if look_at:
props["lookAt"] = look_at
if priority is not None:
props["priority"] = priority
if fov is not None:
props["fieldOfView"] = fov
params: dict[str, Any] = {"action": "create_camera"}
if props:
params["properties"] = props
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("ensure-brain")
@click.option("--camera-ref", default=None, help="Camera to add Brain to (name/path/ID).")
@click.option("--blend-style", default=None, help="Default blend style.")
@click.option("--blend-duration", type=float, default=None, help="Default blend duration.")
@handle_unity_errors
def ensure_brain(camera_ref, blend_style, blend_duration):
"""Ensure CinemachineBrain exists on main camera.
\b
Examples:
unity-mcp camera ensure-brain
unity-mcp camera ensure-brain --blend-style EaseInOut --blend-duration 2.0
"""
config = get_config()
props: dict[str, Any] = {}
if camera_ref:
props["camera"] = camera_ref
if blend_style:
props["defaultBlendStyle"] = blend_style
if blend_duration is not None:
props["defaultBlendDuration"] = blend_duration
params: dict[str, Any] = {"action": "ensure_brain"}
if props:
params["properties"] = props
result = run_command(config, "manage_camera", params)
format_output(result, config)
# =============================================================================
# Configuration
# =============================================================================
@camera.command("set-target")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--follow", default=None, help="Follow target (name/path/ID).")
@click.option("--look-at", default=None, help="LookAt target (name/path/ID).")
@handle_unity_errors
def set_target(target, search_method, follow, look_at):
"""Set camera Follow/LookAt targets.
\b
Examples:
unity-mcp camera set-target "CM Camera" --follow Player --look-at Player
"""
config = get_config()
props: dict[str, Any] = {}
if follow:
props["follow"] = follow
if look_at:
props["lookAt"] = look_at
params = _normalize_cam_params({
"action": "set_target",
"target": target,
"searchMethod": search_method,
"properties": props if props else None,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("set-lens")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--fov", type=float, default=None, help="Field of view.")
@click.option("--near", type=float, default=None, help="Near clip plane.")
@click.option("--far", type=float, default=None, help="Far clip plane.")
@click.option("--ortho-size", type=float, default=None, help="Orthographic size.")
@click.option("--dutch", type=float, default=None, help="Dutch angle (Cinemachine).")
@handle_unity_errors
def set_lens(target, search_method, fov, near, far, ortho_size, dutch):
"""Set camera lens properties.
\b
Examples:
unity-mcp camera set-lens "CM Camera" --fov 40 --near 0.1
"""
config = get_config()
props: dict[str, Any] = {}
if fov is not None:
props["fieldOfView"] = fov
if near is not None:
props["nearClipPlane"] = near
if far is not None:
props["farClipPlane"] = far
if ortho_size is not None:
props["orthographicSize"] = ortho_size
if dutch is not None:
props["dutch"] = dutch
params = _normalize_cam_params({
"action": "set_lens",
"target": target,
"searchMethod": search_method,
"properties": props if props else None,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("set-priority")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--priority", "-p", type=int, required=True, help="Priority value.")
@handle_unity_errors
def set_priority(target, search_method, priority):
"""Set camera priority.
\b
Examples:
unity-mcp camera set-priority "CM Camera" --priority 20
"""
config = get_config()
params = _normalize_cam_params({
"action": "set_priority",
"target": target,
"searchMethod": search_method,
"properties": {"priority": priority},
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
# =============================================================================
# Cinemachine Pipeline
# =============================================================================
@camera.command("set-body")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--body-type", default=None, help="Body component type to swap to.")
@click.option("--props", default=None, help="Body properties as JSON.")
@handle_unity_errors
def set_body(target, search_method, body_type, props):
"""Configure Body component on CinemachineCamera.
\b
Examples:
unity-mcp camera set-body "CM Camera" --body-type CinemachineFollow
unity-mcp camera set-body "CM Camera" --props '{"cameraDistance": 5.0}'
"""
config = get_config()
properties: dict[str, Any] = {}
if body_type:
properties["bodyType"] = body_type
if props:
properties.update(parse_json_dict_or_exit(props))
params = _normalize_cam_params({
"action": "set_body",
"target": target,
"searchMethod": search_method,
"properties": properties if properties else None,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("set-aim")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--aim-type", default=None, help="Aim component type to swap to.")
@click.option("--props", default=None, help="Aim properties as JSON.")
@handle_unity_errors
def set_aim(target, search_method, aim_type, props):
"""Configure Aim component on CinemachineCamera.
\b
Examples:
unity-mcp camera set-aim "CM Camera" --aim-type CinemachineHardLookAt
"""
config = get_config()
properties: dict[str, Any] = {}
if aim_type:
properties["aimType"] = aim_type
if props:
properties.update(parse_json_dict_or_exit(props))
params = _normalize_cam_params({
"action": "set_aim",
"target": target,
"searchMethod": search_method,
"properties": properties if properties else None,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("set-noise")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--amplitude", type=float, default=None, help="Amplitude gain.")
@click.option("--frequency", type=float, default=None, help="Frequency gain.")
@handle_unity_errors
def set_noise(target, search_method, amplitude, frequency):
"""Configure Noise on CinemachineCamera.
\b
Examples:
unity-mcp camera set-noise "CM Camera" --amplitude 0.5 --frequency 1.0
"""
config = get_config()
props: dict[str, Any] = {}
if amplitude is not None:
props["amplitudeGain"] = amplitude
if frequency is not None:
props["frequencyGain"] = frequency
params = _normalize_cam_params({
"action": "set_noise",
"target": target,
"searchMethod": search_method,
"properties": props if props else None,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
# =============================================================================
# Extensions
# =============================================================================
@camera.command("add-extension")
@click.argument("target")
@click.argument("extension_type")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@click.option("--props", default=None, help="Extension properties as JSON.")
@handle_unity_errors
def add_extension(target, extension_type, search_method, props):
"""Add extension to CinemachineCamera.
\b
Examples:
unity-mcp camera add-extension "CM Camera" CinemachineDeoccluder
unity-mcp camera add-extension "CM Camera" CinemachineImpulseListener
"""
config = get_config()
properties: dict[str, Any] = {"extensionType": extension_type}
if props:
properties.update(parse_json_dict_or_exit(props))
params = _normalize_cam_params({
"action": "add_extension",
"target": target,
"searchMethod": search_method,
"properties": properties,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("remove-extension")
@click.argument("target")
@click.argument("extension_type")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def remove_extension(target, extension_type, search_method):
"""Remove extension from CinemachineCamera.
\b
Examples:
unity-mcp camera remove-extension "CM Camera" CinemachineDeoccluder
"""
config = get_config()
params = _normalize_cam_params({
"action": "remove_extension",
"target": target,
"searchMethod": search_method,
"properties": {"extensionType": extension_type},
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
# =============================================================================
# Control
# =============================================================================
@camera.command("set-blend")
@click.option("--style", default=None, help="Blend style (Cut, EaseInOut, Linear, etc.).")
@click.option("--duration", type=float, default=None, help="Blend duration in seconds.")
@handle_unity_errors
def set_blend(style, duration):
"""Configure default blend on CinemachineBrain.
\b
Examples:
unity-mcp camera set-blend --style EaseInOut --duration 2.0
"""
config = get_config()
props: dict[str, Any] = {}
if style:
props["style"] = style
if duration is not None:
props["duration"] = duration
params: dict[str, Any] = {"action": "set_blend"}
if props:
params["properties"] = props
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("force")
@click.argument("target")
@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None)
@handle_unity_errors
def force_camera(target, search_method):
"""Force Brain to use a specific camera.
\b
Examples:
unity-mcp camera force "CM Cinematic"
"""
config = get_config()
params = _normalize_cam_params({
"action": "force_camera",
"target": target,
"searchMethod": search_method,
})
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("release")
@handle_unity_errors
def release_override():
"""Release camera override.
\b
Examples:
unity-mcp camera release
"""
config = get_config()
result = run_command(config, "manage_camera", {"action": "release_override"})
format_output(result, config)
# =============================================================================
# Capture
# =============================================================================
@camera.command("screenshot")
@click.option("--camera-ref", default=None, help="Camera to capture from (name/path/ID).")
@click.option("--file-name", default=None, help="Output file name.")
@click.option("--super-size", type=int, default=None, help="Supersize multiplier.")
@click.option("--include-image/--no-include-image", default=None, help="Return inline base64 PNG.")
@click.option("--max-resolution", type=int, default=None, help="Max resolution for inline image.")
@click.option("--capture-source", default=None,
type=click.Choice(["game_view", "scene_view"], case_sensitive=False),
help="Capture source: game_view (default) or scene_view.")
@click.option("--batch", default=None, type=click.Choice(["surround", "orbit"]),
help="Batch capture mode.")
@click.option("--view-target", default=None,
help="Target to focus on (name/path/ID or [x,y,z]). Aims camera (game_view) or frames Scene View (scene_view).")
@click.option("--output-folder", default=None,
help="Output folder, project-relative (e.g. 'Assets/Screenshots' or 'Captures') or absolute inside the project. "
"Overrides Editor preference; falls back to Assets/Screenshots when unset.")
@handle_unity_errors
def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, capture_source, batch, view_target, output_folder):
"""Capture a screenshot from a camera.
\b
Examples:
unity-mcp camera screenshot
unity-mcp camera screenshot --camera-ref "CM FollowCam" --include-image --max-resolution 512
unity-mcp camera screenshot --capture-source scene_view --view-target Canvas --include-image
unity-mcp camera screenshot --batch surround --view-target Player
unity-mcp camera screenshot --output-folder Captures
"""
config = get_config()
params: dict[str, Any] = {"action": "screenshot"}
if camera_ref:
params["camera"] = camera_ref
if file_name:
params["fileName"] = file_name
if super_size is not None:
params["superSize"] = super_size
if include_image is not None:
params["includeImage"] = include_image
if max_resolution is not None:
params["maxResolution"] = max_resolution
if capture_source:
params["captureSource"] = capture_source
if batch:
params["batch"] = batch
if view_target:
params["viewTarget"] = view_target
if output_folder:
params["outputFolder"] = output_folder
result = run_command(config, "manage_camera", params)
format_output(result, config)
@camera.command("screenshot-multiview")
@click.option("--max-resolution", type=int, default=None, help="Max resolution per tile.")
@click.option("--view-target", default=None, help="Center target for the multiview capture.")
@click.option("--output-folder", default=None,
help="Output folder, project-relative or absolute inside the project. "
"Overrides Editor preference; falls back to Assets/Screenshots when unset.")
@handle_unity_errors
def screenshot_multiview(max_resolution, view_target, output_folder):
"""Capture a 6-angle contact sheet around the scene.
\b
Examples:
unity-mcp camera screenshot-multiview
unity-mcp camera screenshot-multiview --view-target Player --max-resolution 480
"""
config = get_config()
params: dict[str, Any] = {"action": "screenshot_multiview"}
if max_resolution is not None:
params["maxResolution"] = max_resolution
if view_target:
params["viewTarget"] = view_target
if output_folder:
params["outputFolder"] = output_folder
result = run_command(config, "manage_camera", params)
format_output(result, config)
+268
View File
@@ -0,0 +1,268 @@
"""Code CLI commands - read, search, and execute source code."""
import sys
import os
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_info, print_success
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def code():
"""Code operations - read, search, and execute."""
pass
@code.command("execute")
@click.argument("source", required=False)
@click.option("--file", "-f", default=None, type=click.Path(exists=True), help="Read code from a file instead of argument.")
@click.option("--no-safety-checks", is_flag=True, help="Disable blocked-pattern checks (allows File.Delete, Process.Start, etc).")
@handle_unity_errors
def execute(source: Optional[str], file: Optional[str], no_safety_checks: bool):
"""Execute C# code in Unity Editor.
Code runs as a method body with access to UnityEngine and UnityEditor.
Use 'return' to send data back.
\b
Examples:
unity-mcp code execute "return Application.unityVersion;"
unity-mcp code execute "Debug.Log(Camera.main.name);"
unity-mcp code execute -f my_script.cs
"""
config = get_config()
if file:
with open(file, "r", encoding="utf-8") as f:
source = f.read()
elif not source:
print_error("Provide code as an argument or use --file.")
sys.exit(1)
params: dict[str, Any] = {
"action": "execute",
"code": source,
"safety_checks": not no_safety_checks,
}
result = run_command("execute_code", params, config)
click.echo(format_output(result, config.format))
_print_execution_result(result)
@code.command("history")
@click.option("--limit", "-n", default=10, type=int, help="Number of entries to show (default: 10).")
@handle_unity_errors
def history(limit: int):
"""Show execution history.
\b
Examples:
unity-mcp code history
unity-mcp code history --limit 5
"""
config = get_config()
result = run_command("execute_code", {"action": "get_history", "limit": limit}, config)
click.echo(format_output(result, config.format))
@code.command("replay")
@click.argument("index", type=int)
@handle_unity_errors
def replay(index: int):
"""Replay a history entry by index.
\b
Examples:
unity-mcp code replay 0
unity-mcp code replay 3
"""
config = get_config()
result = run_command("execute_code", {"action": "replay", "index": index}, config)
click.echo(format_output(result, config.format))
_print_execution_result(result)
def _print_execution_result(result: dict[str, Any]) -> None:
if result.get("success"):
data = result.get("data", {})
if data and data.get("result") is not None:
print_success(f"Result: {data['result']}")
@code.command("clear-history")
@handle_unity_errors
def clear_history():
"""Clear execution history."""
config = get_config()
result = run_command("execute_code", {"action": "clear_history"}, config)
click.echo(format_output(result, config.format))
@code.command("read")
@click.argument("path")
@click.option(
"--start-line", "-s",
default=None,
type=int,
help="Starting line number (1-based)."
)
@click.option(
"--line-count", "-n",
default=None,
type=int,
help="Number of lines to read."
)
@handle_unity_errors
def read(path: str, start_line: Optional[int], line_count: Optional[int]):
"""Read a source file.
\b
Examples:
unity-mcp code read "Assets/Scripts/Player.cs"
unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20
"""
config = get_config()
# Extract name and directory from path
parts = path.replace("\\", "/").split("/")
filename = os.path.splitext(parts[-1])[0]
directory = "/".join(parts[:-1]) or "Assets"
params: dict[str, Any] = {
"action": "read",
"name": filename,
"path": directory,
}
if start_line:
params["startLine"] = start_line
if line_count:
params["lineCount"] = line_count
result = run_command("manage_script", params, config)
# For read, output content directly if available
if result.get("success") and result.get("data"):
data = result.get("data", {})
if isinstance(data, dict) and "contents" in data:
click.echo(data["contents"])
else:
click.echo(format_output(result, config.format))
else:
click.echo(format_output(result, config.format))
@code.command("search")
@click.argument("pattern")
@click.argument("path")
@click.option(
"--max-results", "-n",
default=50,
type=int,
help="Maximum number of results (default: 50)."
)
@click.option(
"--case-sensitive", "-c",
is_flag=True,
help="Make search case-sensitive (default: case-insensitive)."
)
@handle_unity_errors
def search(pattern: str, path: str, max_results: int, case_sensitive: bool):
"""Search for patterns in Unity scripts using regex.
PATTERN is a regex pattern to search for.
PATH is the script path (e.g., Assets/Scripts/Player.cs).
\\b
Examples:
unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs"
unity-mcp code search "private.*int" "Assets/Scripts/GameManager.cs"
unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs"
"""
import re
import base64
config = get_config()
# Extract name and directory from path
parts = path.replace("\\", "/").split("/")
filename = os.path.splitext(parts[-1])[0]
directory = "/".join(parts[:-1]) or "Assets"
# Step 1: Read the file via Unity's manage_script
read_params: dict[str, Any] = {
"action": "read",
"name": filename,
"path": directory,
}
result = run_command("manage_script", read_params, config)
# Handle nested response structure: {status, result: {success, data}}
inner_result = result.get("result", result)
if not inner_result.get("success") and result.get("status") != "success":
click.echo(format_output(result, config.format))
return
# Get file contents from nested data
data = inner_result.get("data", {})
contents = data.get("contents")
# Handle base64 encoded content
if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
try:
contents = base64.b64decode(
data["encodedContents"]).decode("utf-8", "replace")
except (ValueError, TypeError):
pass
if not contents:
print_error(f"Could not read file content from {path}")
sys.exit(1)
# Step 2: Perform regex search locally
flags = re.MULTILINE
if not case_sensitive:
flags |= re.IGNORECASE
try:
regex = re.compile(pattern, flags)
except re.error as e:
print_error(f"Invalid regex pattern: {e}")
sys.exit(1)
found = list(regex.finditer(contents))
if not found:
print_info(f"No matches found for pattern: {pattern}")
return
results = []
for m in found[:max_results]:
start_idx = m.start()
# Calculate line number
line_num = contents.count('\n', 0, start_idx) + 1
# Get line content
line_start = contents.rfind('\n', 0, start_idx) + 1
line_end = contents.find('\n', start_idx)
if line_end == -1:
line_end = len(contents)
line_content = contents[line_start:line_end].strip()
results.append({
"line": line_num,
"content": line_content,
"match": m.group(0),
})
# Display results
click.echo(f"Found {len(results)} matches (total: {len(found)}):\n")
for match in results:
click.echo(f" Line {match['line']}: {match['content']}")
+217
View File
@@ -0,0 +1,217 @@
"""Component CLI commands."""
import sys
import json
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_value_safe, parse_json_dict_or_exit
from cli.utils.constants import SEARCH_METHOD_CHOICE_BASIC
from cli.utils.confirmation import confirm_destructive_action
@click.group()
def component():
"""Component operations - add, remove, modify components on GameObjects."""
pass
@component.command("add")
@click.argument("target")
@click.argument("component_type")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--properties", "-p",
default=None,
help='Initial properties as JSON (e.g., \'{"mass": 5.0}\').'
)
@handle_unity_errors
def add(target: str, component_type: str, search_method: Optional[str], properties: Optional[str]):
"""Add a component to a GameObject.
\b
Examples:
unity-mcp component add "Player" Rigidbody
unity-mcp component add "-81840" BoxCollider --search-method by_id
unity-mcp component add "Enemy" Rigidbody --properties '{"mass": 5.0, "useGravity": true}'
"""
config = get_config()
params: dict[str, Any] = {
"action": "add",
"target": target,
"componentType": component_type,
}
if search_method:
params["searchMethod"] = search_method
if properties:
params["properties"] = parse_json_dict_or_exit(properties, "properties")
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Added {component_type} to '{target}'")
@component.command("remove")
@click.argument("target")
@click.argument("component_type")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple components of the same type exist."
)
@handle_unity_errors
def remove(target: str, component_type: str, search_method: Optional[str], force: bool, component_index: Optional[int]):
"""Remove a component from a GameObject.
\b
Examples:
unity-mcp component remove "Player" Rigidbody
unity-mcp component remove "-81840" BoxCollider --search-method by_id --force
unity-mcp component remove "Player" BoxCollider --component-index 1
"""
config = get_config()
confirm_destructive_action("Remove", component_type, target, force, "from")
params: dict[str, Any] = {
"action": "remove",
"target": target,
"componentType": component_type,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Removed {component_type} from '{target}'")
@component.command("set")
@click.argument("target")
@click.argument("component_type")
@click.argument("property_name")
@click.argument("value")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple components of the same type exist."
)
@handle_unity_errors
def set_property(target: str, component_type: str, property_name: str, value: str, search_method: Optional[str], component_index: Optional[int]):
"""Set a single property on a component.
\b
Examples:
unity-mcp component set "Player" Rigidbody mass 5.0
unity-mcp component set "Enemy" Transform position "[0, 5, 0]"
unity-mcp component set "-81840" Light intensity 2.5 --search-method by_id
unity-mcp component set "Player" BoxCollider size "[2,2,2]" --component-index 1
"""
config = get_config()
# Try to parse value as JSON for complex types
parsed_value = parse_value_safe(value)
params: dict[str, Any] = {
"action": "set_property",
"target": target,
"componentType": component_type,
"property": property_name,
"value": parsed_value,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set {component_type}.{property_name} = {value}")
@component.command("modify")
@click.argument("target")
@click.argument("component_type")
@click.option(
"--properties", "-p",
required=True,
help='Properties to set as JSON (e.g., \'{"mass": 5.0, "useGravity": false}\').'
)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_BASIC,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple components of the same type exist."
)
@handle_unity_errors
def modify(target: str, component_type: str, properties: str, search_method: Optional[str], component_index: Optional[int]):
"""Set multiple properties on a component at once.
\b
Examples:
unity-mcp component modify "Player" Rigidbody --properties '{"mass": 5.0, "useGravity": false}'
unity-mcp component modify "Light" Light --properties '{"intensity": 2.0, "color": [1, 0, 0, 1]}'
unity-mcp component modify "Player" BoxCollider --properties '{"size": [2,2,2]}' --component-index 1
"""
config = get_config()
props_dict = parse_json_dict_or_exit(properties, "properties")
params: dict[str, Any] = {
"action": "set_property",
"target": target,
"componentType": component_type,
"properties": props_dict,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_components", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Modified {component_type} on '{target}'")
+33
View File
@@ -0,0 +1,33 @@
"""Unity documentation lookup CLI commands."""
import asyncio
import click
from cli.utils.config import get_config
from cli.utils.output import format_output
@click.group()
def docs():
"""Fetch Unity API documentation."""
pass
@docs.command("get")
@click.argument("class_name")
@click.argument("member_name", required=False)
@click.option("--version", "-v", default=None, help="Unity version (e.g., 6000.0).")
def get_doc(class_name: str, member_name: str | None, version: str | None):
"""Fetch documentation for a Unity class or member.
\b
Examples:
unity-mcp docs get Physics
unity-mcp docs get Physics Raycast
unity-mcp docs get NavMeshAgent SetDestination --version 6000.0
"""
from services.tools.unity_docs import _get_doc
config = get_config()
result = asyncio.run(_get_doc(class_name, member_name, version))
click.echo(format_output(result, config.format))
+517
View File
@@ -0,0 +1,517 @@
"""Editor CLI commands."""
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_info
from cli.utils.connection import run_command, run_list_custom_tools, handle_unity_errors, UnityConnectionError
from cli.utils.suggestions import suggest_matches, format_suggestions
from cli.utils.parsers import parse_json_dict_or_exit
@click.group()
def editor():
"""Editor operations - play mode, console, tags, layers."""
pass
@editor.command("play")
@handle_unity_errors
def play():
"""Enter play mode."""
config = get_config()
result = run_command("manage_editor", {"action": "play"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Entered play mode")
@editor.command("pause")
@handle_unity_errors
def pause():
"""Pause play mode."""
config = get_config()
result = run_command("manage_editor", {"action": "pause"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Paused play mode")
@editor.command("stop")
@handle_unity_errors
def stop():
"""Stop play mode."""
config = get_config()
result = run_command("manage_editor", {"action": "stop"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Stopped play mode")
@editor.command("console")
@click.option(
"--type", "-t",
"log_types",
multiple=True,
type=click.Choice(["error", "warning", "log", "all"]),
default=["error", "warning", "log"],
help="Message types to retrieve."
)
@click.option(
"--count", "-n",
default=10,
type=int,
help="Number of messages to retrieve."
)
@click.option(
"--filter", "-f",
"filter_text",
default=None,
help="Filter messages containing this text."
)
@click.option(
"--stacktrace", "-s",
is_flag=True,
help="Include stack traces."
)
@click.option(
"--clear",
is_flag=True,
help="Clear the console instead of reading."
)
@handle_unity_errors
def console(log_types: tuple, count: int, filter_text: Optional[str], stacktrace: bool, clear: bool):
"""Read or clear the Unity console.
\b
Examples:
unity-mcp editor console
unity-mcp editor console --type error --count 20
unity-mcp editor console --filter "NullReference" --stacktrace
unity-mcp editor console --clear
"""
config = get_config()
if clear:
result = run_command("read_console", {"action": "clear"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Console cleared")
return
params: dict[str, Any] = {
"action": "get",
"types": list(log_types),
"count": count,
"include_stacktrace": stacktrace,
}
if filter_text:
params["filter_text"] = filter_text
result = run_command("read_console", params, config)
click.echo(format_output(result, config.format))
@editor.command("add-tag")
@click.argument("tag_name")
@handle_unity_errors
def add_tag(tag_name: str):
"""Add a new tag.
\b
Examples:
unity-mcp editor add-tag "Enemy"
unity-mcp editor add-tag "Collectible"
"""
config = get_config()
result = run_command(
"manage_editor", {"action": "add_tag", "tagName": tag_name}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Added tag: {tag_name}")
@editor.command("remove-tag")
@click.argument("tag_name")
@handle_unity_errors
def remove_tag(tag_name: str):
"""Remove a tag.
\b
Examples:
unity-mcp editor remove-tag "OldTag"
"""
config = get_config()
result = run_command(
"manage_editor", {"action": "remove_tag", "tagName": tag_name}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Removed tag: {tag_name}")
@editor.command("add-layer")
@click.argument("layer_name")
@handle_unity_errors
def add_layer(layer_name: str):
"""Add a new layer.
\b
Examples:
unity-mcp editor add-layer "Interactable"
"""
config = get_config()
result = run_command(
"manage_editor", {"action": "add_layer", "layerName": layer_name}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Added layer: {layer_name}")
@editor.command("remove-layer")
@click.argument("layer_name")
@handle_unity_errors
def remove_layer(layer_name: str):
"""Remove a layer.
\b
Examples:
unity-mcp editor remove-layer "OldLayer"
"""
config = get_config()
result = run_command(
"manage_editor", {"action": "remove_layer", "layerName": layer_name}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Removed layer: {layer_name}")
@editor.command("tool")
@click.argument("tool_name")
@handle_unity_errors
def set_tool(tool_name: str):
"""Set the active editor tool.
\b
Examples:
unity-mcp editor tool "Move"
unity-mcp editor tool "Rotate"
unity-mcp editor tool "Scale"
"""
config = get_config()
result = run_command(
"manage_editor", {"action": "set_active_tool", "toolName": tool_name}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set active tool: {tool_name}")
@editor.command("deploy")
@handle_unity_errors
def deploy():
"""Deploy MCPForUnity package from configured source.
Copies the configured MCPForUnity source folder into the project's
installed package location. The source path must be set in the
MCP for Unity Advanced Settings first. Triggers recompilation.
\b
Examples:
unity-mcp editor deploy
"""
config = get_config()
result = run_command("manage_editor", {"action": "deploy_package"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Package deployed")
@editor.command("restore")
@handle_unity_errors
def restore():
"""Restore MCPForUnity package from last backup.
Reverts the last deployment by restoring from backup.
\b
Examples:
unity-mcp editor restore
"""
config = get_config()
result = run_command("manage_editor", {"action": "restore_package"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Package restored from backup")
@editor.command("undo")
@handle_unity_errors
def undo():
"""Undo the last editor action.
\b
Examples:
unity-mcp editor undo
"""
config = get_config()
result = run_command("manage_editor", {"action": "undo"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Undo performed")
@editor.command("redo")
@handle_unity_errors
def redo():
"""Redo the last undone action.
\b
Examples:
unity-mcp editor redo
"""
config = get_config()
result = run_command("manage_editor", {"action": "redo"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Redo performed")
@editor.command("menu")
@click.argument("menu_path")
@handle_unity_errors
def execute_menu(menu_path: str):
"""Execute a menu item.
\b
Examples:
unity-mcp editor menu "File/Save"
unity-mcp editor menu "Edit/Undo"
unity-mcp editor menu "GameObject/Create Empty"
"""
config = get_config()
result = run_command("execute_menu_item", {"menu_path": menu_path}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Executed: {menu_path}")
@editor.command("tests")
@click.option(
"--mode", "-m",
type=click.Choice(["EditMode", "PlayMode"]),
default="EditMode",
help="Test mode to run."
)
@click.option(
"--async", "async_mode",
is_flag=True,
help="Run asynchronously and return job ID for polling."
)
@click.option(
"--wait", "-w",
type=int,
default=None,
help="Wait up to N seconds for completion (default: no wait)."
)
@click.option(
"--details",
is_flag=True,
help="Include detailed results for all tests."
)
@click.option(
"--failed-only",
is_flag=True,
help="Include details for failed/skipped tests only."
)
@handle_unity_errors
def run_tests(mode: str, async_mode: bool, wait: Optional[int], details: bool, failed_only: bool):
"""Run Unity tests.
\b
Examples:
unity-mcp editor tests
unity-mcp editor tests --mode PlayMode
unity-mcp editor tests --async
unity-mcp editor tests --wait 60 --failed-only
"""
config = get_config()
params: dict[str, Any] = {"mode": mode}
if wait is not None:
params["wait_timeout"] = wait
if details:
params["include_details"] = True
if failed_only:
params["include_failed_tests"] = True
result = run_command("run_tests", params, config)
# For async mode, just show job ID
if async_mode and result.get("success"):
job_id = result.get("data", {}).get("job_id")
if job_id:
click.echo(f"Test job started: {job_id}")
print_info("Poll with: unity-mcp editor poll-test " + job_id)
return
click.echo(format_output(result, config.format))
@editor.command("poll-test")
@click.argument("job_id")
@click.option(
"--wait", "-w",
type=int,
default=30,
help="Wait up to N seconds for completion (default: 30)."
)
@click.option(
"--details",
is_flag=True,
help="Include detailed results for all tests."
)
@click.option(
"--failed-only",
is_flag=True,
help="Include details for failed/skipped tests only."
)
@handle_unity_errors
def poll_test(job_id: str, wait: int, details: bool, failed_only: bool):
"""Poll an async test job for status/results.
\b
Examples:
unity-mcp editor poll-test abc123
unity-mcp editor poll-test abc123 --wait 60
unity-mcp editor poll-test abc123 --failed-only
"""
config = get_config()
params: dict[str, Any] = {"job_id": job_id}
if wait:
params["wait_timeout"] = wait
if details:
params["include_details"] = True
if failed_only:
params["include_failed_tests"] = True
result = run_command("get_test_job", params, config)
click.echo(format_output(result, config.format))
if isinstance(result, dict) and result.get("success"):
data = result.get("data", {})
status = data.get("status", "unknown")
if status == "succeeded":
print_success("Tests completed successfully")
elif status == "failed":
summary = data.get("result", {}).get("summary", {})
failed = summary.get("failed", 0)
print_error(f"Tests failed: {failed} failures")
elif status == "running":
progress = data.get("progress", {})
completed = progress.get("completed", 0)
total = progress.get("total", 0)
print_info(f"Tests running: {completed}/{total}")
@editor.command("refresh")
@click.option(
"--mode",
type=click.Choice(["if_dirty", "force"]),
default="if_dirty",
help="Refresh mode."
)
@click.option(
"--scope",
type=click.Choice(["assets", "scripts", "all"]),
default="all",
help="What to refresh."
)
@click.option(
"--compile",
is_flag=True,
help="Request script compilation."
)
@click.option(
"--no-wait",
is_flag=True,
help="Don't wait for refresh to complete."
)
@handle_unity_errors
def refresh(mode: str, scope: str, compile: bool, no_wait: bool):
"""Force Unity to refresh assets/scripts.
\b
Examples:
unity-mcp editor refresh
unity-mcp editor refresh --mode force
unity-mcp editor refresh --compile
unity-mcp editor refresh --scope scripts --compile
"""
config = get_config()
params: dict[str, Any] = {
"mode": mode,
"scope": scope,
"wait_for_ready": not no_wait,
}
if compile:
params["compile"] = "request"
click.echo("Refreshing Unity...")
result = run_command("refresh_unity", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Unity refreshed")
@editor.command("custom-tool")
@click.argument("tool_name")
@click.option(
"--params", "-p",
default="{}",
help="Tool parameters as JSON."
)
@handle_unity_errors
def custom_tool(tool_name: str, params: str):
"""Execute a custom Unity tool.
Custom tools are registered by Unity projects via the MCP plugin.
\b
Examples:
unity-mcp editor custom-tool "MyCustomTool"
unity-mcp editor custom-tool "BuildPipeline" --params '{"target": "Android"}'
"""
config = get_config()
params_dict = parse_json_dict_or_exit(params, "params")
result = run_command("execute_custom_tool", {
"tool_name": tool_name,
"parameters": params_dict,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Executed custom tool: {tool_name}")
else:
message = (result.get("message") or result.get("error") or "").lower()
if "not found" in message and "tool" in message:
try:
tools_result = run_list_custom_tools(config)
tools = tools_result.get("tools")
if tools is None:
data = tools_result.get("data", {})
tools = data.get("tools") if isinstance(data, dict) else None
names = [
t.get("name") for t in tools if isinstance(t, dict) and t.get("name")
] if isinstance(tools, list) else []
matches = suggest_matches(tool_name, names)
suggestion = format_suggestions(matches)
if suggestion:
print_info(suggestion)
print_info(f'Example: unity-mcp editor custom-tool "{matches[0]}"')
except UnityConnectionError:
pass
+496
View File
@@ -0,0 +1,496 @@
"""GameObject CLI commands."""
import sys
import json
import click
from typing import Optional, Tuple, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_warning
from cli.utils.connection import run_command, handle_unity_errors, UnityConnectionError
from cli.utils.constants import SEARCH_METHOD_CHOICE_FULL, SEARCH_METHOD_CHOICE_TAGGED
from cli.utils.confirmation import confirm_destructive_action
@click.group()
def gameobject():
"""GameObject operations - create, find, modify, delete GameObjects."""
pass
@gameobject.command("find")
@click.argument("search_term")
@click.option(
"--method", "-m",
type=SEARCH_METHOD_CHOICE_FULL,
default="by_name",
help="Search method."
)
@click.option(
"--include-inactive", "-i",
is_flag=True,
help="Include inactive GameObjects."
)
@click.option(
"--limit", "-l",
default=50,
type=int,
help="Maximum results to return."
)
@click.option(
"--cursor", "-c",
default=0,
type=int,
help="Pagination cursor (offset)."
)
@handle_unity_errors
def find(search_term: str, method: str, include_inactive: bool, limit: int, cursor: int):
"""Find GameObjects by search criteria.
\b
Examples:
unity-mcp gameobject find "Player"
unity-mcp gameobject find "Enemy" --method by_tag
unity-mcp gameobject find "-81840" --method by_id
unity-mcp gameobject find "Rigidbody" --method by_component
unity-mcp gameobject find "/Canvas/Panel" --method by_path
"""
config = get_config()
result = run_command("find_gameobjects", {
"searchMethod": method,
"searchTerm": search_term,
"includeInactive": include_inactive,
"pageSize": limit,
"cursor": cursor,
}, config)
click.echo(format_output(result, config.format))
@gameobject.command("create")
@click.argument("name")
@click.option(
"--primitive", "-p",
type=click.Choice(["Cube", "Sphere", "Cylinder",
"Plane", "Capsule", "Quad"]),
help="Create a primitive type."
)
@click.option(
"--position", "-pos",
nargs=3,
type=float,
default=None,
help="Position as X Y Z."
)
@click.option(
"--rotation", "-rot",
nargs=3,
type=float,
default=None,
help="Rotation as X Y Z (euler angles)."
)
@click.option(
"--scale", "-s",
nargs=3,
type=float,
default=None,
help="Scale as X Y Z."
)
@click.option(
"--parent",
default=None,
help="Parent GameObject name or path."
)
@click.option(
"--tag", "-t",
default=None,
help="Tag to assign."
)
@click.option(
"--layer",
default=None,
help="Layer to assign."
)
@click.option(
"--components",
default=None,
help="Comma-separated list of components to add."
)
@click.option(
"--save-prefab",
is_flag=True,
help="Save as prefab after creation."
)
@click.option(
"--prefab-path",
default=None,
help="Path for prefab (e.g., Assets/Prefabs/MyPrefab.prefab)."
)
@handle_unity_errors
def create(
name: str,
primitive: Optional[str],
position: Optional[Tuple[float, float, float]],
rotation: Optional[Tuple[float, float, float]],
scale: Optional[Tuple[float, float, float]],
parent: Optional[str],
tag: Optional[str],
layer: Optional[str],
components: Optional[str],
save_prefab: bool,
prefab_path: Optional[str],
):
"""Create a new GameObject.
\b
Examples:
unity-mcp gameobject create "MyCube" --primitive Cube
unity-mcp gameobject create "Player" --position 0 1 0
unity-mcp gameobject create "Enemy" --primitive Sphere --tag Enemy
unity-mcp gameobject create "Child" --parent "ParentObject"
unity-mcp gameobject create "Item" --components "Rigidbody,BoxCollider"
"""
config = get_config()
params: dict[str, Any] = {
"action": "create",
"name": name,
}
if primitive:
params["primitiveType"] = primitive
if position:
params["position"] = list(position)
if rotation:
params["rotation"] = list(rotation)
if scale:
params["scale"] = list(scale)
if parent:
params["parent"] = parent
if tag:
params["tag"] = tag
if layer:
params["layer"] = layer
if save_prefab:
params["saveAsPrefab"] = True
if prefab_path:
params["prefabPath"] = prefab_path
result = run_command("manage_gameobject", params, config)
# Add components separately since componentsToAdd doesn't work
if components and (result.get("success") or result.get("data") or result.get("result")):
component_list = [c.strip() for c in components.split(",")]
failed_components = []
for component in component_list:
try:
run_command("manage_components", {
"action": "add",
"target": name,
"componentType": component,
}, config)
except UnityConnectionError:
failed_components.append(component)
if failed_components:
print_warning(f"Failed to add components: {', '.join(failed_components)}")
click.echo(format_output(result, config.format))
if result.get("success") or result.get("result"):
print_success(f"Created GameObject '{name}'")
@gameobject.command("modify")
@click.argument("target")
@click.option(
"--name", "-n",
default=None,
help="New name for the GameObject."
)
@click.option(
"--position", "-pos",
nargs=3,
type=float,
default=None,
help="New position as X Y Z."
)
@click.option(
"--rotation", "-rot",
nargs=3,
type=float,
default=None,
help="New rotation as X Y Z (euler angles)."
)
@click.option(
"--scale", "-s",
nargs=3,
type=float,
default=None,
help="New scale as X Y Z."
)
@click.option(
"--parent",
default=None,
help="New parent GameObject."
)
@click.option(
"--tag", "-t",
default=None,
help="New tag."
)
@click.option(
"--layer",
default=None,
help="New layer."
)
@click.option(
"--active/--inactive",
default=None,
help="Set active state."
)
@click.option(
"--static/--no-static",
default=None,
help="Set static flag (all StaticEditorFlags on/off)."
)
@click.option(
"--add-components",
default=None,
help="Comma-separated list of components to add."
)
@click.option(
"--remove-components",
default=None,
help="Comma-separated list of components to remove."
)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_TAGGED,
default=None,
help="How to find the target GameObject."
)
@handle_unity_errors
def modify(
target: str,
name: Optional[str],
position: Optional[Tuple[float, float, float]],
rotation: Optional[Tuple[float, float, float]],
scale: Optional[Tuple[float, float, float]],
parent: Optional[str],
tag: Optional[str],
layer: Optional[str],
active: Optional[bool],
static: Optional[bool],
add_components: Optional[str],
remove_components: Optional[str],
search_method: Optional[str],
):
"""Modify an existing GameObject.
TARGET can be a name, path, instance ID, or tag depending on --search-method.
\b
Examples:
unity-mcp gameobject modify "Player" --position 0 5 0
unity-mcp gameobject modify "Enemy" --name "Boss" --tag "Boss"
unity-mcp gameobject modify "-81840" --search-method by_id --active
unity-mcp gameobject modify "/Canvas/Panel" --search-method by_path --inactive
unity-mcp gameobject modify "Cube" --add-components "Rigidbody,BoxCollider"
unity-mcp gameobject modify "Ground" --static
"""
config = get_config()
params: dict[str, Any] = {
"action": "modify",
"target": target,
}
if name:
params["name"] = name
if position:
params["position"] = list(position)
if rotation:
params["rotation"] = list(rotation)
if scale:
params["scale"] = list(scale)
if parent:
params["parent"] = parent
if tag:
params["tag"] = tag
if layer:
params["layer"] = layer
if active is not None:
params["setActive"] = active
if static is not None:
params["isStatic"] = static
if add_components:
params["componentsToAdd"] = [c.strip() for c in add_components.split(",")]
if remove_components:
params["componentsToRemove"] = [c.strip() for c in remove_components.split(",")]
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_gameobject", params, config)
click.echo(format_output(result, config.format))
@gameobject.command("delete")
@click.argument("target")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_TAGGED,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@handle_unity_errors
def delete(target: str, search_method: Optional[str], force: bool):
"""Delete a GameObject.
\b
Examples:
unity-mcp gameobject delete "OldObject"
unity-mcp gameobject delete "-81840" --search-method by_id
unity-mcp gameobject delete "TempObjects" --search-method by_tag --force
"""
config = get_config()
confirm_destructive_action("Delete", "GameObject", target, force)
params = {
"action": "delete",
"target": target,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_gameobject", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Deleted GameObject '{target}'")
@gameobject.command("duplicate")
@click.argument("target")
@click.option(
"--name", "-n",
default=None,
help="Name for the duplicate (default: OriginalName_Copy)."
)
@click.option(
"--offset",
nargs=3,
type=float,
default=None,
help="Position offset from original as X Y Z."
)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_TAGGED,
default=None,
help="How to find the target GameObject."
)
@handle_unity_errors
def duplicate(
target: str,
name: Optional[str],
offset: Optional[Tuple[float, float, float]],
search_method: Optional[str],
):
"""Duplicate a GameObject.
\b
Examples:
unity-mcp gameobject duplicate "Player"
unity-mcp gameobject duplicate "Enemy" --name "Enemy2" --offset 5 0 0
unity-mcp gameobject duplicate "-81840" --search-method by_id
"""
config = get_config()
params: dict[str, Any] = {
"action": "duplicate",
"target": target,
}
if name:
params["new_name"] = name
if offset:
params["offset"] = list(offset)
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_gameobject", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Duplicated GameObject '{target}'")
@gameobject.command("move")
@click.argument("target")
@click.option(
"--reference", "-r",
required=True,
help="Reference object for relative movement."
)
@click.option(
"--direction", "-d",
type=click.Choice(["left", "right", "up", "down", "forward",
"back", "front", "backward", "behind"]),
required=True,
help="Direction to move."
)
@click.option(
"--distance",
type=float,
default=1.0,
help="Distance to move (default: 1.0)."
)
@click.option(
"--local",
is_flag=True,
help="Use reference object's local space instead of world space."
)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_TAGGED,
default=None,
help="How to find the target GameObject."
)
@handle_unity_errors
def move(
target: str,
reference: str,
direction: str,
distance: float,
local: bool,
search_method: Optional[str],
):
"""Move a GameObject relative to another object.
\b
Examples:
unity-mcp gameobject move "Chair" --reference "Table" --direction right --distance 2
unity-mcp gameobject move "Light" --reference "Player" --direction up --distance 3
unity-mcp gameobject move "NPC" --reference "Player" --direction forward --local
"""
config = get_config()
params: dict[str, Any] = {
"action": "move_relative",
"target": target,
"reference_object": reference,
"direction": direction,
"distance": distance,
"world_space": not local,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_gameobject", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Moved '{target}' {direction} of '{reference}' by {distance} units")
+545
View File
@@ -0,0 +1,545 @@
import click
from cli.utils.connection import handle_unity_errors, run_command, get_config
from cli.utils.output import format_output
@click.group("graphics")
def graphics():
"""Manage rendering graphics: volumes, effects, and pipeline settings."""
pass
def _coerce_cli_value(val: str):
"""Convert a CLI string value to bool/float/int/str."""
if val.lower() in ("true", "false"):
return val.lower() == "true"
try:
return float(val) if "." in val else int(val)
except ValueError:
return val
@graphics.command("ping")
@handle_unity_errors
def ping():
"""Check graphics system status."""
config = get_config()
params = {"action": "ping"}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-create")
@click.option("--name", "-n", default=None, help="Name for the Volume GameObject.")
@click.option("--global/--local", "is_global", default=True, help="Global or local Volume.")
@click.option("--weight", "-w", type=float, default=None, help="Volume weight (0-1).")
@click.option("--priority", "-p", type=float, default=None, help="Volume priority.")
@click.option("--profile-path", default=None, help="Existing VolumeProfile asset path to assign.")
@handle_unity_errors
def volume_create(name, is_global, weight, priority, profile_path):
"""Create a Volume GameObject with a profile."""
config = get_config()
params = {"action": "volume_create", "is_global": is_global}
if name:
params["name"] = name
if weight is not None:
params["weight"] = weight
if priority is not None:
params["priority"] = priority
if profile_path:
params["profile_path"] = profile_path
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-add-effect")
@click.option("--target", "-t", required=True, help="Volume name or instance ID.")
@click.option("--effect", "-e", required=True, help="Effect type (e.g., Bloom, Vignette).")
@handle_unity_errors
def volume_add_effect(target, effect):
"""Add an effect override to a Volume."""
config = get_config()
params = {"action": "volume_add_effect", "target": target, "effect": effect}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-set-effect")
@click.option("--target", "-t", required=True, help="Volume name or instance ID.")
@click.option("--effect", "-e", required=True, help="Effect type (e.g., Bloom).")
@click.option("--param", "-p", multiple=True, type=(str, str), help="Parameter key-value pair.")
@handle_unity_errors
def volume_set_effect(target, effect, param):
"""Set parameters on a Volume effect."""
config = get_config()
parameters = {k: v for k, v in param}
params = {
"action": "volume_set_effect",
"target": target,
"effect": effect,
"parameters": parameters,
}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-remove-effect")
@click.option("--target", "-t", required=True, help="Volume name or instance ID.")
@click.option("--effect", "-e", required=True, help="Effect type to remove.")
@handle_unity_errors
def volume_remove_effect(target, effect):
"""Remove an effect from a Volume."""
config = get_config()
params = {"action": "volume_remove_effect", "target": target, "effect": effect}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-info")
@click.option("--target", "-t", required=True, help="Volume name or instance ID.")
@handle_unity_errors
def volume_info(target):
"""Get all effects and parameters on a Volume."""
config = get_config()
params = {"action": "volume_get_info", "target": target}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-set-properties")
@click.option("--target", "-t", required=True, help="Volume name or instance ID.")
@click.option("--weight", "-w", type=float, default=None, help="Volume weight (0-1).")
@click.option("--priority", "-p", type=float, default=None, help="Volume priority.")
@click.option("--global/--local", "is_global", default=None, help="Global or local Volume.")
@handle_unity_errors
def volume_set_properties(target, weight, priority, is_global):
"""Set Volume properties (weight, priority, is_global)."""
config = get_config()
params = {"action": "volume_set_properties", "target": target}
if weight is not None:
params["weight"] = weight
if priority is not None:
params["priority"] = priority
if is_global is not None:
params["is_global"] = is_global
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-list-effects")
@handle_unity_errors
def volume_list_effects():
"""List available VolumeComponent effect types."""
config = get_config()
params = {"action": "volume_list_effects"}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("volume-create-profile")
@click.option("--path", "-p", required=True, help="Asset path for the VolumeProfile (e.g., Assets/Profiles/MyProfile.asset).")
@click.option("--name", "-n", default=None, help="Display name for the profile.")
@handle_unity_errors
def volume_create_profile(path, name):
"""Create a standalone VolumeProfile asset."""
config = get_config()
params = {"action": "volume_create_profile", "path": path}
if name:
params["name"] = name
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("pipeline-info")
@handle_unity_errors
def pipeline_info():
"""Get active render pipeline, quality level, and settings."""
config = get_config()
params = {"action": "pipeline_get_info"}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("pipeline-set-quality")
@click.option("--level", "-l", required=True, help="Quality level name or index.")
@handle_unity_errors
def pipeline_set_quality(level):
"""Switch quality level."""
config = get_config()
params = {"action": "pipeline_set_quality", "level": level}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("pipeline-settings")
@handle_unity_errors
def pipeline_settings():
"""Get detailed pipeline settings."""
config = get_config()
result = run_command("manage_graphics", {"action": "pipeline_get_settings"}, config)
click.echo(format_output(result, config.format))
@graphics.command("pipeline-set-settings")
@click.option("--setting", "-s", multiple=True, type=(str, str), required=True,
help="Setting key-value pair (e.g., -s renderScale 0.5 -s supportsHDR true).")
@handle_unity_errors
def pipeline_set_settings(setting):
"""Set pipeline asset settings."""
config = get_config()
settings = {key: _coerce_cli_value(val) for key, val in setting}
params = {"action": "pipeline_set_settings", "settings": settings}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
# --- Bake commands ---
@graphics.command("bake-start")
@click.option("--sync", is_flag=True, help="Synchronous bake (blocks until done).")
@handle_unity_errors
def bake_start(sync):
"""Start lightmap bake."""
config = get_config()
params = {"action": "bake_start", "async": not sync}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-cancel")
@handle_unity_errors
def bake_cancel():
"""Cancel running bake."""
config = get_config()
result = run_command("manage_graphics", {"action": "bake_cancel"}, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-status")
@handle_unity_errors
def bake_status():
"""Get bake progress/status."""
config = get_config()
result = run_command("manage_graphics", {"action": "bake_status"}, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-clear")
@handle_unity_errors
def bake_clear():
"""Clear all baked lighting data."""
config = get_config()
result = run_command("manage_graphics", {"action": "bake_clear"}, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-settings")
@handle_unity_errors
def bake_settings():
"""Get current lighting/bake settings."""
config = get_config()
result = run_command("manage_graphics", {"action": "bake_get_settings"}, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-reflection-probe")
@click.option("--target", "-t", required=True, help="Name or instance ID of GameObject with ReflectionProbe.")
@handle_unity_errors
def bake_reflection_probe(target):
"""Bake a specific reflection probe."""
config = get_config()
params = {"action": "bake_reflection_probe", "target": target}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-set-settings")
@click.option("--setting", "-s", multiple=True, type=(str, str), required=True,
help="Lighting setting key-value pair.")
@handle_unity_errors
def bake_set_settings(setting):
"""Set lighting/bake settings."""
config = get_config()
settings = {key: _coerce_cli_value(val) for key, val in setting}
params = {"action": "bake_set_settings", "settings": settings}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-create-probes")
@click.option("--name", "-n", default=None, help="Name for the probe group.")
@click.option("--spacing", "-s", type=float, default=None, help="Grid spacing.")
@handle_unity_errors
def bake_create_probes(name, spacing):
"""Create a light probe group with grid layout."""
config = get_config()
params = {"action": "bake_create_light_probe_group"}
if name:
params["name"] = name
if spacing is not None:
params["spacing"] = spacing
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("bake-create-reflection")
@click.option("--name", "-n", default=None, help="Name for the reflection probe.")
@click.option("--resolution", "-r", type=int, default=None, help="Probe resolution.")
@click.option("--mode", "-m", default=None, help="Baked/Realtime/Custom.")
@handle_unity_errors
def bake_create_reflection(name, resolution, mode):
"""Create a reflection probe."""
config = get_config()
params = {"action": "bake_create_reflection_probe"}
if name:
params["name"] = name
if resolution is not None:
params["resolution"] = resolution
if mode:
params["mode"] = mode
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
# --- Stats commands ---
@graphics.command("stats")
@handle_unity_errors
def stats():
"""Get rendering performance stats."""
config = get_config()
result = run_command("manage_graphics", {"action": "stats_get"}, config)
click.echo(format_output(result, config.format))
@graphics.command("stats-memory")
@handle_unity_errors
def stats_memory():
"""Get memory allocation stats."""
config = get_config()
result = run_command("manage_graphics", {"action": "stats_get_memory"}, config)
click.echo(format_output(result, config.format))
@graphics.command("stats-debug-mode")
@click.option("--mode", "-m", required=True, help="Debug mode (Overdraw, Wireframe, Mipmaps, etc.).")
@handle_unity_errors
def stats_debug_mode(mode):
"""Set Scene view debug visualization mode."""
config = get_config()
params = {"action": "stats_set_scene_debug", "mode": mode}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
# --- Feature commands ---
@graphics.command("feature-list")
@handle_unity_errors
def feature_list():
"""List URP renderer features."""
config = get_config()
result = run_command("manage_graphics", {"action": "feature_list"}, config)
click.echo(format_output(result, config.format))
@graphics.command("feature-add")
@click.option("--type", "-t", "feature_type", required=True, help="Feature type (e.g., FullScreenPassRendererFeature).")
@click.option("--name", "-n", default=None, help="Display name.")
@handle_unity_errors
def feature_add(feature_type, name):
"""Add a renderer feature."""
config = get_config()
params = {"action": "feature_add", "type": feature_type}
if name:
params["name"] = name
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("feature-remove")
@click.option("--index", "-i", type=int, default=None, help="Feature index.")
@click.option("--name", "-n", default=None, help="Feature name.")
@handle_unity_errors
def feature_remove(index, name):
"""Remove a renderer feature."""
config = get_config()
params = {"action": "feature_remove"}
if index is not None:
params["index"] = index
if name:
params["name"] = name
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("feature-configure")
@click.option("--index", "-i", type=int, default=None, help="Feature index.")
@click.option("--name", "-n", default=None, help="Feature name.")
@click.option("--prop", "-p", multiple=True, type=(str, str), required=True,
help="Property key-value pair.")
@handle_unity_errors
def feature_configure(index, name, prop):
"""Configure properties on a renderer feature."""
config = get_config()
properties = {key: _coerce_cli_value(val) for key, val in prop}
params = {"action": "feature_configure", "properties": properties}
if index is not None:
params["index"] = index
if name:
params["name"] = name
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("feature-reorder")
@click.option("--order", "-o", required=True, help="Comma-separated list of indices (e.g., '2,0,1').")
@handle_unity_errors
def feature_reorder(order):
"""Reorder renderer features."""
config = get_config()
order_list = [int(x.strip()) for x in order.split(",")]
params = {"action": "feature_reorder", "order": order_list}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("feature-toggle")
@click.option("--index", "-i", type=int, default=None, help="Feature index.")
@click.option("--name", "-n", default=None, help="Feature name.")
@click.option("--active/--inactive", default=True, help="Enable or disable.")
@handle_unity_errors
def feature_toggle(index, name, active):
"""Enable/disable a renderer feature."""
config = get_config()
params = {"action": "feature_toggle", "active": active}
if index is not None:
params["index"] = index
if name:
params["name"] = name
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
# --- Skybox / Environment commands ---
@graphics.command("skybox-info")
@handle_unity_errors
def skybox_info():
"""Get all environment settings (skybox, ambient, fog, reflection, sun)."""
config = get_config()
result = run_command("manage_graphics", {"action": "skybox_get"}, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-material")
@click.option("--material", "-m", required=True, help="Asset path to skybox material.")
@handle_unity_errors
def skybox_set_material(material):
"""Set the skybox material by asset path."""
config = get_config()
params = {"action": "skybox_set_material", "material": material}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-properties")
@click.option("--prop", "-p", multiple=True, type=(str, str), required=True,
help="Material property key-value pair (e.g., -p _Exposure 1.3).")
@handle_unity_errors
def skybox_set_properties(prop):
"""Set properties on the current skybox material."""
config = get_config()
properties = {key: _coerce_cli_value(val) for key, val in prop}
params = {"action": "skybox_set_properties", "properties": properties}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-ambient")
@click.option("--mode", "-m", default=None, help="Ambient mode: Skybox, Trilight, Flat, Custom.")
@click.option("--intensity", "-i", type=float, default=None, help="Ambient intensity.")
@click.option("--color", "-c", default=None, help="Sky/ambient color as 'r,g,b[,a]'.")
@click.option("--equator-color", default=None, help="Equator color as 'r,g,b[,a]' (Trilight mode).")
@click.option("--ground-color", default=None, help="Ground color as 'r,g,b[,a]' (Trilight mode).")
@handle_unity_errors
def skybox_set_ambient(mode, intensity, color, equator_color, ground_color):
"""Set ambient lighting mode and colors."""
config = get_config()
params = {"action": "skybox_set_ambient"}
if mode:
params["ambient_mode"] = mode
if intensity is not None:
params["intensity"] = intensity
if color:
params["color"] = [float(x) for x in color.split(",")]
if equator_color:
params["equator_color"] = [float(x) for x in equator_color.split(",")]
if ground_color:
params["ground_color"] = [float(x) for x in ground_color.split(",")]
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-fog")
@click.option("--enable/--disable", "fog_enabled", default=None, help="Enable or disable fog.")
@click.option("--mode", "-m", default=None, help="Fog mode: Linear, Exponential, ExponentialSquared.")
@click.option("--color", "-c", default=None, help="Fog color as 'r,g,b[,a]'.")
@click.option("--density", "-d", type=float, default=None, help="Fog density.")
@click.option("--start", type=float, default=None, help="Fog start distance (Linear).")
@click.option("--end", type=float, default=None, help="Fog end distance (Linear).")
@handle_unity_errors
def skybox_set_fog(fog_enabled, mode, color, density, start, end):
"""Enable and configure fog."""
config = get_config()
params = {"action": "skybox_set_fog"}
if fog_enabled is not None:
params["fog_enabled"] = fog_enabled
if mode:
params["fog_mode"] = mode
if color:
params["fog_color"] = [float(x) for x in color.split(",")]
if density is not None:
params["fog_density"] = density
if start is not None:
params["fog_start"] = start
if end is not None:
params["fog_end"] = end
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-reflection")
@click.option("--intensity", "-i", type=float, default=None, help="Reflection intensity.")
@click.option("--bounces", "-b", type=int, default=None, help="Reflection bounces.")
@click.option("--mode", "-m", default=None, help="Reflection mode: Skybox, Custom.")
@click.option("--resolution", "-r", type=int, default=None, help="Default reflection resolution.")
@click.option("--cubemap", default=None, help="Custom cubemap asset path.")
@handle_unity_errors
def skybox_set_reflection(intensity, bounces, mode, resolution, cubemap):
"""Configure environment reflection settings."""
config = get_config()
params = {"action": "skybox_set_reflection"}
if intensity is not None:
params["intensity"] = intensity
if bounces is not None:
params["bounces"] = bounces
if mode:
params["reflection_mode"] = mode
if resolution is not None:
params["resolution"] = resolution
if cubemap:
params["path"] = cubemap
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
@graphics.command("skybox-set-sun")
@click.option("--target", "-t", required=True, help="Light GameObject name or instance ID.")
@handle_unity_errors
def skybox_set_sun(target):
"""Set the sun source light for the environment."""
config = get_config()
params = {"action": "skybox_set_sun", "target": target}
result = run_command("manage_graphics", params, config)
click.echo(format_output(result, config.format))
+93
View File
@@ -0,0 +1,93 @@
"""Instance CLI commands for managing Unity instances."""
import click
from typing import Optional
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_info
from cli.utils.connection import run_command, run_list_instances, handle_unity_errors
@click.group()
def instance():
"""Unity instance management - list, select, and view instances."""
pass
@instance.command("list")
@handle_unity_errors
def list_instances():
"""List available Unity instances.
\\b
Examples:
unity-mcp instance list
"""
config = get_config()
result = run_list_instances(config)
instances = result.get("instances", []) if isinstance(
result, dict) else []
if not instances:
print_info("No Unity instances currently connected")
return
click.echo("Available Unity instances:")
for inst in instances:
project = inst.get("project", "Unknown")
version = inst.get("unity_version", "Unknown")
hash_id = inst.get("hash", "")
session_id = inst.get("session_id", "")
# Format: ProjectName@hash (Unity version)
display_id = f"{project}@{hash_id}" if hash_id else project
click.echo(f"{display_id} (Unity {version})")
if session_id:
click.echo(f" Session: {session_id[:8]}...")
@instance.command("set")
@click.argument("instance_id")
@handle_unity_errors
def set_instance(instance_id: str):
"""Set the active Unity instance.
INSTANCE_ID can be Name@hash or just a hash prefix.
\\b
Examples:
unity-mcp instance set "MyProject@abc123"
unity-mcp instance set abc123
"""
config = get_config()
result = run_command("set_active_instance", {
"instance": instance_id,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
data = result.get("data", {})
active = data.get("instance", instance_id)
print_success(f"Active instance set to: {active}")
@instance.command("current")
def current_instance():
"""Show the currently selected Unity instance.
\\b
Examples:
unity-mcp instance current
"""
config = get_config()
# The current instance is typically shown in telemetry or needs to be tracked
# For now, we can show the configured instance from CLI options
if config.unity_instance:
click.echo(f"Configured instance: {config.unity_instance}")
else:
print_info(
"No instance explicitly set. Using default (auto-select single instance).")
print_info("Use 'unity-mcp instance list' to see available instances.")
print_info("Use 'unity-mcp instance set <id>' to select one.")
+123
View File
@@ -0,0 +1,123 @@
"""Lighting CLI commands."""
import click
from typing import Optional, Tuple
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def lighting():
"""Lighting operations - create, modify lights and lighting settings."""
pass
@lighting.command("create")
@click.argument("name")
@click.option(
"--type", "-t",
"light_type",
type=click.Choice(["Directional", "Point", "Spot", "Area"]),
default="Point",
help="Type of light to create."
)
@click.option(
"--position", "-pos",
nargs=3,
type=float,
default=(0, 3, 0),
help="Position as X Y Z."
)
@click.option(
"--color", "-c",
nargs=3,
type=float,
default=None,
help="Color as R G B (0-1)."
)
@click.option(
"--intensity", "-i",
default=None,
type=float,
help="Light intensity."
)
@handle_unity_errors
def create(name: str, light_type: str, position: Tuple[float, float, float], color: Optional[Tuple[float, float, float]], intensity: Optional[float]):
"""Create a new light.
\b
Examples:
unity-mcp lighting create "MainLight" --type Directional
unity-mcp lighting create "PointLight1" --position 0 5 0 --intensity 2
unity-mcp lighting create "RedLight" --type Spot --color 1 0 0
"""
config = get_config()
# Step 1: Create empty GameObject with position
create_result = run_command("manage_gameobject", {
"action": "create",
"name": name,
"position": list(position),
}, config)
if not (create_result.get("success")):
click.echo(format_output(create_result, config.format))
return
# Step 2: Add Light component using manage_components
add_result = run_command("manage_components", {
"action": "add",
"target": name,
"componentType": "Light",
}, config)
if not add_result.get("success"):
click.echo(format_output(add_result, config.format))
return
# Step 3: Set light type using manage_components set_property
type_result = run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "Light",
"property": "type",
"value": light_type,
}, config)
if not type_result.get("success"):
click.echo(format_output(type_result, config.format))
return
# Step 4: Set color if provided
if color:
color_result = run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "Light",
"property": "color",
"value": {"r": color[0], "g": color[1], "b": color[2], "a": 1},
}, config)
if not color_result.get("success"):
click.echo(format_output(color_result, config.format))
return
# Step 5: Set intensity if provided
if intensity is not None:
intensity_result = run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "Light",
"property": "intensity",
"value": intensity,
}, config)
if not intensity_result.get("success"):
click.echo(format_output(intensity_result, config.format))
return
# Output the result
click.echo(format_output(create_result, config.format))
print_success(f"Created {light_type} light: {name}")
+239
View File
@@ -0,0 +1,239 @@
"""Material CLI commands."""
import sys
import json
import click
from typing import Optional, Any, Tuple
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_value_safe, parse_json_dict_or_exit
from cli.utils.constants import SEARCH_METHOD_CHOICE_RENDERER
@click.group()
def material():
"""Material operations - create, modify, assign materials."""
pass
@material.command("info")
@click.argument("path")
@handle_unity_errors
def info(path: str):
"""Get information about a material.
\b
Examples:
unity-mcp material info "Assets/Materials/Red.mat"
"""
config = get_config()
result = run_command("manage_material", {
"action": "get_material_info",
"materialPath": path,
}, config)
click.echo(format_output(result, config.format))
@material.command("create")
@click.argument("path")
@click.option(
"--shader", "-s",
default="Standard",
help="Shader to use (default: Standard)."
)
@click.option(
"--properties", "-p",
default=None,
help='Initial properties as JSON.'
)
@handle_unity_errors
def create(path: str, shader: str, properties: Optional[str]):
"""Create a new material.
\b
Examples:
unity-mcp material create "Assets/Materials/NewMat.mat"
unity-mcp material create "Assets/Materials/Red.mat" --shader "Universal Render Pipeline/Lit"
unity-mcp material create "Assets/Materials/Blue.mat" --properties '{"_Color": [0,0,1,1]}'
"""
config = get_config()
params: dict[str, Any] = {
"action": "create",
"materialPath": path,
"shader": shader,
}
if properties:
params["properties"] = parse_json_dict_or_exit(properties, "properties")
result = run_command("manage_material", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created material: {path}")
@material.command("set-color")
@click.argument("path")
@click.argument("r", type=float)
@click.argument("g", type=float)
@click.argument("b", type=float)
@click.argument("a", type=float, default=1.0)
@click.option(
"--property", "-p",
default="_Color",
help="Color property name (default: _Color)."
)
@handle_unity_errors
def set_color(path: str, r: float, g: float, b: float, a: float, property: str):
"""Set a material's color.
\b
Examples:
unity-mcp material set-color "Assets/Materials/Red.mat" 1 0 0
unity-mcp material set-color "Assets/Materials/Blue.mat" 0 0 1 0.5
unity-mcp material set-color "Assets/Materials/Mat.mat" 1 1 0 --property "_BaseColor"
"""
config = get_config()
params: dict[str, Any] = {
"action": "set_material_color",
"materialPath": path,
"property": property,
"color": [r, g, b, a],
}
result = run_command("manage_material", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set color on: {path}")
@material.command("set-property")
@click.argument("path")
@click.argument("property_name")
@click.argument("value")
@handle_unity_errors
def set_property(path: str, property_name: str, value: str):
"""Set a shader property on a material.
\b
Examples:
unity-mcp material set-property "Assets/Materials/Mat.mat" _Metallic 0.5
unity-mcp material set-property "Assets/Materials/Mat.mat" _Smoothness 0.8
unity-mcp material set-property "Assets/Materials/Mat.mat" _MainTex "Assets/Textures/Tex.png"
"""
config = get_config()
# Try to parse value as JSON for complex types
parsed_value = parse_value_safe(value)
params: dict[str, Any] = {
"action": "set_material_shader_property",
"materialPath": path,
"property": property_name,
"value": parsed_value,
}
result = run_command("manage_material", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set {property_name} on: {path}")
@material.command("assign")
@click.argument("material_path")
@click.argument("target")
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_RENDERER,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--slot", "-s",
default=0,
type=int,
help="Material slot index (default: 0)."
)
@click.option(
"--mode", "-m",
type=click.Choice(["shared", "instance", "property_block", "create_unique"]),
default="shared",
help="Assignment mode."
)
@handle_unity_errors
def assign(material_path: str, target: str, search_method: Optional[str], slot: int, mode: str):
"""Assign a material to a GameObject's renderer.
\b
Examples:
unity-mcp material assign "Assets/Materials/Red.mat" "Cube"
unity-mcp material assign "Assets/Materials/Blue.mat" "Player" --mode instance
unity-mcp material assign "Assets/Materials/Mat.mat" "-81840" --search-method by_id --slot 1
"""
config = get_config()
params: dict[str, Any] = {
"action": "assign_material_to_renderer",
"materialPath": material_path,
"target": target,
"slot": slot,
"mode": mode,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_material", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Assigned material to: {target}")
@material.command("set-renderer-color")
@click.argument("target")
@click.argument("r", type=float)
@click.argument("g", type=float)
@click.argument("b", type=float)
@click.argument("a", type=float, default=1.0)
@click.option(
"--search-method",
type=SEARCH_METHOD_CHOICE_RENDERER,
default=None,
help="How to find the target GameObject."
)
@click.option(
"--mode", "-m",
type=click.Choice(["shared", "instance", "property_block", "create_unique"]),
default="property_block",
help="Modification mode (default: property_block — use create_unique for persistent per-object material)."
)
@handle_unity_errors
def set_renderer_color(target: str, r: float, g: float, b: float, a: float, search_method: Optional[str], mode: str):
"""Set a renderer's material color directly.
\b
Examples:
unity-mcp material set-renderer-color "Cube" 1 0 0
unity-mcp material set-renderer-color "Player" 0 1 0 --mode instance
"""
config = get_config()
params: dict[str, Any] = {
"action": "set_renderer_color",
"target": target,
"color": [r, g, b, a],
"mode": mode,
}
if search_method:
params["searchMethod"] = search_method
result = run_command("manage_material", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set renderer color on: {target}")
+236
View File
@@ -0,0 +1,236 @@
"""Package management CLI commands."""
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_info
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def packages():
"""Package management - install, remove, search, registries."""
pass
@packages.command("add")
@click.argument("package")
@handle_unity_errors
def add_package(package: str):
"""Install a package.
\b
Examples:
unity-mcp packages add com.unity.inputsystem
unity-mcp packages add com.unity.inputsystem@1.8.0
unity-mcp packages add https://github.com/user/repo.git
"""
config = get_config()
result = run_command("manage_packages", {"action": "add_package", "package": package}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Installation started. Poll with: unity-mcp packages status {job_id}")
else:
print_success(f"Package added: {package}")
@packages.command("remove")
@click.argument("package")
@click.option("--force", "-f", is_flag=True, help="Force removal even if other packages depend on it.")
@handle_unity_errors
def remove_package(package: str, force: bool):
"""Remove a package.
\b
Examples:
unity-mcp packages remove com.unity.inputsystem
unity-mcp packages remove com.unity.inputsystem --force
"""
config = get_config()
params: dict[str, Any] = {"action": "remove_package", "package": package}
if force:
params["force"] = True
result = run_command("manage_packages", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Removal started. Poll with: unity-mcp packages status {job_id}")
else:
print_success(f"Package removed: {package}")
@packages.command("list")
@handle_unity_errors
def list_packages():
"""List installed packages.
\b
Examples:
unity-mcp packages list
"""
config = get_config()
result = run_command("manage_packages", {"action": "list_packages"}, config)
click.echo(format_output(result, config.format))
@packages.command("search")
@click.argument("query")
@handle_unity_errors
def search_packages(query: str):
"""Search Unity package registry.
\b
Examples:
unity-mcp packages search input
unity-mcp packages search xr
"""
config = get_config()
result = run_command("manage_packages", {"action": "search_packages", "query": query}, config)
click.echo(format_output(result, config.format))
@packages.command("info")
@click.argument("package")
@handle_unity_errors
def get_info(package: str):
"""Get detailed package info.
\b
Examples:
unity-mcp packages info com.unity.inputsystem
"""
config = get_config()
result = run_command("manage_packages", {"action": "get_package_info", "package": package}, config)
click.echo(format_output(result, config.format))
@packages.command("status")
@click.argument("job_id", required=False)
@handle_unity_errors
def status(job_id: Optional[str]):
"""Check package operation status.
\b
Examples:
unity-mcp packages status
unity-mcp packages status abc123
"""
config = get_config()
params: dict[str, Any] = {"action": "status"}
if job_id:
params["job_id"] = job_id
result = run_command("manage_packages", params, config)
click.echo(format_output(result, config.format))
@packages.command("embed")
@click.argument("package")
@handle_unity_errors
def embed_package(package: str):
"""Embed a package for local editing.
\b
Examples:
unity-mcp packages embed com.unity.timeline
"""
config = get_config()
result = run_command("manage_packages", {"action": "embed_package", "package": package}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
job_id = (result.get("data") or {}).get("job_id")
if job_id:
print_info(f"Embedding started. Poll with: unity-mcp packages status {job_id}")
else:
print_success(f"Package embedded: {package}")
@packages.command("resolve")
@handle_unity_errors
def resolve():
"""Force re-resolution of all packages.
\b
Examples:
unity-mcp packages resolve
"""
config = get_config()
result = run_command("manage_packages", {"action": "resolve_packages"}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Packages resolved")
@packages.command("list-registries")
@handle_unity_errors
def list_registries():
"""List all scoped registries.
\b
Examples:
unity-mcp packages list-registries
"""
config = get_config()
result = run_command("manage_packages", {"action": "list_registries"}, config)
click.echo(format_output(result, config.format))
@packages.command("add-registry")
@click.argument("registry_name")
@click.option("--url", required=True, help="Registry URL.")
@click.option("--scope", "-s", multiple=True, required=True, help="Package scope (can specify multiple).")
@handle_unity_errors
def add_registry(registry_name: str, url: str, scope: tuple):
"""Add a scoped registry.
\b
Examples:
unity-mcp packages add-registry OpenUPM --url https://package.openupm.com --scope com.cysharp --scope com.neuecc
"""
config = get_config()
result = run_command("manage_packages", {
"action": "add_registry",
"name": registry_name,
"url": url,
"scopes": list(scope),
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Registry added: {registry_name}")
@packages.command("remove-registry")
@click.argument("registry_name")
@handle_unity_errors
def remove_registry(registry_name: str):
"""Remove a scoped registry.
\b
Examples:
unity-mcp packages remove-registry OpenUPM
"""
config = get_config()
result = run_command("manage_packages", {
"action": "remove_registry",
"name": registry_name,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Registry removed: {registry_name}")
@packages.command("ping")
@handle_unity_errors
def ping():
"""Check package manager status.
\b
Examples:
unity-mcp packages ping
"""
config = get_config()
result = run_command("manage_packages", {"action": "ping"}, config)
click.echo(format_output(result, config.format))
+441
View File
@@ -0,0 +1,441 @@
import click
from cli.utils.connection import handle_unity_errors, run_command, get_config
from cli.utils.output import format_output
def _coerce_cli_value(value: str):
"""Coerce a CLI string value to bool, int, float, or leave as str."""
if value.lower() in ("true", "false"):
return value.lower() == "true"
try:
return float(value) if "." in value else int(value)
except ValueError:
return value
@click.group("physics")
def physics():
"""Manage 3D and 2D physics: settings, collision matrix, materials, joints, queries, validation."""
pass
@physics.command("ping")
@handle_unity_errors
def ping():
"""Check physics system status."""
config = get_config()
result = run_command("manage_physics", {"action": "ping"}, config)
click.echo(format_output(result, config.format))
@physics.command("get-settings")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def get_settings(dimension):
"""Get physics project settings."""
config = get_config()
result = run_command(
"manage_physics", {"action": "get_settings", "dimension": dimension}, config
)
click.echo(format_output(result, config.format))
@physics.command("set-settings")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@click.argument("key")
@click.argument("value")
@handle_unity_errors
def set_settings(dimension, key, value):
"""Set a physics setting (key value)."""
config = get_config()
coerced = _coerce_cli_value(value)
result = run_command(
"manage_physics",
{"action": "set_settings", "dimension": dimension, "settings": {key: coerced}},
config,
)
click.echo(format_output(result, config.format))
@physics.command("get-collision-matrix")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def get_collision_matrix(dimension):
"""Get layer collision matrix."""
config = get_config()
result = run_command(
"manage_physics",
{"action": "get_collision_matrix", "dimension": dimension},
config,
)
click.echo(format_output(result, config.format))
@physics.command("set-collision-matrix")
@click.argument("layer_a")
@click.argument("layer_b")
@click.option("--collide/--ignore", default=True, help="Enable or disable collision.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def set_collision_matrix(layer_a, layer_b, collide, dimension):
"""Set collision between two layers."""
config = get_config()
result = run_command(
"manage_physics",
{
"action": "set_collision_matrix",
"layer_a": layer_a,
"layer_b": layer_b,
"collide": collide,
"dimension": dimension,
},
config,
)
click.echo(format_output(result, config.format))
@physics.command("create-material")
@click.option("--name", "-n", required=True, help="Material name.")
@click.option("--path", "-p", default=None, help="Folder path.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@click.option("--bounciness", "-b", type=float, default=None, help="Bounciness (0-1).")
@click.option("--dynamic-friction", type=float, default=None, help="Dynamic friction.")
@click.option("--static-friction", type=float, default=None, help="Static friction.")
@click.option("--friction", type=float, default=None, help="Friction (2D only).")
@handle_unity_errors
def create_material(name, path, dimension, bounciness, dynamic_friction, static_friction, friction):
"""Create a physics material asset."""
config = get_config()
params = {
"action": "create_physics_material",
"name": name,
"dimension": dimension,
}
if path:
params["path"] = path
if bounciness is not None:
params["bounciness"] = bounciness
if dynamic_friction is not None:
params["dynamic_friction"] = dynamic_friction
if static_friction is not None:
params["static_friction"] = static_friction
if friction is not None:
params["friction"] = friction
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("validate")
@click.option("--target", "-t", default=None, help="Target GameObject (or whole scene).")
@click.option("--dimension", "-d", default="both", help="3d, 2d, or both.")
@handle_unity_errors
def validate(target, dimension):
"""Validate physics setup for common mistakes."""
config = get_config()
params = {"action": "validate", "dimension": dimension}
if target:
params["target"] = target
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("raycast")
@click.option("--origin", "-o", required=True, help="Origin as 'x,y,z'.")
@click.option("--direction", "-d", required=True, help="Direction as 'x,y,z'.")
@click.option("--max-distance", type=float, default=None, help="Max distance.")
@click.option("--dimension", default="3d", help="3d or 2d.")
@handle_unity_errors
def raycast(origin, direction, max_distance, dimension):
"""Perform a physics raycast."""
config = get_config()
params = {
"action": "raycast",
"origin": [float(x) for x in origin.split(",")],
"direction": [float(x) for x in direction.split(",")],
"dimension": dimension,
}
if max_distance is not None:
params["max_distance"] = max_distance
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("simulate")
@click.option("--steps", "-s", type=int, default=1, help="Number of steps (max 100).")
@click.option("--step-size", type=float, default=None, help="Step size in seconds.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def simulate(steps, step_size, dimension):
"""Run physics simulation steps in edit mode."""
config = get_config()
params = {"action": "simulate_step", "steps": steps, "dimension": dimension}
if step_size is not None:
params["step_size"] = step_size
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("configure-material")
@click.option("--path", "-p", required=True, help="Asset path to physics material.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@click.argument("properties", nargs=-1) # key=value pairs like dynamicFriction=0.5
@handle_unity_errors
def configure_material(path, dimension, properties):
"""Configure a physics material asset (key=value ...)."""
config = get_config()
props = {k: _coerce_cli_value(v) for kv in properties if "=" in kv for k, v in [kv.split("=", 1)]}
result = run_command(
"manage_physics",
{"action": "configure_physics_material", "path": path, "dimension": dimension, "properties": props},
config,
)
click.echo(format_output(result, config.format))
@physics.command("assign-material")
@click.option("--target", "-t", required=True, help="Target GameObject name or instance ID.")
@click.option("--material-path", "-m", required=True, help="Path to physics material asset.")
@click.option("--collider-type", default=None, help="Specific collider type.")
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple colliders of the same type exist."
)
@handle_unity_errors
def assign_material(target, material_path, collider_type, component_index):
"""Assign a physics material to a GameObject's collider."""
config = get_config()
params = {
"action": "assign_physics_material",
"target": target,
"material_path": material_path,
}
if collider_type:
params["collider_type"] = collider_type
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("add-joint")
@click.option("--target", "-t", required=True, help="Target GameObject.")
@click.option("--joint-type", "-j", required=True, help="Joint type (e.g. hinge, fixed, spring).")
@click.option("--connected-body", default=None, help="Connected body GameObject.")
@click.option("--dimension", "-d", default=None, help="3d or 2d (auto-detected if omitted).")
@handle_unity_errors
def add_joint(target, joint_type, connected_body, dimension):
"""Add a physics joint to a GameObject."""
config = get_config()
params = {"action": "add_joint", "target": target, "joint_type": joint_type}
if connected_body:
params["connected_body"] = connected_body
if dimension:
params["dimension"] = dimension
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("configure-joint")
@click.option("--target", "-t", required=True, help="Target GameObject.")
@click.option("--joint-type", "-j", default=None, help="Joint type to target.")
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple joints of the same type exist."
)
@click.argument("properties", nargs=-1) # key=value pairs
@handle_unity_errors
def configure_joint(target, joint_type, component_index, properties):
"""Configure a joint on a GameObject (key=value ...)."""
config = get_config()
props = {k: _coerce_cli_value(v) for kv in properties if "=" in kv for k, v in [kv.split("=", 1)]}
params = {"action": "configure_joint", "target": target, "properties": props}
if joint_type:
params["joint_type"] = joint_type
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("remove-joint")
@click.option("--target", "-t", required=True, help="Target GameObject.")
@click.option("--joint-type", "-j", default=None, help="Joint type to remove (omit to remove all).")
@click.option(
"--component-index", "-i",
type=int,
default=None,
help="Zero-based index when multiple joints of the same type exist."
)
@handle_unity_errors
def remove_joint(target, joint_type, component_index):
"""Remove joint(s) from a GameObject."""
config = get_config()
params = {"action": "remove_joint", "target": target}
if joint_type:
params["joint_type"] = joint_type
if component_index is not None:
params["componentIndex"] = component_index
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("overlap")
@click.option("--shape", "-s", required=True, help="Shape: sphere, box, capsule (3D); circle, box, capsule (2D).")
@click.option("--position", "-p", required=True, help="Position as 'x,y,z' or 'x,y'.")
@click.option("--size", required=True, help="Size: float for sphere/circle radius, or 'x,y,z' for box.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def overlap(shape, position, size, dimension):
"""Perform a physics overlap query."""
config = get_config()
pos_parts = [float(x) for x in position.split(",")]
# Try to parse size as float first, then as array
try:
parsed_size = float(size)
except ValueError:
parsed_size = [float(x) for x in size.split(",")]
params = {
"action": "overlap",
"shape": shape,
"position": pos_parts,
"size": parsed_size,
"dimension": dimension,
}
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("raycast-all")
@click.option("--origin", "-o", required=True, help="Origin as 'x,y,z'.")
@click.option("--direction", "-d", required=True, help="Direction as 'x,y,z'.")
@click.option("--max-distance", type=float, default=None, help="Max distance.")
@click.option("--dimension", default="3d", help="3d or 2d.")
@handle_unity_errors
def raycast_all(origin, direction, max_distance, dimension):
"""Perform a multi-hit raycast returning all hits."""
config = get_config()
params = {
"action": "raycast_all",
"origin": [float(x) for x in origin.split(",")],
"direction": [float(x) for x in direction.split(",")],
"dimension": dimension,
}
if max_distance is not None:
params["max_distance"] = max_distance
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("linecast")
@click.option("--start", "-s", required=True, help="Start point as 'x,y,z'.")
@click.option("--end", "-e", required=True, help="End point as 'x,y,z'.")
@click.option("--dimension", "-d", default="3d", help="3d or 2d.")
@handle_unity_errors
def linecast(start, end, dimension):
"""Check if anything intersects the line between two points."""
config = get_config()
params = {
"action": "linecast",
"start": [float(x) for x in start.split(",")],
"end": [float(x) for x in end.split(",")],
"dimension": dimension,
}
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("shapecast")
@click.option("--shape", "-s", required=True, help="Shape: sphere, box, capsule (3D); circle, box, capsule (2D).")
@click.option("--origin", "-o", required=True, help="Origin as 'x,y,z' or 'x,y'.")
@click.option("--direction", "-d", required=True, help="Direction as 'x,y,z' or 'x,y'.")
@click.option("--size", required=True, help="Size: float for sphere/circle radius, or 'x,y,z' for box.")
@click.option("--max-distance", type=float, default=None, help="Max distance.")
@click.option("--dimension", default="3d", help="3d or 2d.")
@handle_unity_errors
def shapecast(shape, origin, direction, size, max_distance, dimension):
"""Cast a shape (sphere, box, capsule) along a direction."""
config = get_config()
try:
parsed_size = float(size)
except ValueError:
parsed_size = [float(x) for x in size.split(",")]
params = {
"action": "shapecast",
"shape": shape,
"origin": [float(x) for x in origin.split(",")],
"direction": [float(x) for x in direction.split(",")],
"size": parsed_size,
"dimension": dimension,
}
if max_distance is not None:
params["max_distance"] = max_distance
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("apply-force")
@click.option("--target", "-t", required=True, help="Target GameObject.")
@click.option("--force", "-f", required=True, help="Force vector as 'x,y,z' or 'x,y'.")
@click.option("--force-mode", default="Force", help="Force, Impulse, Acceleration, VelocityChange.")
@click.option("--dimension", "-d", default=None, help="3d or 2d.")
@click.option("--torque", default=None, help="Torque as 'x,y,z' (3D) or float (2D).")
@click.option("--position", "-p", default=None, help="Point to apply force at, as 'x,y,z'.")
@handle_unity_errors
def apply_force(target, force, force_mode, dimension, torque, position):
"""Apply force to a Rigidbody."""
config = get_config()
params = {
"action": "apply_force",
"target": target,
"force": [float(x) for x in force.split(",")],
"force_mode": force_mode,
}
if dimension:
params["dimension"] = dimension
if torque:
try:
params["torque"] = [float(torque)]
except ValueError:
params["torque"] = [float(x) for x in torque.split(",")]
if position:
params["position"] = [float(x) for x in position.split(",")]
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("get-rigidbody")
@click.argument("target")
@click.option("--dimension", "-d", default=None, help="3d or 2d (auto-detected if omitted).")
@click.option("--search-method", default=None, help="Search method for target resolution.")
@handle_unity_errors
def get_rigidbody(target, dimension, search_method):
"""Get Rigidbody state (mass, velocity, position, etc.)."""
config = get_config()
params = {"action": "get_rigidbody", "target": target}
if dimension:
params["dimension"] = dimension
if search_method:
params["search_method"] = search_method
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
@physics.command("configure-rigidbody")
@click.option("--target", "-t", required=True, help="Target GameObject.")
@click.option("--dimension", "-d", default=None, help="3d or 2d.")
@click.argument("properties", nargs=-1)
@handle_unity_errors
def configure_rigidbody(target, dimension, properties):
"""Configure Rigidbody properties (key=value ...)."""
config = get_config()
props = {k: _coerce_cli_value(v) for kv in properties if "=" in kv for k, v in [kv.split("=", 1)]}
params = {"action": "configure_rigidbody", "target": target, "properties": props}
if dimension:
params["dimension"] = dimension
result = run_command("manage_physics", params, config)
click.echo(format_output(result, config.format))
+370
View File
@@ -0,0 +1,370 @@
"""Prefab CLI commands."""
import json
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def prefab():
"""Prefab operations - info, hierarchy, open, save, close, create prefabs."""
pass
@prefab.command("open")
@click.argument("path")
@handle_unity_errors
def open_stage(path: str):
"""Open a prefab in the prefab stage for editing.
\b
Examples:
unity-mcp prefab open "Assets/Prefabs/Player.prefab"
"""
config = get_config()
params: dict[str, Any] = {
"action": "open_prefab_stage",
"prefabPath": path,
}
result = run_command("manage_prefabs", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Opened prefab: {path}")
@prefab.command("close")
@click.option(
"--save", "-s",
is_flag=True,
help="Save the prefab before closing."
)
@handle_unity_errors
def close_stage(save: bool):
"""Close the current prefab stage.
\b
Examples:
unity-mcp prefab close
unity-mcp prefab close --save
"""
config = get_config()
params: dict[str, Any] = {
"action": "close_prefab_stage",
}
if save:
params["saveBeforeClose"] = True
result = run_command("manage_prefabs", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Closed prefab stage")
@prefab.command("save")
@handle_unity_errors
def save_stage():
"""Save the currently open prefab stage.
\b
Examples:
unity-mcp prefab save
"""
config = get_config()
params: dict[str, Any] = {
"action": "save_prefab_stage",
}
result = run_command("manage_prefabs", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Saved prefab stage")
@prefab.command("info")
@click.argument("path")
@click.option(
"--compact", "-c",
is_flag=True,
help="Show compact output (key values only)."
)
@handle_unity_errors
def info(path: str, compact: bool):
"""Get information about a prefab asset.
\b
Examples:
unity-mcp prefab info "Assets/Prefabs/Player.prefab"
unity-mcp prefab info "Assets/Prefabs/UI.prefab" --compact
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_info",
"prefabPath": path,
}
result = run_command("manage_prefabs", params, config)
# Get the actual response data from the wrapped result structure
response_data = result.get("result", result)
if compact and response_data.get("success") and response_data.get("data"):
data = response_data["data"]
click.echo(f"Prefab: {data.get('assetPath', path)}")
click.echo(f" Type: {data.get('prefabType', 'Unknown')}")
click.echo(f" Root: {data.get('rootObjectName', 'N/A')}")
click.echo(f" GUID: {data.get('guid', 'N/A')}")
click.echo(
f" Components: {len(data.get('rootComponentTypes', []))}")
click.echo(f" Children: {data.get('childCount', 0)}")
if data.get('isVariant'):
click.echo(f" Variant of: {data.get('parentPrefab', 'N/A')}")
else:
click.echo(format_output(result, config.format))
@prefab.command("hierarchy")
@click.argument("path")
@click.option(
"--compact", "-c",
is_flag=True,
help="Show compact output (names and paths only)."
)
@click.option(
"--show-prefab-info", "-p",
is_flag=True,
help="Show prefab nesting information."
)
@handle_unity_errors
def hierarchy(path: str, compact: bool, show_prefab_info: bool):
"""Get the hierarchical structure of a prefab.
\b
Examples:
unity-mcp prefab hierarchy "Assets/Prefabs/Player.prefab"
unity-mcp prefab hierarchy "Assets/Prefabs/UI.prefab" --compact
unity-mcp prefab hierarchy "Assets/Prefabs/Complex.prefab" --show-prefab-info
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_hierarchy",
"prefabPath": path,
}
result = run_command("manage_prefabs", params, config)
# Get the actual response data from the wrapped result structure
response_data = result.get("result", result)
if compact and response_data.get("success") and response_data.get("data"):
data = response_data["data"]
items = data.get("items", [])
for item in items:
indent = " " * item.get("path", "").count("/")
prefab_info = ""
if show_prefab_info and item.get("prefab", {}).get("isNestedRoot"):
prefab_info = f" [nested: {item['prefab']['assetPath']}]"
click.echo(f"{indent}{item.get('name')}{prefab_info}")
click.echo(f"\nTotal: {data.get('total', 0)} objects")
elif show_prefab_info:
# Show prefab info in readable format
if response_data.get("success") and response_data.get("data"):
data = response_data["data"]
items = data.get("items", [])
for item in items:
prefab = item.get("prefab", {})
prefab_info = ""
if prefab.get("isRoot"):
prefab_info = " [root]"
elif prefab.get("isNestedRoot"):
prefab_info = f" [nested: {prefab.get('nestingDepth', 0)}]"
click.echo(f"{item.get('path')}{prefab_info}")
click.echo(f"\nTotal: {data.get('total', 0)} objects")
else:
click.echo(format_output(result, config.format))
else:
click.echo(format_output(result, config.format))
@prefab.command("create")
@click.argument("target")
@click.argument("path")
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing prefab at path."
)
@click.option(
"--include-inactive",
is_flag=True,
help="Include inactive objects when finding target."
)
@click.option(
"--unlink-if-instance",
is_flag=True,
help="Unlink from existing prefab before creating new one."
)
@handle_unity_errors
def create(target: str, path: str, overwrite: bool, include_inactive: bool, unlink_if_instance: bool):
"""Create a prefab from a scene GameObject.
\b
Examples:
unity-mcp prefab create "Player" "Assets/Prefabs/Player.prefab"
unity-mcp prefab create "Enemy" "Assets/Prefabs/Enemy.prefab" --overwrite
unity-mcp prefab create "EnemyInstance" "Assets/Prefabs/BossEnemy.prefab" --unlink-if-instance
"""
config = get_config()
params: dict[str, Any] = {
"action": "create_from_gameobject",
"target": target,
"prefabPath": path,
}
if overwrite:
params["allowOverwrite"] = True
if include_inactive:
params["searchInactive"] = True
if unlink_if_instance:
params["unlinkIfInstance"] = True
result = run_command("manage_prefabs", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created prefab: {path}")
def _parse_vector3(value: str) -> list[float]:
"""Parse 'x,y,z' string to list of floats."""
parts = value.split(",")
if len(parts) != 3:
raise click.BadParameter("Must be 'x,y,z' format")
try:
return [float(p.strip()) for p in parts]
except ValueError as e:
raise click.BadParameter(f"All components must be numeric, got: '{value}'") from e
def _parse_property(prop_str: str) -> tuple[str, str, Any]:
"""Parse 'Component.prop=value' into (component, prop, value)."""
if "=" not in prop_str:
raise click.BadParameter("Must be 'Component.prop=value' format")
comp_prop, val_str = prop_str.split("=", 1)
if "." not in comp_prop:
raise click.BadParameter("Must be 'Component.prop=value' format")
component, prop = comp_prop.rsplit(".", 1)
if not component.strip() or not prop.strip():
raise click.BadParameter(f"Component and property must be non-empty in '{comp_prop}', expected 'Component.prop=value'")
val_str = val_str.strip()
# Parse booleans
if val_str.lower() == "true":
parsed_value: Any = True
elif val_str.lower() == "false":
parsed_value = False
# Parse numbers
elif "." in val_str:
try:
parsed_value = float(val_str)
except ValueError:
parsed_value = val_str
else:
try:
parsed_value = int(val_str)
except ValueError:
parsed_value = val_str
return component.strip(), prop.strip(), parsed_value
@prefab.command("modify")
@click.argument("path")
@click.option("--target", "-t", help="Target object name/path within prefab (default: root)")
@click.option("--position", "-p", help="New local position as 'x,y,z'")
@click.option("--rotation", "-r", help="New local rotation as 'x,y,z'")
@click.option("--scale", "-s", help="New local scale as 'x,y,z'")
@click.option("--name", "-n", help="New name for target")
@click.option("--tag", help="New tag")
@click.option("--layer", help="New layer")
@click.option("--active/--inactive", default=None, help="Set active state")
@click.option("--parent", help="New parent object name/path")
@click.option("--add-component", multiple=True, help="Component type to add (repeatable)")
@click.option("--remove-component", multiple=True, help="Component type to remove (repeatable)")
@click.option("--set-property", multiple=True, help="Property as 'Component.prop=value' (repeatable)")
@click.option("--delete-child", multiple=True, help="Child name/path to remove (repeatable)")
@click.option("--create-child", help="JSON object for child creation")
@handle_unity_errors
def modify(path: str, target: Optional[str], position: Optional[str], rotation: Optional[str],
scale: Optional[str], name: Optional[str], tag: Optional[str], layer: Optional[str],
active: Optional[bool], parent: Optional[str], add_component: tuple, remove_component: tuple,
set_property: tuple, delete_child: tuple, create_child: Optional[str]):
"""Modify a prefab's contents (headless, no UI).
\b
Examples:
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child Child1
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --delete-child "Turret/Barrel" --delete-child Bullet
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --target Weapon --position "0,1,2"
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --set-property "Rigidbody.mass=5"
unity-mcp prefab modify "Assets/Prefabs/Player.prefab" --create-child '{"name":"Spawn","primitive_type":"Sphere"}'
"""
config = get_config()
params: dict[str, Any] = {
"action": "modify_contents",
"prefabPath": path,
}
if target:
params["target"] = target
if position:
params["position"] = _parse_vector3(position)
if rotation:
params["rotation"] = _parse_vector3(rotation)
if scale:
params["scale"] = _parse_vector3(scale)
if name:
params["name"] = name
if tag:
params["tag"] = tag
if layer:
params["layer"] = layer
if active is not None:
params["setActive"] = active
if parent:
params["parent"] = parent
if add_component:
params["componentsToAdd"] = list(add_component)
if remove_component:
params["componentsToRemove"] = list(remove_component)
if set_property:
component_properties: dict[str, dict[str, Any]] = {}
for prop in set_property:
comp, name_p, val = _parse_property(prop)
if comp not in component_properties:
component_properties[comp] = {}
component_properties[comp][name_p] = val
params["componentProperties"] = component_properties
if delete_child:
params["deleteChild"] = list(delete_child)
if create_child:
try:
parsed = json.loads(create_child)
except json.JSONDecodeError as e:
raise click.BadParameter(f"Invalid JSON for --create-child: {e}") from e
if not isinstance(parsed, dict):
raise click.BadParameter(f"--create-child must be a JSON object, got {type(parsed).__name__}")
params["createChild"] = parsed
result = run_command("manage_prefabs", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Modified prefab: {path}")
+715
View File
@@ -0,0 +1,715 @@
"""ProBuilder CLI commands for managing Unity ProBuilder meshes."""
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_dict_or_exit, parse_json_list_or_exit
from cli.utils.constants import SEARCH_METHOD_CHOICE_TAGGED
_PB_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"}
def _parse_edges_param(edges: str) -> dict[str, Any]:
"""Parse edge JSON into either 'edges' (vertex pairs) or 'edgeIndices' (flat indices)."""
import json
try:
parsed = json.loads(edges)
except json.JSONDecodeError:
print_error("Invalid JSON for edges parameter")
raise SystemExit(1)
if parsed and isinstance(parsed[0], dict):
return {"edges": parsed}
return {"edgeIndices": parsed}
def _normalize_pb_params(params: dict[str, Any]) -> dict[str, Any]:
params = dict(params)
properties: dict[str, Any] = {}
for key in list(params.keys()):
if key in _PB_TOP_LEVEL_KEYS:
continue
properties[key] = params.pop(key)
if properties:
existing = params.get("properties")
if isinstance(existing, dict):
params["properties"] = {**properties, **existing}
else:
params["properties"] = properties
return {k: v for k, v in params.items() if v is not None}
@click.group()
def probuilder():
"""ProBuilder operations - 3D modeling, mesh editing, UV management."""
pass
# =============================================================================
# Shape Creation
# =============================================================================
@probuilder.command("create-shape")
@click.argument("shape_type")
@click.option("--name", "-n", default=None, help="Name for the created GameObject.")
@click.option("--position", nargs=3, type=float, default=None, help="Position X Y Z.")
@click.option("--rotation", nargs=3, type=float, default=None, help="Rotation X Y Z (euler).")
@click.option("--params", "-p", default="{}", help="Shape-specific parameters as JSON.")
@handle_unity_errors
def create_shape(shape_type: str, name: Optional[str], position, rotation, params: str):
"""Create a ProBuilder shape with real dimensions.
\\b
Shape types: Cube, Cylinder, Sphere, Plane, Cone, Torus, Pipe, Arch,
Stair, CurvedStair, Door, Prism
Each shape accepts type-specific dimension parameters:
Cube/Prism: width, height, depth (or size for uniform)
Cylinder: radius, height, segments/axisDivisions, heightCuts
Cone: radius, height, segments/subdivAxis
Sphere: radius, subdivisions
Torus: innerRadius, outerRadius, rows, columns
Pipe: radius, height, thickness, segments
Plane: width, height, widthCuts, heightCuts
Stair: width, height, depth, steps, buildSides
CurvedStair: width, height, innerRadius, circumference, steps
Arch: radius, width, depth, angle, radialCuts
Door: width, height, depth, ledgeHeight, legWidth
\\b
Examples:
unity-mcp probuilder create-shape Cube
unity-mcp probuilder create-shape Cube --params '{"width": 2, "height": 3, "depth": 1}'
unity-mcp probuilder create-shape Cylinder --params '{"radius": 0.5, "height": 3, "segments": 16}'
unity-mcp probuilder create-shape Torus --name "MyTorus" --params '{"innerRadius": 0.2, "outerRadius": 1}'
unity-mcp probuilder create-shape Stair --position 0 0 5 --params '{"steps": 10, "width": 2}'
"""
config = get_config()
extra = parse_json_dict_or_exit(params, "params")
request: dict[str, Any] = {
"action": "create_shape",
"shapeType": shape_type,
}
if name:
request["name"] = name
if position:
request["position"] = list(position)
if rotation:
request["rotation"] = list(rotation)
request.update(extra)
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created ProBuilder {shape_type}")
@probuilder.command("create-poly")
@click.option("--points", "-p", required=True, help='Points as JSON: [[x,y,z], ...]')
@click.option("--height", "-h", type=float, default=1.0, help="Extrude height.")
@click.option("--name", "-n", default=None, help="Name for the created GameObject.")
@click.option("--flip-normals", is_flag=True, help="Flip face normals.")
@handle_unity_errors
def create_poly(points: str, height: float, name: Optional[str], flip_normals: bool):
"""Create a ProBuilder mesh from a 2D polygon footprint.
\\b
Examples:
unity-mcp probuilder create-poly --points "[[0,0,0],[5,0,0],[5,0,5],[0,0,5]]" --height 3
"""
config = get_config()
points_list = parse_json_list_or_exit(points, "points")
request: dict[str, Any] = {
"action": "create_poly_shape",
"points": points_list,
"extrudeHeight": height,
}
if name:
request["name"] = name
if flip_normals:
request["flipNormals"] = True
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Created ProBuilder poly shape")
# =============================================================================
# Mesh Editing
# =============================================================================
@probuilder.command("extrude-faces")
@click.argument("target")
@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.")
@click.option("--distance", "-d", type=float, default=0.5, help="Extrusion distance.")
@click.option("--method", type=click.Choice(["FaceNormal", "VertexNormal", "IndividualFaces"]),
default="FaceNormal", help="Extrusion method.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def extrude_faces(target: str, faces: str, distance: float, method: str,
search_method: Optional[str]):
"""Extrude faces of a ProBuilder mesh.
\\b
Examples:
unity-mcp probuilder extrude-faces "MyCube" --faces '[0]' --distance 1.0
unity-mcp probuilder extrude-faces "MyCube" --faces '[0,1,2]' --method IndividualFaces
"""
config = get_config()
face_indices = parse_json_list_or_exit(faces, "faces")
request: dict[str, Any] = {
"action": "extrude_faces",
"target": target,
"faceIndices": face_indices,
"distance": distance,
"method": method,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Extruded faces by {distance}")
@probuilder.command("extrude-edges")
@click.argument("target")
@click.option("--edges", required=True,
help='Edge indices as JSON array [0,1] or vertex pairs [{"a":0,"b":1}].')
@click.option("--distance", "-d", type=float, default=0.5, help="Extrusion distance.")
@click.option("--as-group/--no-group", default=True, help="Extrude as group.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def extrude_edges(target: str, edges: str, distance: float, as_group: bool,
search_method: Optional[str]):
"""Extrude edges of a ProBuilder mesh.
\\b
Edges can be specified as flat indices into the unique edge list,
or as vertex pairs [{a: 0, b: 1}, ...].
\\b
Examples:
unity-mcp probuilder extrude-edges "MyCube" --edges '[0,1]' --distance 0.5
unity-mcp probuilder extrude-edges "MyCube" --edges '[{"a":0,"b":1}]' --distance 1
"""
config = get_config()
request: dict[str, Any] = {
"action": "extrude_edges",
"target": target,
"distance": distance,
"asGroup": as_group,
**_parse_edges_param(edges),
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Extruded edges by {distance}")
@probuilder.command("bevel-edges")
@click.argument("target")
@click.option("--edges", required=True,
help='Edge indices as JSON array [0,1] or vertex pairs [{"a":0,"b":1}].')
@click.option("--amount", "-a", type=float, default=0.1, help="Bevel amount (0-1).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def bevel_edges(target: str, edges: str, amount: float, search_method: Optional[str]):
"""Bevel edges of a ProBuilder mesh.
\\b
Examples:
unity-mcp probuilder bevel-edges "MyCube" --edges '[0,1,2]' --amount 0.2
unity-mcp probuilder bevel-edges "MyCube" --edges '[{"a":0,"b":1}]' --amount 0.15
"""
config = get_config()
request: dict[str, Any] = {
"action": "bevel_edges",
"target": target,
"amount": amount,
**_parse_edges_param(edges),
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Beveled edges with amount {amount}")
@probuilder.command("delete-faces")
@click.argument("target")
@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def delete_faces(target: str, faces: str, search_method: Optional[str]):
"""Delete faces from a ProBuilder mesh.
\\b
Examples:
unity-mcp probuilder delete-faces "MyCube" --faces '[0,1]'
"""
config = get_config()
face_indices = parse_json_list_or_exit(faces, "faces")
request: dict[str, Any] = {
"action": "delete_faces",
"target": target,
"faceIndices": face_indices,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Deleted faces")
@probuilder.command("subdivide")
@click.argument("target")
@click.option("--faces", default=None, help="Face indices as JSON array (optional, subdivides all if omitted).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def subdivide(target: str, faces: Optional[str], search_method: Optional[str]):
"""Subdivide faces of a ProBuilder mesh.
\\b
Examples:
unity-mcp probuilder subdivide "MyCube"
unity-mcp probuilder subdivide "MyCube" --faces '[0,1]'
"""
config = get_config()
request: dict[str, Any] = {
"action": "subdivide",
"target": target,
}
if faces:
request["faceIndices"] = parse_json_list_or_exit(faces, "faces")
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Subdivided mesh")
@probuilder.command("select-faces")
@click.argument("target")
@click.option("--direction", type=click.Choice(["up", "down", "forward", "back", "left", "right"]),
default=None, help="Select faces by normal direction.")
@click.option("--tolerance", type=float, default=0.7, help="Dot product tolerance for direction (0-1).")
@click.option("--grow-from", default=None, help="Face indices to grow selection from (JSON array).")
@click.option("--grow-angle", type=float, default=-1, help="Max angle for grow selection (-1=any).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def select_faces(target: str, direction: Optional[str], tolerance: float,
grow_from: Optional[str], grow_angle: float,
search_method: Optional[str]):
"""Select faces by criteria (direction, grow, flood, loop).
\\b
Examples:
unity-mcp probuilder select-faces "MyCube" --direction up
unity-mcp probuilder select-faces "MyCube" --direction forward --tolerance 0.9
unity-mcp probuilder select-faces "MyCube" --grow-from '[0]' --grow-angle 45
"""
config = get_config()
request: dict[str, Any] = {
"action": "select_faces",
"target": target,
}
if direction:
request["direction"] = direction
if tolerance != 0.7:
request["tolerance"] = tolerance
if grow_from:
request["growFrom"] = parse_json_list_or_exit(grow_from, "grow-from")
if grow_angle != -1:
request["growAngle"] = grow_angle
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
@probuilder.command("move-vertices")
@click.argument("target")
@click.option("--vertices", required=True, help="Vertex indices as JSON array, e.g. '[0,1,2]'.")
@click.option("--offset", nargs=3, type=float, required=True, help="Offset X Y Z.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def move_vertices(target: str, vertices: str, offset, search_method: Optional[str]):
"""Move vertices by an offset.
\\b
Examples:
unity-mcp probuilder move-vertices "MyCube" --vertices '[0,1,2,3]' --offset 0 1 0
"""
config = get_config()
vertex_indices = parse_json_list_or_exit(vertices, "vertices")
request: dict[str, Any] = {
"action": "move_vertices",
"target": target,
"vertexIndices": vertex_indices,
"offset": list(offset),
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Moved vertices")
@probuilder.command("weld-vertices")
@click.argument("target")
@click.option("--vertices", required=True, help="Vertex indices as JSON array.")
@click.option("--radius", "-r", type=float, default=0.01, help="Neighbor radius for welding.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def weld_vertices(target: str, vertices: str, radius: float,
search_method: Optional[str]):
"""Weld vertices within a proximity radius.
\\b
Examples:
unity-mcp probuilder weld-vertices "MyCube" --vertices '[0,1,2,3]' --radius 0.1
"""
config = get_config()
vertex_indices = parse_json_list_or_exit(vertices, "vertices")
request: dict[str, Any] = {
"action": "weld_vertices",
"target": target,
"vertexIndices": vertex_indices,
"radius": radius,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Welded vertices")
@probuilder.command("set-material")
@click.argument("target")
@click.option("--faces", required=True, help="Face indices as JSON array.")
@click.option("--material", "-m", required=True, help="Material asset path.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def set_material(target: str, faces: str, material: str,
search_method: Optional[str]):
"""Assign a material to specific faces.
\\b
Examples:
unity-mcp probuilder set-material "MyCube" --faces '[0,1]' --material "Assets/Materials/Red.mat"
"""
config = get_config()
face_indices = parse_json_list_or_exit(faces, "faces")
request: dict[str, Any] = {
"action": "set_face_material",
"target": target,
"faceIndices": face_indices,
"materialPath": material,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Set material on faces")
# =============================================================================
# Mesh Info
# =============================================================================
@probuilder.command("info")
@click.argument("target")
@click.option("--include", type=click.Choice(["summary", "faces", "edges", "all"]),
default="summary", help="Detail level: summary, faces, edges, or all.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def mesh_info(target: str, include: str, search_method: Optional[str]):
"""Get ProBuilder mesh info.
\\b
Edge data now includes world-space vertex positions and uses deduplicated edges.
\\b
Examples:
unity-mcp probuilder info "MyCube"
unity-mcp probuilder info "MyCube" --include faces
unity-mcp probuilder info "-12345" --search-method by_id --include all
"""
config = get_config()
request: dict[str, Any] = {"action": "get_mesh_info", "target": target, "include": include}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
# =============================================================================
# Smoothing
# =============================================================================
@probuilder.command("auto-smooth")
@click.argument("target")
@click.option("--angle", type=float, default=30.0, help="Angle threshold in degrees (default: 30).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def auto_smooth(target: str, angle: float, search_method: Optional[str]):
"""Auto-assign smoothing groups by angle threshold.
\\b
Examples:
unity-mcp probuilder auto-smooth "MyCube"
unity-mcp probuilder auto-smooth "MyCube" --angle 45
"""
config = get_config()
request: dict[str, Any] = {
"action": "auto_smooth",
"target": target,
"angleThreshold": angle,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Auto-smoothed with angle {angle}°")
@probuilder.command("set-smoothing")
@click.argument("target")
@click.option("--faces", required=True, help="Face indices as JSON array, e.g. '[0,1,2]'.")
@click.option("--group", type=int, required=True, help="Smoothing group (0=hard, 1+=smooth).")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def set_smoothing(target: str, faces: str, group: int, search_method: Optional[str]):
"""Set smoothing group on specific faces.
\\b
Examples:
unity-mcp probuilder set-smoothing "MyCube" --faces '[0,1,2]' --group 1
unity-mcp probuilder set-smoothing "MyCube" --faces '[3,4,5]' --group 0
"""
config = get_config()
face_indices = parse_json_list_or_exit(faces, "faces")
request: dict[str, Any] = {
"action": "set_smoothing",
"target": target,
"faceIndices": face_indices,
"smoothingGroup": group,
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set smoothing group {group}")
# =============================================================================
# Mesh Utilities
# =============================================================================
@probuilder.command("center-pivot")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def center_pivot(target: str, search_method: Optional[str]):
"""Move pivot point to mesh bounds center.
\\b
Examples:
unity-mcp probuilder center-pivot "MyCube"
"""
config = get_config()
request: dict[str, Any] = {"action": "center_pivot", "target": target}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Pivot centered")
@probuilder.command("set-pivot")
@click.argument("target")
@click.option("--position", nargs=3, type=float, required=True, help="World position X Y Z.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def set_pivot(target: str, position, search_method: Optional[str]):
"""Set pivot to an arbitrary world position.
\\b
Examples:
unity-mcp probuilder set-pivot "MyCube" --position 0 0 0
unity-mcp probuilder set-pivot "MyCube" --position 1.5 0 2.3
"""
config = get_config()
request: dict[str, Any] = {
"action": "set_pivot",
"target": target,
"position": list(position),
}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Pivot set")
@probuilder.command("freeze-transform")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def freeze_transform(target: str, search_method: Optional[str]):
"""Bake position/rotation/scale into vertex data, reset transform.
\\b
Examples:
unity-mcp probuilder freeze-transform "MyCube"
"""
config = get_config()
request: dict[str, Any] = {"action": "freeze_transform", "target": target}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Transform frozen")
@probuilder.command("validate")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def validate_mesh(target: str, search_method: Optional[str]):
"""Check mesh health (degenerate triangles, unused vertices).
\\b
Examples:
unity-mcp probuilder validate "MyCube"
"""
config = get_config()
request: dict[str, Any] = {"action": "validate_mesh", "target": target}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
@probuilder.command("repair")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def repair_mesh(target: str, search_method: Optional[str]):
"""Auto-fix degenerate triangles and unused vertices.
\\b
Examples:
unity-mcp probuilder repair "MyCube"
"""
config = get_config()
request: dict[str, Any] = {"action": "repair_mesh", "target": target}
if search_method:
request["searchMethod"] = search_method
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Mesh repaired")
# =============================================================================
# Raw Command (escape hatch)
# =============================================================================
@probuilder.command("raw")
@click.argument("action")
@click.argument("target", required=False)
@click.option("--params", "-p", default="{}", help="Additional parameters as JSON.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@handle_unity_errors
def pb_raw(action: str, target: Optional[str], params: str, search_method: Optional[str]):
"""Execute any ProBuilder action directly.
\\b
Actions include:
create_shape, create_poly_shape,
extrude_faces, extrude_edges, bevel_edges, subdivide,
delete_faces, bridge_edges, connect_elements, detach_faces,
flip_normals, merge_faces, combine_meshes, merge_objects,
duplicate_and_flip, create_polygon,
merge_vertices, weld_vertices, split_vertices, move_vertices,
insert_vertex, append_vertices_to_edge,
select_faces,
set_face_material, set_face_color, set_face_uvs,
get_mesh_info, convert_to_probuilder,
set_smoothing, auto_smooth,
center_pivot, set_pivot, freeze_transform, validate_mesh, repair_mesh
\\b
Examples:
unity-mcp probuilder raw extrude_faces "MyCube" --params '{"faceIndices": [0], "distance": 1.0}'
unity-mcp probuilder raw bevel_edges "MyCube" --params '{"edges": [{"a":0,"b":1}], "amount": 0.2}'
unity-mcp probuilder raw detach_faces "MyCube" --params '{"faceIndices": [0], "deleteSourceFaces": true}'
unity-mcp probuilder raw weld_vertices "MyCube" --params '{"vertexIndices": [0,1,2], "radius": 0.1}'
unity-mcp probuilder raw select_faces "MyCube" --params '{"direction": "up", "tolerance": 0.9}'
unity-mcp probuilder raw insert_vertex "MyCube" --params '{"edge": {"a":0,"b":1}, "point": [0.5,0,0]}'
unity-mcp probuilder raw set_pivot "MyCube" --params '{"position": [0, 0, 0]}'
"""
config = get_config()
extra = parse_json_dict_or_exit(params, "params")
request: dict[str, Any] = {"action": action}
if target:
request["target"] = target
if search_method:
request["searchMethod"] = search_method
request.update(extra)
result = run_command("manage_probuilder", _normalize_pb_params(request), config)
click.echo(format_output(result, config.format))
+170
View File
@@ -0,0 +1,170 @@
import click
from cli.utils.connection import handle_unity_errors, run_command, get_config
from cli.utils.output import format_output
@click.group("profiler")
def profiler():
"""Unity Profiler session control, counter reads, memory snapshots, and Frame Debugger."""
pass
# --- Session ---
@profiler.command("start")
@click.option("--log-file", default=None, help="Path to .raw file for recording.")
@click.option("--callstacks", is_flag=True, default=False, help="Enable allocation callstacks.")
@handle_unity_errors
def start(log_file, callstacks):
"""Start the Unity Profiler, optionally record to a .raw file."""
config = get_config()
params = {"action": "profiler_start"}
if log_file:
params["log_file"] = log_file
if callstacks:
params["enable_callstacks"] = True
result = run_command("manage_profiler", params, config)
click.echo(format_output(result, config.format))
@profiler.command("stop")
@handle_unity_errors
def stop():
"""Stop the Unity Profiler and any active recording."""
config = get_config()
result = run_command("manage_profiler", {"action": "profiler_stop"}, config)
click.echo(format_output(result, config.format))
@profiler.command("status")
@handle_unity_errors
def status():
"""Get Profiler enabled state, active areas, and recording status."""
config = get_config()
result = run_command("manage_profiler", {"action": "profiler_status"}, config)
click.echo(format_output(result, config.format))
@profiler.command("set-areas")
@click.option("--area", multiple=True, help="Area=bool pairs (e.g. CPU=true Audio=false).")
@handle_unity_errors
def set_areas(area):
"""Toggle specific ProfilerAreas on or off."""
config = get_config()
areas = {}
for a in area:
name, _, val = a.partition("=")
areas[name.strip()] = val.strip().lower() in ("true", "1", "yes")
result = run_command("manage_profiler", {"action": "profiler_set_areas", "areas": areas}, config)
click.echo(format_output(result, config.format))
# --- Counters ---
@profiler.command("frame-timing")
@handle_unity_errors
def frame_timing():
"""Get frame timing via FrameTimingManager (12 fields, synchronous)."""
config = get_config()
result = run_command("manage_profiler", {"action": "get_frame_timing"}, config)
click.echo(format_output(result, config.format))
@profiler.command("get-counters")
@click.option("--category", required=True, help="Profiler category (e.g. Render, Scripts, Memory).")
@click.option("--counter", multiple=True, help="Specific counter names. Omit to read all in category.")
@handle_unity_errors
def get_counters(category, counter):
"""Read profiler counters by category (async, 1-frame wait)."""
config = get_config()
params = {"action": "get_counters", "category": category}
if counter:
params["counters"] = list(counter)
result = run_command("manage_profiler", params, config)
click.echo(format_output(result, config.format))
@profiler.command("object-memory")
@click.option("--path", required=True, help="Scene hierarchy or asset path.")
@handle_unity_errors
def object_memory(path):
"""Get native memory size of a specific Unity object."""
config = get_config()
result = run_command("manage_profiler", {"action": "get_object_memory", "object_path": path}, config)
click.echo(format_output(result, config.format))
# --- Memory Snapshot ---
@profiler.command("memory-snapshot")
@click.option("--path", default=None, help="Output .snap file path (default: auto-generated).")
@handle_unity_errors
def memory_snapshot(path):
"""Take a memory snapshot (requires com.unity.memoryprofiler)."""
config = get_config()
params = {"action": "memory_take_snapshot"}
if path:
params["snapshot_path"] = path
result = run_command("manage_profiler", params, config)
click.echo(format_output(result, config.format))
@profiler.command("memory-list")
@click.option("--search-path", default=None, help="Directory to search for snapshots.")
@handle_unity_errors
def memory_list(search_path):
"""List available memory snapshot files."""
config = get_config()
params = {"action": "memory_list_snapshots"}
if search_path:
params["search_path"] = search_path
result = run_command("manage_profiler", params, config)
click.echo(format_output(result, config.format))
@profiler.command("memory-compare")
@click.option("--a", "snapshot_a", required=True, help="First snapshot path.")
@click.option("--b", "snapshot_b", required=True, help="Second snapshot path.")
@handle_unity_errors
def memory_compare(snapshot_a, snapshot_b):
"""Compare two memory snapshots."""
config = get_config()
result = run_command("manage_profiler", {
"action": "memory_compare_snapshots",
"snapshot_a": snapshot_a, "snapshot_b": snapshot_b,
}, config)
click.echo(format_output(result, config.format))
# --- Frame Debugger ---
@profiler.command("frame-debugger-enable")
@handle_unity_errors
def frame_debugger_enable():
"""Enable the Frame Debugger and report event count."""
config = get_config()
result = run_command("manage_profiler", {"action": "frame_debugger_enable"}, config)
click.echo(format_output(result, config.format))
@profiler.command("frame-debugger-disable")
@handle_unity_errors
def frame_debugger_disable():
"""Disable the Frame Debugger."""
config = get_config()
result = run_command("manage_profiler", {"action": "frame_debugger_disable"}, config)
click.echo(format_output(result, config.format))
@profiler.command("frame-debugger-events")
@click.option("--page-size", default=50, help="Events per page (default 50).")
@click.option("--cursor", default=None, type=int, help="Cursor offset.")
@handle_unity_errors
def frame_debugger_events(page_size, cursor):
"""Get Frame Debugger draw call events (paged)."""
config = get_config()
params = {"action": "frame_debugger_get_events", "page_size": page_size}
if cursor is not None:
params["cursor"] = cursor
result = run_command("manage_profiler", params, config)
click.echo(format_output(result, config.format))
+72
View File
@@ -0,0 +1,72 @@
"""Unity API reflection CLI commands."""
import click
from cli.utils.config import get_config
from cli.utils.output import format_output
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def reflect():
"""Inspect Unity C# APIs via reflection."""
pass
@reflect.command("type")
@click.argument("class_name")
@handle_unity_errors
def get_type(class_name: str):
"""Get member summary for a Unity type.
\b
Examples:
unity-mcp reflect type NavMeshAgent
unity-mcp reflect type UnityEngine.Physics
"""
config = get_config()
result = run_command("unity_reflect", {"action": "get_type", "class_name": class_name}, config)
click.echo(format_output(result, config.format))
@reflect.command("member")
@click.argument("class_name")
@click.argument("member_name")
@handle_unity_errors
def get_member(class_name: str, member_name: str):
"""Get detailed info for a specific member.
\b
Examples:
unity-mcp reflect member Physics Raycast
unity-mcp reflect member NavMeshAgent SetDestination
"""
config = get_config()
result = run_command("unity_reflect", {
"action": "get_member",
"class_name": class_name,
"member_name": member_name,
}, config)
click.echo(format_output(result, config.format))
@reflect.command("search")
@click.argument("query")
@click.option("--scope", "-s", default="unity", type=click.Choice(["unity", "packages", "project", "all"]),
help="Assembly scope to search.")
@handle_unity_errors
def search(query: str, scope: str):
"""Search for Unity types by name.
\b
Examples:
unity-mcp reflect search NavMesh
unity-mcp reflect search Camera --scope all
"""
config = get_config()
result = run_command("unity_reflect", {
"action": "search",
"query": query,
"scope": scope,
}, config)
click.echo(format_output(result, config.format))
+352
View File
@@ -0,0 +1,352 @@
"""Scene CLI commands."""
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success, print_warning
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def scene():
"""Scene operations - hierarchy, load, save, create, multi-scene, validation."""
pass
@scene.command("hierarchy")
@click.option(
"--parent",
default=None,
help="Parent GameObject to list children of (name, path, or instance ID)."
)
@click.option(
"--max-depth", "-d",
default=None,
type=int,
help="Maximum depth to traverse."
)
@click.option(
"--include-transform", "-t",
is_flag=True,
help="Include transform data for each node."
)
@click.option(
"--limit", "-l",
default=50,
type=int,
help="Maximum nodes to return."
)
@click.option(
"--cursor", "-c",
default=0,
type=int,
help="Pagination cursor."
)
@handle_unity_errors
def hierarchy(
parent: Optional[str],
max_depth: Optional[int],
include_transform: bool,
limit: int,
cursor: int,
):
"""Get the scene hierarchy.
\b
Examples:
unity-mcp scene hierarchy
unity-mcp scene hierarchy --max-depth 3
unity-mcp scene hierarchy --parent "Canvas" --include-transform
unity-mcp scene hierarchy --format json
"""
config = get_config()
params: dict[str, Any] = {
"action": "get_hierarchy",
"pageSize": limit,
"cursor": cursor,
}
if parent:
params["parent"] = parent
if max_depth is not None:
params["maxDepth"] = max_depth
if include_transform:
params["includeTransform"] = True
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
@scene.command("active")
@handle_unity_errors
def active():
"""Get information about the active scene."""
config = get_config()
result = run_command("manage_scene", {"action": "get_active"}, config)
click.echo(format_output(result, config.format))
@scene.command("load")
@click.argument("scene")
@click.option(
"--by-index", "-i",
is_flag=True,
help="Load by build index instead of path/name."
)
@handle_unity_errors
def load(scene: str, by_index: bool):
"""Load a scene.
\b
Examples:
unity-mcp scene load "Assets/Scenes/Main.unity"
unity-mcp scene load "MainScene"
unity-mcp scene load 0 --by-index
"""
config = get_config()
params: dict[str, Any] = {"action": "load"}
if by_index:
try:
params["buildIndex"] = int(scene)
except ValueError:
print_error(f"Invalid build index: {scene}")
sys.exit(1)
else:
if scene.endswith(".unity"):
params["path"] = scene
else:
params["name"] = scene
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Loaded scene: {scene}")
@scene.command("save")
@click.option(
"--path",
default=None,
help="Path to save the scene to (for new scenes)."
)
@handle_unity_errors
def save(path: Optional[str]):
"""Save the current scene.
\b
Examples:
unity-mcp scene save
unity-mcp scene save --path "Assets/Scenes/NewScene.unity"
"""
config = get_config()
params: dict[str, Any] = {"action": "save"}
if path:
params["path"] = path
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success("Scene saved")
@scene.command("create")
@click.argument("name")
@click.option(
"--path",
default=None,
help="Path to create the scene at."
)
@click.option(
"--template", "-t",
default=None,
type=click.Choice(["empty", "default", "3d_basic", "2d_basic"]),
help="Scene template (omit for empty scene)."
)
@handle_unity_errors
def create(name: str, path: Optional[str], template: Optional[str]):
"""Create a new scene, optionally from a template.
\b
Templates:
empty - Empty scene, no default objects
default - Camera + Directional Light (Unity default)
3d_basic - Default + ground plane
2d_basic - Default + orthographic camera
\b
Examples:
unity-mcp scene create "NewLevel"
unity-mcp scene create "Level1" --template 3d_basic
unity-mcp scene create "Level1" --template 2d_basic --path "Assets/Scenes"
"""
config = get_config()
params: dict[str, Any] = {
"action": "create",
"name": name,
}
if path:
params["path"] = path
if template:
params["template"] = template
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
label = f" from template '{template}'" if template else ""
print_success(f"Created scene{label}: {name}")
@scene.command("build-settings")
@handle_unity_errors
def build_settings():
"""Get scenes in build settings."""
config = get_config()
result = run_command("manage_scene", {"action": "get_build_settings"}, config)
click.echo(format_output(result, config.format))
# ── Multi-scene editing ──────────────────────────────────────────────
@scene.command("loaded")
@handle_unity_errors
def loaded():
"""List all currently loaded scenes.
\b
Examples:
unity-mcp scene loaded
"""
config = get_config()
result = run_command("manage_scene", {"action": "get_loaded_scenes"}, config)
click.echo(format_output(result, config.format))
@scene.command("open-additive")
@click.argument("scene_path")
@handle_unity_errors
def open_additive(scene_path: str):
"""Open a scene additively (keeps current scene loaded).
\b
Examples:
unity-mcp scene open-additive "Assets/Scenes/Level2.unity"
"""
config = get_config()
result = run_command("manage_scene", {
"action": "load",
"path": scene_path,
"additive": True,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Opened additively: {scene_path}")
@scene.command("close")
@click.argument("scene_name")
@click.option("--remove", is_flag=True, help="Fully remove scene instead of just unloading.")
@handle_unity_errors
def close(scene_name: str, remove: bool):
"""Close/unload a loaded scene.
\b
Examples:
unity-mcp scene close "Level2"
unity-mcp scene close "Level2" --remove
"""
config = get_config()
params: dict[str, Any] = {
"action": "close_scene",
"sceneName": scene_name,
}
if remove:
params["removeScene"] = True
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Closed scene: {scene_name}")
@scene.command("set-active")
@click.argument("scene_name")
@handle_unity_errors
def set_active(scene_name: str):
"""Set a loaded scene as the active scene.
\b
Examples:
unity-mcp scene set-active "Level2"
"""
config = get_config()
result = run_command("manage_scene", {
"action": "set_active_scene",
"sceneName": scene_name,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Set active: {scene_name}")
@scene.command("move-to")
@click.argument("target")
@click.argument("scene_name")
@handle_unity_errors
def move_to(target: str, scene_name: str):
"""Move a root GameObject to another loaded scene.
\b
Examples:
unity-mcp scene move-to "Player" "Level2"
"""
config = get_config()
result = run_command("manage_scene", {
"action": "move_to_scene",
"target": target,
"sceneName": scene_name,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Moved '{target}' to scene '{scene_name}'")
# ── Scene validation ─────────────────────────────────────────────────
@scene.command("validate")
@click.option("--repair", is_flag=True, help="Auto-fix missing scripts (undoable).")
@handle_unity_errors
def validate(repair: bool):
"""Validate the active scene for issues (missing scripts, broken prefabs).
\b
Examples:
unity-mcp scene validate
unity-mcp scene validate --repair
"""
config = get_config()
params: dict[str, Any] = {"action": "validate"}
if repair:
params["autoRepair"] = True
result = run_command("manage_scene", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
data = result.get("data", {})
total = data.get("totalIssues", 0)
repaired = data.get("repaired", 0)
if total == 0:
print_success("Scene is clean")
elif repaired > 0:
print_success(f"Found {total} issue(s), repaired {repaired}")
else:
print_warning(f"Found {total} issue(s), none repaired")
+222
View File
@@ -0,0 +1,222 @@
"""Script CLI commands."""
import sys
import json
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_list_or_exit
from cli.utils.confirmation import confirm_destructive_action
@click.group()
def script():
"""Script operations - create, read, edit C# scripts."""
pass
@script.command("create")
@click.argument("name")
@click.option(
"--path", "-p",
default="Assets/Scripts",
help="Directory to create the script in."
)
@click.option(
"--type", "-t",
"script_type",
type=click.Choice(["MonoBehaviour", "ScriptableObject",
"Editor", "EditorWindow", "Plain"]),
default="MonoBehaviour",
help="Type of script to create."
)
@click.option(
"--namespace", "-n",
default=None,
help="Namespace for the script."
)
@click.option(
"--contents", "-c",
default=None,
help="Full script contents (overrides template)."
)
@handle_unity_errors
def create(name: str, path: str, script_type: str, namespace: Optional[str], contents: Optional[str]):
"""Create a new C# script.
\b
Examples:
unity-mcp script create "PlayerController"
unity-mcp script create "GameManager" --path "Assets/Scripts/Managers"
unity-mcp script create "EnemyData" --type ScriptableObject
unity-mcp script create "CustomEditor" --type Editor --namespace "MyGame.Editor"
"""
config = get_config()
params: dict[str, Any] = {
"action": "create",
"name": name,
"path": path,
"scriptType": script_type,
}
if namespace:
params["namespace"] = namespace
if contents:
params["contents"] = contents
result = run_command("manage_script", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created script: {name}.cs")
@script.command("read")
@click.argument("path")
@click.option(
"--start-line", "-s",
default=None,
type=int,
help="Starting line number (1-based)."
)
@click.option(
"--line-count", "-n",
default=None,
type=int,
help="Number of lines to read."
)
@handle_unity_errors
def read(path: str, start_line: Optional[int], line_count: Optional[int]):
"""Read a C# script file.
\b
Examples:
unity-mcp script read "Assets/Scripts/Player.cs"
unity-mcp script read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20
"""
config = get_config()
parts = path.rsplit("/", 1)
filename = parts[-1]
directory = parts[0] if len(parts) > 1 else "Assets"
name = filename[:-3] if filename.endswith(".cs") else filename
params: dict[str, Any] = {
"action": "read",
"name": name,
"path": directory,
}
if start_line:
params["startLine"] = start_line
if line_count:
params["lineCount"] = line_count
result = run_command("manage_script", params, config)
# For read, just output the content directly
if result.get("success") and result.get("data"):
data = result.get("data", {})
if isinstance(data, dict) and "contents" in data:
click.echo(data["contents"])
else:
click.echo(format_output(result, config.format))
else:
click.echo(format_output(result, config.format))
@script.command("delete")
@click.argument("path")
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@handle_unity_errors
def delete(path: str, force: bool):
"""Delete a C# script.
\b
Examples:
unity-mcp script delete "Assets/Scripts/OldScript.cs"
"""
config = get_config()
confirm_destructive_action("Delete", "script", path, force)
parts = path.rsplit("/", 1)
filename = parts[-1]
directory = parts[0] if len(parts) > 1 else "Assets"
name = filename[:-3] if filename.endswith(".cs") else filename
params: dict[str, Any] = {
"action": "delete",
"name": name,
"path": directory,
}
result = run_command("manage_script", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Deleted: {path}")
@script.command("edit")
@click.argument("path")
@click.option(
"--edits", "-e",
required=True,
help='Edits as JSON array of {startLine, startCol, endLine, endCol, newText}.'
)
@handle_unity_errors
def edit(path: str, edits: str):
"""Apply text edits to a script.
\b
Examples:
unity-mcp script edit "Assets/Scripts/Player.cs" --edits '[{"startLine": 10, "startCol": 1, "endLine": 10, "endCol": 20, "newText": "// Modified"}]'
"""
config = get_config()
edits_list = parse_json_list_or_exit(edits, "edits")
params: dict[str, Any] = {
"uri": path,
"edits": edits_list,
}
result = run_command("apply_text_edits", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Applied edits to: {path}")
@script.command("validate")
@click.argument("path")
@click.option(
"--level", "-l",
type=click.Choice(["basic", "standard"]),
default="basic",
help="Validation level."
)
@handle_unity_errors
def validate(path: str, level: str):
"""Validate a C# script for errors.
\b
Examples:
unity-mcp script validate "Assets/Scripts/Player.cs"
unity-mcp script validate "Assets/Scripts/Player.cs" --level standard
"""
config = get_config()
params: dict[str, Any] = {
"uri": path,
"level": level,
"include_diagnostics": True,
}
result = run_command("validate_script", params, config)
click.echo(format_output(result, config.format))
+226
View File
@@ -0,0 +1,226 @@
"""Shader CLI commands for managing Unity shaders."""
import sys
import click
from typing import Optional
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.confirmation import confirm_destructive_action
@click.group()
def shader():
"""Shader operations - create, read, update, delete shaders."""
pass
@shader.command("read")
@click.argument("path")
@handle_unity_errors
def read_shader(path: str):
"""Read a shader file.
\\b
Examples:
unity-mcp shader read "Assets/Shaders/MyShader.shader"
"""
config = get_config()
# Extract name from path
import os
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
result = run_command("manage_shader", {
"action": "read",
"name": name,
"path": directory or "Assets/",
}, config)
# If successful, display the contents nicely
if result.get("success") and result.get("data", {}).get("contents"):
click.echo(result["data"]["contents"])
else:
click.echo(format_output(result, config.format))
@shader.command("create")
@click.argument("name")
@click.option(
"--path", "-p",
default="Assets/Shaders",
help="Directory to create shader in."
)
@click.option(
"--contents", "-c",
default=None,
help="Shader code (reads from stdin if not provided)."
)
@click.option(
"--file", "-f",
"file_path",
default=None,
type=click.Path(exists=True),
help="Read shader code from file."
)
@handle_unity_errors
def create_shader(name: str, path: str, contents: Optional[str], file_path: Optional[str]):
"""Create a new shader.
\\b
Examples:
unity-mcp shader create "MyShader" --path "Assets/Shaders"
unity-mcp shader create "MyShader" --file local_shader.shader
echo "Shader code..." | unity-mcp shader create "MyShader"
"""
config = get_config()
# Get contents from file, option, or stdin
if file_path:
with open(file_path, 'r') as f:
shader_contents = f.read()
elif contents:
shader_contents = contents
else:
# Read from stdin if available
import sys
if not sys.stdin.isatty():
shader_contents = sys.stdin.read()
else:
# Provide default shader template
shader_contents = f'''Shader "Custom/{name}"
{{
Properties
{{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {{}}
}}
SubShader
{{
Tags {{ "RenderType"="Opaque" }}
LOD 200
CGPROGRAM
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0
sampler2D _MainTex;
fixed4 _Color;
struct Input
{{
float2 uv_MainTex;
}};
void surf (Input IN, inout SurfaceOutputStandard o)
{{
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
}}
ENDCG
}}
FallBack "Diffuse"
}}
'''
result = run_command("manage_shader", {
"action": "create",
"name": name,
"path": path,
"contents": shader_contents,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created shader: {path}/{name}.shader")
@shader.command("update")
@click.argument("path")
@click.option(
"--contents", "-c",
default=None,
help="New shader code."
)
@click.option(
"--file", "-f",
"file_path",
default=None,
type=click.Path(exists=True),
help="Read shader code from file."
)
@handle_unity_errors
def update_shader(path: str, contents: Optional[str], file_path: Optional[str]):
"""Update an existing shader.
\\b
Examples:
unity-mcp shader update "Assets/Shaders/MyShader.shader" --file updated.shader
echo "New shader code" | unity-mcp shader update "Assets/Shaders/MyShader.shader"
"""
config = get_config()
import os
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
# Get contents from file, option, or stdin
if file_path:
with open(file_path, 'r') as f:
shader_contents = f.read()
elif contents:
shader_contents = contents
else:
import sys
if not sys.stdin.isatty():
shader_contents = sys.stdin.read()
else:
print_error(
"No shader contents provided. Use --contents, --file, or pipe via stdin.")
sys.exit(1)
result = run_command("manage_shader", {
"action": "update",
"name": name,
"path": directory or "Assets/",
"contents": shader_contents,
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Updated shader: {path}")
@shader.command("delete")
@click.argument("path")
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@handle_unity_errors
def delete_shader(path: str, force: bool):
"""Delete a shader.
\\b
Examples:
unity-mcp shader delete "Assets/Shaders/OldShader.shader"
unity-mcp shader delete "Assets/Shaders/OldShader.shader" --force
"""
config = get_config()
confirm_destructive_action("Delete", "shader", path, force)
import os
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
result = run_command("manage_shader", {
"action": "delete",
"name": name,
"path": directory or "Assets/",
}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Deleted shader: {path}")
+667
View File
@@ -0,0 +1,667 @@
"""Texture CLI commands."""
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_or_exit as try_parse_json
_TEXTURE_TYPES = {
"default": "Default",
"normal_map": "NormalMap",
"editor_gui": "GUI",
"sprite": "Sprite",
"cursor": "Cursor",
"cookie": "Cookie",
"lightmap": "Lightmap",
"directional_lightmap": "DirectionalLightmap",
"shadow_mask": "Shadowmask",
"single_channel": "SingleChannel",
}
_TEXTURE_SHAPES = {"2d": "Texture2D", "cube": "TextureCube"}
_ALPHA_SOURCES = {
"none": "None",
"from_input": "FromInput",
"from_gray_scale": "FromGrayScale",
}
_WRAP_MODES = {
"repeat": "Repeat",
"clamp": "Clamp",
"mirror": "Mirror",
"mirror_once": "MirrorOnce",
}
_FILTER_MODES = {"point": "Point",
"bilinear": "Bilinear", "trilinear": "Trilinear"}
_COMPRESSIONS = {
"none": "Uncompressed",
"low_quality": "CompressedLQ",
"normal_quality": "Compressed",
"high_quality": "CompressedHQ",
}
_SPRITE_MODES = {"single": "Single",
"multiple": "Multiple", "polygon": "Polygon"}
_SPRITE_MESH_TYPES = {"full_rect": "FullRect", "tight": "Tight"}
_MIPMAP_FILTERS = {"box": "BoxFilter", "kaiser": "KaiserFilter"}
_MAX_TEXTURE_DIMENSION = 1024
_MAX_TEXTURE_PIXELS = 1024 * 1024
def _validate_texture_dimensions(width: int, height: int) -> list[str]:
if width <= 0 or height <= 0:
raise ValueError("width and height must be positive")
warnings: list[str] = []
if width > _MAX_TEXTURE_DIMENSION or height > _MAX_TEXTURE_DIMENSION:
warnings.append(
f"width and height should be <= {_MAX_TEXTURE_DIMENSION} (got {width}x{height})")
total_pixels = width * height
if total_pixels > _MAX_TEXTURE_PIXELS:
warnings.append(
f"width*height should be <= {_MAX_TEXTURE_PIXELS} (got {width}x{height})")
return warnings
def _is_normalized_color(values: list[Any]) -> bool:
if not values:
return False
try:
numeric_values = [float(v) for v in values]
except (TypeError, ValueError):
return False
all_small = all(0 <= v <= 1.0 for v in numeric_values)
if not all_small:
return False
has_fractional = any(0 < v < 1 for v in numeric_values)
all_binary = all(v in (0, 1, 0.0, 1.0) for v in numeric_values)
return has_fractional or all_binary
def _parse_hex_color(value: str) -> list[int]:
h = value.lstrip("#")
if len(h) == 6:
return [int(h[i:i + 2], 16) for i in (0, 2, 4)] + [255]
if len(h) == 8:
return [int(h[i:i + 2], 16) for i in (0, 2, 4, 6)]
raise ValueError(f"Invalid hex color: {value}")
def _normalize_color(value: Any, context: str) -> list[int]:
if value is None:
raise ValueError(f"{context} is required")
if isinstance(value, str):
if value.startswith("#"):
return _parse_hex_color(value)
value = try_parse_json(value, context)
# Handle dict with r/g/b keys (e.g., {"r": 1, "g": 0, "b": 0} or {"r": 1, "g": 0, "b": 0, "a": 1})
if isinstance(value, dict):
if all(k in value for k in ("r", "g", "b")):
try:
color = [value["r"], value["g"], value["b"]]
if "a" in value:
color.append(value["a"])
else:
color.append(1.0 if _is_normalized_color(color) else 255)
if _is_normalized_color(color):
return [int(round(float(c) * 255)) for c in color]
return [int(c) for c in color]
except (TypeError, ValueError):
raise ValueError(f"{context} dict values must be numeric, got {value}")
raise ValueError(f"{context} dict must have 'r', 'g', 'b' keys, got {list(value.keys())}")
if isinstance(value, (list, tuple)):
if len(value) == 3:
value = list(value) + [1.0 if _is_normalized_color(value) else 255]
if len(value) == 4:
try:
if _is_normalized_color(value):
return [int(round(float(c) * 255)) for c in value]
return [int(c) for c in value]
except (TypeError, ValueError):
raise ValueError(
f"{context} values must be numeric, got {value}")
raise ValueError(
f"{context} must have 3 or 4 components, got {len(value)}")
raise ValueError(f"{context} must be a list or hex string")
def _normalize_palette(value: Any, context: str) -> list[list[int]]:
if value is None:
return []
if isinstance(value, str):
value = try_parse_json(value, context)
if not isinstance(value, list):
raise ValueError(f"{context} must be a list of colors")
return [_normalize_color(color, f"{context} item") for color in value]
def _normalize_pixels(value: Any, width: int, height: int, context: str) -> list[list[int]] | str:
if value is None:
raise ValueError(f"{context} is required")
if isinstance(value, str):
if value.startswith("base64:"):
return value
trimmed = value.strip()
if trimmed.startswith("[") and trimmed.endswith("]"):
value = try_parse_json(trimmed, context)
else:
return f"base64:{value}"
if isinstance(value, list):
expected_count = width * height
if len(value) != expected_count:
raise ValueError(
f"{context} must have {expected_count} entries, got {len(value)}")
return [_normalize_color(pixel, f"{context} pixel") for pixel in value]
raise ValueError(f"{context} must be a list or base64 string")
def _normalize_set_pixels(value: Any) -> dict[str, Any]:
if value is None:
raise ValueError("set-pixels is required")
if isinstance(value, str):
value = try_parse_json(value, "set-pixels")
if not isinstance(value, dict):
raise ValueError("set-pixels must be a JSON object")
result: dict[str, Any] = dict(value)
if "pixels" in value:
width = value.get("width")
height = value.get("height")
if width is None or height is None:
raise ValueError(
"set-pixels requires width and height when pixels are provided")
width = int(width)
height = int(height)
if width <= 0 or height <= 0:
raise ValueError("set-pixels width and height must be positive")
result["width"] = width
result["height"] = height
result["pixels"] = _normalize_pixels(
value["pixels"], width, height, "set-pixels pixels")
if "color" in value:
result["color"] = _normalize_color(value["color"], "set-pixels color")
if "pixels" not in value and "color" not in value:
raise ValueError("set-pixels requires 'color' or 'pixels'")
if "x" in value:
result["x"] = int(value["x"])
if "y" in value:
result["y"] = int(value["y"])
if "width" in value and "pixels" not in value:
result["width"] = int(value["width"])
if "height" in value and "pixels" not in value:
result["height"] = int(value["height"])
return result
def _map_enum(value: Any, mapping: dict[str, str]) -> Any:
if isinstance(value, str):
key = value.lower()
return mapping.get(key, value)
return value
_TRUE_STRINGS = {"true", "1", "yes", "on"}
_FALSE_STRINGS = {"false", "0", "no", "off"}
def _coerce_bool(value: Any, name: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1, 0.0, 1.0):
return bool(value)
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in _TRUE_STRINGS:
return True
if lowered in _FALSE_STRINGS:
return False
raise ValueError(f"{name} must be a boolean")
def _normalize_import_settings(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, str):
value = try_parse_json(value, "import_settings")
if not isinstance(value, dict):
raise ValueError("import_settings must be a JSON object")
result: dict[str, Any] = {}
if "texture_type" in value:
result["textureType"] = _map_enum(
value["texture_type"], _TEXTURE_TYPES)
if "texture_shape" in value:
result["textureShape"] = _map_enum(
value["texture_shape"], _TEXTURE_SHAPES)
for snake, camel in [
("srgb", "sRGBTexture"),
("alpha_is_transparency", "alphaIsTransparency"),
("readable", "isReadable"),
("generate_mipmaps", "mipmapEnabled"),
("compression_crunched", "crunchedCompression"),
]:
if snake in value:
result[camel] = _coerce_bool(value[snake], snake)
if "alpha_source" in value:
result["alphaSource"] = _map_enum(
value["alpha_source"], _ALPHA_SOURCES)
for snake, camel in [("wrap_mode", "wrapMode"), ("wrap_mode_u", "wrapModeU"), ("wrap_mode_v", "wrapModeV")]:
if snake in value:
result[camel] = _map_enum(value[snake], _WRAP_MODES)
if "filter_mode" in value:
result["filterMode"] = _map_enum(value["filter_mode"], _FILTER_MODES)
if "mipmap_filter" in value:
result["mipmapFilter"] = _map_enum(
value["mipmap_filter"], _MIPMAP_FILTERS)
if "compression" in value:
result["textureCompression"] = _map_enum(
value["compression"], _COMPRESSIONS)
if "aniso_level" in value:
result["anisoLevel"] = int(value["aniso_level"])
if "max_texture_size" in value:
result["maxTextureSize"] = int(value["max_texture_size"])
if "compression_quality" in value:
result["compressionQuality"] = int(value["compression_quality"])
if "sprite_mode" in value:
result["spriteImportMode"] = _map_enum(
value["sprite_mode"], _SPRITE_MODES)
if "sprite_pixels_per_unit" in value:
result["spritePixelsPerUnit"] = float(value["sprite_pixels_per_unit"])
if "sprite_pivot" in value:
result["spritePivot"] = value["sprite_pivot"]
if "sprite_mesh_type" in value:
result["spriteMeshType"] = _map_enum(
value["sprite_mesh_type"], _SPRITE_MESH_TYPES)
if "sprite_extrude" in value:
result["spriteExtrude"] = int(value["sprite_extrude"])
for key, val in value.items():
if key in result:
continue
if key in (
"textureType", "textureShape", "sRGBTexture", "alphaSource",
"alphaIsTransparency", "isReadable", "mipmapEnabled", "wrapMode",
"wrapModeU", "wrapModeV", "filterMode", "mipmapFilter", "anisoLevel",
"maxTextureSize", "textureCompression", "crunchedCompression",
"compressionQuality", "spriteImportMode", "spritePixelsPerUnit",
"spritePivot", "spriteMeshType", "spriteExtrude",
):
result[key] = val
return result
@click.group()
def texture():
"""Texture operations - create, modify, generate sprites."""
pass
@texture.command("create")
@click.argument("path")
@click.option("--width", default=64, help="Texture width (default: 64)")
@click.option("--height", default=64, help="Texture height (default: 64)")
@click.option("--image-path", help="Source image path (PNG/JPG) to import.")
@click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')")
@click.option("--pattern", type=click.Choice([
"checkerboard", "stripes", "stripes_h", "stripes_v", "stripes_diag",
"dots", "grid", "brick"
]), help="Pattern type")
@click.option("--palette", help="Color palette for pattern (JSON array of colors)")
@click.option("--import-settings", help="TextureImporter settings (JSON)")
@handle_unity_errors
def create(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str],
pattern: Optional[str], palette: Optional[str], import_settings: Optional[str]):
"""Create a new procedural texture.
\b
Examples:
unity-mcp texture create Assets/Red.png --color '[255,0,0]'
unity-mcp texture create Assets/Check.png --pattern checkerboard
unity-mcp texture create Assets/UI.png --import-settings '{"texture_type": "sprite"}'
"""
config = get_config()
if image_path:
if color or pattern or palette:
print_error(
"image-path cannot be combined with color, pattern, or palette.")
sys.exit(1)
else:
try:
warnings = _validate_texture_dimensions(width, height)
except ValueError as e:
print_error(str(e))
sys.exit(1)
for warning in warnings:
click.echo(f"⚠️ Warning: {warning}")
params: dict[str, Any] = {
"action": "create",
"path": path,
"width": width,
"height": height,
}
if color:
try:
params["fillColor"] = _normalize_color(color, "color")
except ValueError as e:
print_error(str(e))
sys.exit(1)
elif not pattern and not image_path:
# Default to white if no color or pattern specified
params["fillColor"] = [255, 255, 255, 255]
if pattern:
params["pattern"] = pattern
if palette:
try:
params["palette"] = _normalize_palette(palette, "palette")
except ValueError as e:
print_error(str(e))
sys.exit(1)
if import_settings:
try:
params["importSettings"] = _normalize_import_settings(
import_settings)
except ValueError as e:
print_error(str(e))
sys.exit(1)
if image_path:
params["imagePath"] = image_path
result = run_command("manage_texture", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created texture: {path}")
@texture.command("sprite")
@click.argument("path")
@click.option("--width", default=64, help="Texture width (default: 64)")
@click.option("--height", default=64, help="Texture height (default: 64)")
@click.option("--image-path", help="Source image path (PNG/JPG) to import.")
@click.option("--color", help="Fill color (e.g., '#FF0000' or '[1,0,0,1]')")
@click.option("--pattern", type=click.Choice([
"checkerboard", "stripes", "dots", "grid"
]), help="Pattern type (defaults to checkerboard if no color specified)")
@click.option("--ppu", default=100.0, help="Pixels Per Unit")
@click.option("--pivot", help="Pivot as [x,y] (default: [0.5, 0.5])")
@handle_unity_errors
def sprite(path: str, width: int, height: int, image_path: Optional[str], color: Optional[str], pattern: Optional[str], ppu: float, pivot: Optional[str]):
"""Quickly create a sprite texture.
\b
Examples:
unity-mcp texture sprite Assets/Sprites/Player.png
unity-mcp texture sprite Assets/Sprites/Coin.png --pattern dots
unity-mcp texture sprite Assets/Sprites/Solid.png --color '[0,255,0]'
"""
config = get_config()
if image_path:
if color or pattern:
print_error("image-path cannot be combined with color or pattern.")
sys.exit(1)
else:
try:
warnings = _validate_texture_dimensions(width, height)
except ValueError as e:
print_error(str(e))
sys.exit(1)
for warning in warnings:
click.echo(f"⚠️ Warning: {warning}")
sprite_settings: dict[str, Any] = {"pixelsPerUnit": ppu}
if pivot:
sprite_settings["pivot"] = try_parse_json(pivot, "pivot")
else:
sprite_settings["pivot"] = [0.5, 0.5]
params: dict[str, Any] = {
"action": "create_sprite",
"path": path,
"width": width,
"height": height,
"spriteSettings": sprite_settings
}
if color:
try:
params["fillColor"] = _normalize_color(color, "color")
except ValueError as e:
print_error(str(e))
sys.exit(1)
# Only default pattern if no color is specified
if pattern:
params["pattern"] = pattern
elif not color and not image_path:
params["pattern"] = "checkerboard"
if image_path:
params["imagePath"] = image_path
result = run_command("manage_texture", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Created sprite: {path}")
def _build_import_settings_from_flags(
texture_type: Optional[str],
sprite_mode: Optional[str],
sprite_ppu: Optional[float],
max_size: Optional[str],
compression: Optional[str],
generate_mipmaps: Optional[bool],
srgb: Optional[bool],
readable: Optional[bool],
) -> dict[str, Any]:
"""Build importSettings dict from CLI flags. Returns empty dict if no flags set."""
import_settings: dict[str, Any] = {}
if texture_type:
import_settings["textureType"] = _TEXTURE_TYPES[texture_type]
if sprite_mode:
import_settings["spriteImportMode"] = _SPRITE_MODES[sprite_mode]
if sprite_ppu is not None:
import_settings["spritePixelsPerUnit"] = sprite_ppu
if max_size:
import_settings["maxTextureSize"] = int(max_size)
if compression:
import_settings["textureCompression"] = _COMPRESSIONS[compression]
if generate_mipmaps is not None:
import_settings["mipmapEnabled"] = generate_mipmaps
if srgb is not None:
import_settings["sRGBTexture"] = srgb
if readable is not None:
import_settings["isReadable"] = readable
return import_settings
def _apply_import_flags_to_params(
params: dict[str, Any],
texture_type: Optional[str],
sprite_mode: Optional[str],
sprite_ppu: Optional[float],
max_size: Optional[str],
compression: Optional[str],
generate_mipmaps: Optional[bool],
srgb: Optional[bool],
readable: Optional[bool],
as_sprite: bool,
) -> bool:
"""Validate and apply import-setting flags to params dict. Returns True if any import setting present."""
has_other_flags = any(v is not None for v in (
texture_type, sprite_mode, sprite_ppu, max_size, compression, generate_mipmaps, srgb, readable))
if as_sprite:
if has_other_flags:
print_error("--as-sprite cannot be combined with other import-setting flags")
sys.exit(1)
params["spriteSettings"] = {"pivot": [0.5, 0.5], "pixelsPerUnit": 100}
return True
if has_other_flags:
import_settings = _build_import_settings_from_flags(
texture_type, sprite_mode, sprite_ppu, max_size, compression,
generate_mipmaps, srgb, readable)
if import_settings:
params["importSettings"] = import_settings
return True
return False
@texture.command("modify")
@click.argument("path")
@click.option("--set-pixels", default=None, help="Modification args as JSON")
@click.option("--texture-type", type=click.Choice(list(_TEXTURE_TYPES.keys())), help="Texture type")
@click.option("--sprite-mode", type=click.Choice(list(_SPRITE_MODES.keys())), help="Sprite import mode")
@click.option("--sprite-ppu", type=float, help="Sprite pixels per unit")
@click.option("--max-size", type=click.Choice(["32", "64", "128", "256", "512", "1024", "2048", "4096", "8192", "16384"]), help="Max texture size")
@click.option("--compression", type=click.Choice(list(_COMPRESSIONS.keys())), help="Compression quality")
@click.option("--generate-mipmaps/--no-mipmaps", default=None, help="Generate mipmaps")
@click.option("--srgb/--linear", default=None, help="sRGB color texture")
@click.option("--readable/--no-readable", default=None, help="Read/Write enabled")
@click.option("--as-sprite", is_flag=True, help="Shorthand: set texture type to Sprite with defaults")
@handle_unity_errors
def modify(path: str, set_pixels: Optional[str], texture_type: Optional[str], sprite_mode: Optional[str],
sprite_ppu: Optional[float], max_size: Optional[str], compression: Optional[str],
generate_mipmaps: Optional[bool], srgb: Optional[bool], readable: Optional[bool],
as_sprite: bool):
"""Modify an existing texture.
\b
Examples:
unity-mcp texture modify Assets/Tex.png --set-pixels '{"x":0,"y":0,"width":10,"height":10,"color":[255,0,0]}'
unity-mcp texture modify Assets/Tex.png --set-pixels '{"x":0,"y":0,"width":2,"height":2,"pixels":[[255,0,0,255],[0,255,0,255],[0,0,255,255],[255,255,0,255]]}'
unity-mcp texture modify Assets/UI/icon.png --as-sprite
unity-mcp texture modify Assets/UI/bg.png --texture-type sprite --max-size 2048
"""
config = get_config()
params: dict[str, Any] = {"action": "modify", "path": path}
has_import = _apply_import_flags_to_params(
params, texture_type, sprite_mode, sprite_ppu, max_size,
compression, generate_mipmaps, srgb, readable, as_sprite)
if set_pixels is not None:
try:
params["setPixels"] = _normalize_set_pixels(set_pixels)
except ValueError as e:
print_error(str(e))
sys.exit(1)
elif not has_import:
print_error("At least one of --set-pixels or an import-setting flag must be provided")
sys.exit(1)
result = run_command("manage_texture", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Modified texture: {path}")
@texture.command("delete")
@click.argument("path")
@click.option(
"--force", "-f",
is_flag=True,
help="Skip confirmation prompt."
)
@handle_unity_errors
def delete(path: str, force: bool):
"""Delete a texture.
\\b
Examples:
unity-mcp texture delete "Assets/Textures/Old.png"
unity-mcp texture delete "Assets/Textures/Old.png" --force
"""
from cli.utils.confirmation import confirm_destructive_action
config = get_config()
confirm_destructive_action("Delete", "texture", path, force)
result = run_command("manage_texture", {
"action": "delete", "path": path}, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Deleted texture: {path}")
@texture.command("set-import-settings")
@click.argument("path")
@click.option("--texture-type", type=click.Choice(list(_TEXTURE_TYPES.keys())), help="Texture type")
@click.option("--sprite-mode", type=click.Choice(list(_SPRITE_MODES.keys())), help="Sprite import mode")
@click.option("--sprite-ppu", type=float, help="Sprite pixels per unit")
@click.option("--max-size", type=click.Choice(["32", "64", "128", "256", "512", "1024", "2048", "4096", "8192", "16384"]), help="Max texture size")
@click.option("--compression", type=click.Choice(list(_COMPRESSIONS.keys())), help="Compression quality")
@click.option("--generate-mipmaps/--no-mipmaps", default=None, help="Generate mipmaps")
@click.option("--srgb/--linear", default=None, help="sRGB color texture")
@click.option("--readable/--no-readable", default=None, help="Read/Write enabled")
@click.option("--as-sprite", is_flag=True, help="Shorthand: set texture type to Sprite with defaults")
@handle_unity_errors
def set_import_settings(path: str, texture_type: Optional[str], sprite_mode: Optional[str],
sprite_ppu: Optional[float], max_size: Optional[str],
compression: Optional[str], generate_mipmaps: Optional[bool],
srgb: Optional[bool], readable: Optional[bool], as_sprite: bool):
"""Change import settings on an existing texture.
\b
Examples:
unity-mcp texture set-import-settings Assets/UI/icon.png --texture-type sprite
unity-mcp texture set-import-settings Assets/UI/icon.png --as-sprite
unity-mcp texture set-import-settings Assets/UI/icon.png --texture-type sprite --sprite-mode single --sprite-ppu 100
unity-mcp texture set-import-settings Assets/UI/bg.png --max-size 2048 --compression high_quality
"""
config = get_config()
params: dict[str, Any] = {"action": "set_import_settings", "path": path}
has_import = _apply_import_flags_to_params(
params, texture_type, sprite_mode, sprite_ppu, max_size,
compression, generate_mipmaps, srgb, readable, as_sprite)
if not has_import:
print_error("At least one import setting must be specified")
sys.exit(1)
result = run_command("manage_texture", params, config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Import settings updated: {path}")
+58
View File
@@ -0,0 +1,58 @@
"""Tool CLI commands for listing custom tools."""
import click
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error
from cli.utils.connection import run_list_custom_tools, handle_unity_errors
def _list_custom_tools() -> None:
config = get_config()
result = run_list_custom_tools(config)
if config.format != "text":
click.echo(format_output(result, config.format))
return
if not isinstance(result, dict) or not result.get("success", True):
click.echo(format_output(result, config.format))
return
tools = result.get("tools")
if tools is None:
data = result.get("data", {})
tools = data.get("tools") if isinstance(data, dict) else None
if not isinstance(tools, list):
click.echo(format_output(result, config.format))
return
click.echo(f"Custom tools ({len(tools)}):")
for i, t in enumerate(tools):
name = t.get("name") if isinstance(t, dict) else str(t)
click.echo(f" [{i}] {name}")
@click.group("tool")
def tool():
"""Tool management - list custom tools for the active Unity project."""
pass
@tool.command("list")
@handle_unity_errors
def list_tools():
"""List custom tools registered for the active Unity project."""
_list_custom_tools()
@click.group("custom_tool")
def custom_tool():
"""Alias for tool management (custom tools)."""
pass
@custom_tool.command("list")
@handle_unity_errors
def list_custom_tools():
"""List custom tools registered for the active Unity project."""
_list_custom_tools()
+258
View File
@@ -0,0 +1,258 @@
"""UI CLI commands - placeholder for future implementation."""
import sys
import click
from typing import Optional, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
@click.group()
def ui():
"""UI operations - create and modify UI elements."""
pass
@ui.command("create-canvas")
@click.argument("name")
@click.option(
"--render-mode",
type=click.Choice(
["ScreenSpaceOverlay", "ScreenSpaceCamera", "WorldSpace"]),
default="ScreenSpaceOverlay",
help="Canvas render mode."
)
@handle_unity_errors
def create_canvas(name: str, render_mode: str):
"""Create a new Canvas.
\b
Examples:
unity-mcp ui create-canvas "MainUI"
unity-mcp ui create-canvas "WorldUI" --render-mode WorldSpace
"""
config = get_config()
# Step 1: Create empty GameObject
result = run_command("manage_gameobject", {
"action": "create",
"name": name,
}, config)
if not (result.get("success") or result.get("data") or result.get("result")):
click.echo(format_output(result, config.format))
return
# Step 2: Add Canvas components
failed_components = []
for component in ["Canvas", "CanvasScaler", "GraphicRaycaster"]:
comp_result = run_command("manage_components", {
"action": "add",
"target": name,
"componentType": component,
}, config)
if not (comp_result.get("success") or comp_result.get("data")):
failed_components.append((component, comp_result.get("error", "Unknown error")))
if failed_components:
error_details = "; ".join([f"{c}: {e}" for c, e in failed_components])
print_error(f"Failed to add components: {error_details}")
# Step 3: Set render mode
render_mode_value = {"ScreenSpaceOverlay": 0,
"ScreenSpaceCamera": 1, "WorldSpace": 2}.get(render_mode, 0)
run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "Canvas",
"property": "renderMode",
"value": render_mode_value,
}, config)
click.echo(format_output(result, config.format))
print_success(f"Created Canvas: {name}")
@ui.command("create-text")
@click.argument("name")
@click.option(
"--parent", "-p",
required=True,
help="Parent Canvas or UI element."
)
@click.option(
"--text", "-t",
default="New Text",
help="Initial text content."
)
@click.option(
"--position",
nargs=2,
type=float,
default=(0, 0),
help="Anchored position X Y."
)
@handle_unity_errors
def create_text(name: str, parent: str, text: str, position: tuple):
"""Create a UI Text element (TextMeshPro).
\b
Examples:
unity-mcp ui create-text "TitleText" --parent "MainUI" --text "Hello World"
"""
config = get_config()
# Step 1: Create empty GameObject with parent
result = run_command("manage_gameobject", {
"action": "create",
"name": name,
"parent": parent,
"position": list(position),
}, config)
if not (result.get("success") or result.get("data") or result.get("result")):
click.echo(format_output(result, config.format))
return
# Step 2: Add RectTransform and TextMeshProUGUI
run_command("manage_components", {
"action": "add",
"target": name,
"componentType": "TextMeshProUGUI",
}, config)
# Step 3: Set text content
run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "TextMeshProUGUI",
"property": "text",
"value": text,
}, config)
click.echo(format_output(result, config.format))
print_success(f"Created Text: {name}")
@ui.command("create-button")
@click.argument("name")
@click.option(
"--parent", "-p",
required=True,
help="Parent Canvas or UI element."
)
@click.option(
"--text", "-t",
default="Button",
help="Button label text."
)
@handle_unity_errors
def create_button(name: str, parent: str, text: str): # text current placeholder
"""Create a UI Button.
\b
Examples:
unity-mcp ui create-button "StartButton" --parent "MainUI" --text "Start Game"
"""
config = get_config()
# Step 1: Create empty GameObject with parent
result = run_command("manage_gameobject", {
"action": "create",
"name": name,
"parent": parent,
}, config)
if not (result.get("success") or result.get("data") or result.get("result")):
click.echo(format_output(result, config.format))
return
# Step 2: Add Button and Image components
for component in ["Image", "Button"]:
run_command("manage_components", {
"action": "add",
"target": name,
"componentType": component,
}, config)
# Step 3: Create child label GameObject
label_name = f"{name}_Label"
run_command("manage_gameobject", {
"action": "create",
"name": label_name,
"parent": name,
}, config)
# Step 4: Add TextMeshProUGUI to label and set text
run_command("manage_components", {
"action": "add",
"target": label_name,
"componentType": "TextMeshProUGUI",
}, config)
run_command("manage_components", {
"action": "set_property",
"target": label_name,
"componentType": "TextMeshProUGUI",
"property": "text",
"value": text,
}, config)
click.echo(format_output(result, config.format))
print_success(f"Created Button: {name} (with label '{text}')")
@ui.command("create-image")
@click.argument("name")
@click.option(
"--parent", "-p",
required=True,
help="Parent Canvas or UI element."
)
@click.option(
"--sprite", "-s",
default=None,
help="Sprite asset path."
)
@handle_unity_errors
def create_image(name: str, parent: str, sprite: Optional[str]):
"""Create a UI Image.
\b
Examples:
unity-mcp ui create-image "Background" --parent "MainUI"
unity-mcp ui create-image "Icon" --parent "MainUI" --sprite "Assets/Sprites/icon.png"
"""
config = get_config()
# Step 1: Create empty GameObject with parent
result = run_command("manage_gameobject", {
"action": "create",
"name": name,
"parent": parent,
}, config)
if not (result.get("success") or result.get("data") or result.get("result")):
click.echo(format_output(result, config.format))
return
# Step 2: Add Image component
run_command("manage_components", {
"action": "add",
"target": name,
"componentType": "Image",
}, config)
# Step 3: Set sprite if provided
if sprite:
run_command("manage_components", {
"action": "set_property",
"target": name,
"componentType": "Image",
"property": "sprite",
"value": sprite,
}, config)
click.echo(format_output(result, config.format))
print_success(f"Created Image: {name}")
+468
View File
@@ -0,0 +1,468 @@
"""VFX CLI commands for managing Unity visual effects."""
import sys
import json
import click
from typing import Optional, Tuple, Any
from cli.utils.config import get_config
from cli.utils.output import format_output, print_error, print_success
from cli.utils.connection import run_command, handle_unity_errors
from cli.utils.parsers import parse_json_list_or_exit, parse_json_dict_or_exit
from cli.utils.constants import SEARCH_METHOD_CHOICE_TAGGED
_VFX_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties", "componentIndex"}
def _normalize_vfx_params(params: dict[str, Any]) -> dict[str, Any]:
params = dict(params)
properties: dict[str, Any] = {}
for key in list(params.keys()):
if key in _VFX_TOP_LEVEL_KEYS:
continue
properties[key] = params.pop(key)
if properties:
existing = params.get("properties")
if isinstance(existing, dict):
params["properties"] = {**properties, **existing}
else:
params["properties"] = properties
return {k: v for k, v in params.items() if v is not None}
@click.group()
def vfx():
"""VFX operations - particle systems, line renderers, trails."""
pass
# =============================================================================
# Particle System Commands
# =============================================================================
@vfx.group()
def particle():
"""Particle system operations."""
pass
@particle.command("info")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_info(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Get particle system info.
\\b
Examples:
unity-mcp vfx particle info "Fire"
unity-mcp vfx particle info "-12345" --search-method by_id
unity-mcp vfx particle info "Effects" --component-index 1
"""
config = get_config()
params: dict[str, Any] = {"action": "particle_get_info", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@particle.command("play")
@click.argument("target")
@click.option("--with-children", is_flag=True, help="Also play child particle systems.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_play(target: str, with_children: bool, search_method: Optional[str], component_index: Optional[int]):
"""Play a particle system.
\\b
Examples:
unity-mcp vfx particle play "Fire"
unity-mcp vfx particle play "Effects" --with-children
"""
config = get_config()
params: dict[str, Any] = {"action": "particle_play", "target": target}
if with_children:
params["withChildren"] = True
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Playing particle system: {target}")
@particle.command("stop")
@click.argument("target")
@click.option("--with-children", is_flag=True, help="Also stop child particle systems.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_stop(target: str, with_children: bool, search_method: Optional[str], component_index: Optional[int]):
"""Stop a particle system."""
config = get_config()
params: dict[str, Any] = {"action": "particle_stop", "target": target}
if with_children:
params["withChildren"] = True
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
if result.get("success"):
print_success(f"Stopped particle system: {target}")
@particle.command("pause")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_pause(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Pause a particle system."""
config = get_config()
params: dict[str, Any] = {"action": "particle_pause", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@particle.command("restart")
@click.argument("target")
@click.option("--with-children", is_flag=True)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_restart(target: str, with_children: bool, search_method: Optional[str], component_index: Optional[int]):
"""Restart a particle system."""
config = get_config()
params: dict[str, Any] = {"action": "particle_restart", "target": target}
if with_children:
params["withChildren"] = True
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@particle.command("clear")
@click.argument("target")
@click.option("--with-children", is_flag=True)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple ParticleSystems exist.")
@handle_unity_errors
def particle_clear(target: str, with_children: bool, search_method: Optional[str], component_index: Optional[int]):
"""Clear all particles from a particle system."""
config = get_config()
params: dict[str, Any] = {"action": "particle_clear", "target": target}
if with_children:
params["withChildren"] = True
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
# =============================================================================
# Line Renderer Commands
# =============================================================================
@vfx.group()
def line():
"""Line renderer operations."""
pass
@line.command("info")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple LineRenderers exist.")
@handle_unity_errors
def line_info(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Get line renderer info.
\\b
Examples:
unity-mcp vfx line info "LaserBeam"
unity-mcp vfx line info "MultiLine" --component-index 1
"""
config = get_config()
params: dict[str, Any] = {"action": "line_get_info", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@line.command("set-positions")
@click.argument("target")
@click.option("--positions", "-p", required=True, help='Positions as JSON array: [[0,0,0], [1,1,1], [2,0,0]]')
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple LineRenderers exist.")
@handle_unity_errors
def line_set_positions(target: str, positions: str, search_method: Optional[str], component_index: Optional[int]):
"""Set all positions on a line renderer.
\\b
Examples:
unity-mcp vfx line set-positions "Line" --positions "[[0,0,0], [5,2,0], [10,0,0]]"
"""
config = get_config()
positions_list = parse_json_list_or_exit(positions, "positions")
params: dict[str, Any] = {
"action": "line_set_positions",
"target": target,
"positions": positions_list,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@line.command("create-line")
@click.argument("target")
@click.option("--start", nargs=3, type=float, required=True, help="Start point X Y Z")
@click.option("--end", nargs=3, type=float, required=True, help="End point X Y Z")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple LineRenderers exist.")
@handle_unity_errors
def line_create_line(target: str, start: Tuple[float, float, float], end: Tuple[float, float, float], search_method: Optional[str], component_index: Optional[int]):
"""Create a simple line between two points.
\\b
Examples:
unity-mcp vfx line create-line "MyLine" --start 0 0 0 --end 10 5 0
"""
config = get_config()
params: dict[str, Any] = {
"action": "line_create_line",
"target": target,
"start": list(start),
"end": list(end),
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@line.command("create-circle")
@click.argument("target")
@click.option("--center", nargs=3, type=float, default=(0, 0, 0), help="Center point X Y Z")
@click.option("--radius", type=float, required=True, help="Circle radius")
@click.option("--segments", type=int, default=32, help="Number of segments")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple LineRenderers exist.")
@handle_unity_errors
def line_create_circle(target: str, center: Tuple[float, float, float], radius: float, segments: int, search_method: Optional[str], component_index: Optional[int]):
"""Create a circle shape.
\\b
Examples:
unity-mcp vfx line create-circle "Circle" --radius 5 --segments 64
unity-mcp vfx line create-circle "Ring" --center 0 2 0 --radius 3
"""
config = get_config()
params: dict[str, Any] = {
"action": "line_create_circle",
"target": target,
"center": list(center),
"radius": radius,
"segments": segments,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@line.command("clear")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple LineRenderers exist.")
@handle_unity_errors
def line_clear(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Clear all positions from a line renderer."""
config = get_config()
params: dict[str, Any] = {"action": "line_clear", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
# =============================================================================
# Trail Renderer Commands
# =============================================================================
@vfx.group()
def trail():
"""Trail renderer operations."""
pass
@trail.command("info")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple TrailRenderers exist.")
@handle_unity_errors
def trail_info(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Get trail renderer info."""
config = get_config()
params: dict[str, Any] = {"action": "trail_get_info", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@trail.command("set-time")
@click.argument("target")
@click.argument("duration", type=float)
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple TrailRenderers exist.")
@handle_unity_errors
def trail_set_time(target: str, duration: float, search_method: Optional[str], component_index: Optional[int]):
"""Set trail duration.
\\b
Examples:
unity-mcp vfx trail set-time "PlayerTrail" 2.0
"""
config = get_config()
params: dict[str, Any] = {
"action": "trail_set_time",
"target": target,
"time": duration,
}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
@trail.command("clear")
@click.argument("target")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple TrailRenderers exist.")
@handle_unity_errors
def trail_clear(target: str, search_method: Optional[str], component_index: Optional[int]):
"""Clear a trail renderer."""
config = get_config()
params: dict[str, Any] = {"action": "trail_clear", "target": target}
if search_method:
params["searchMethod"] = search_method
if component_index is not None:
params["componentIndex"] = component_index
result = run_command(
"manage_vfx", _normalize_vfx_params(params), config)
click.echo(format_output(result, config.format))
# =============================================================================
# Raw Command (escape hatch for all VFX actions)
# =============================================================================
@vfx.command("raw")
@click.argument("action")
@click.argument("target", required=False)
@click.option("--params", "-p", default="{}", help="Additional parameters as JSON.")
@click.option("--search-method", type=SEARCH_METHOD_CHOICE_TAGGED, default=None)
@click.option("--component-index", "-i", type=int, default=None, help="Zero-based index when multiple components of the same type exist.")
@handle_unity_errors
def vfx_raw(action: str, target: Optional[str], params: str, search_method: Optional[str], component_index: Optional[int]):
"""Execute any VFX action directly.
For advanced users who need access to all 60+ VFX actions.
\\b
Actions include:
particle_*: particle_set_main, particle_set_emission, particle_set_shape, ...
vfx_*: vfx_set_float, vfx_send_event, vfx_play, ...
line_*: line_create_arc, line_create_bezier, ...
trail_*: trail_set_width, trail_set_color, ...
\\b
Examples:
unity-mcp vfx raw particle_set_main "Fire" --params '{"duration": 5, "looping": true}'
unity-mcp vfx raw line_create_arc "Arc" --params '{"radius": 3, "startAngle": 0, "endAngle": 180}'
unity-mcp vfx raw vfx_send_event "Explosion" --params '{"eventName": "OnSpawn"}'
"""
config = get_config()
extra_params = parse_json_dict_or_exit(params, "params")
request_params: dict[str, Any] = {"action": action}
if target:
request_params["target"] = target
if search_method:
request_params["searchMethod"] = search_method
if component_index is not None:
request_params["componentIndex"] = component_index
# Merge extra params
request_params.update(extra_params)
result = run_command(
"manage_vfx", _normalize_vfx_params(request_params), config)
click.echo(format_output(result, config.format))
+295
View File
@@ -0,0 +1,295 @@
"""Unity MCP Command Line Interface - Main Entry Point."""
import sys
from importlib import import_module
import click
from typing import Optional
from cli import __version__
from cli.utils.config import CLIConfig, set_config, get_config
from cli.utils.suggestions import suggest_matches, format_suggestions
from cli.utils.output import format_output, print_error, print_success, print_info
from cli.utils.connection import (
run_command,
run_check_connection,
run_list_instances,
UnityConnectionError,
warn_if_remote_host,
)
# Context object to pass configuration between commands
class Context:
def __init__(self):
self.config: Optional[CLIConfig] = None
self.verbose: bool = False
pass_context = click.make_pass_decorator(Context, ensure=True)
_ORIGINAL_RESOLVE_COMMAND = click.Group.resolve_command
def _resolve_command_with_suggestions(self: click.Group, ctx: click.Context, args: list[str]):
try:
return _ORIGINAL_RESOLVE_COMMAND(self, ctx, args)
except click.exceptions.NoSuchCommand as e:
if not args or args[0].startswith("-"):
raise
matches = suggest_matches(args[0], self.list_commands(ctx))
suggestion = format_suggestions(matches)
if suggestion:
message = f"{e}\n{suggestion}"
raise click.exceptions.UsageError(message, ctx=ctx)
raise
except click.exceptions.UsageError as e:
if args and not args[0].startswith("-") and "No such command" in str(e):
matches = suggest_matches(args[0], self.list_commands(ctx))
suggestion = format_suggestions(matches)
if suggestion:
message = f"{e}\n{suggestion}"
raise click.exceptions.UsageError(message, ctx=ctx)
raise
# Install suggestion handling for all CLI command groups.
click.Group.resolve_command = _resolve_command_with_suggestions # type: ignore[assignment]
@click.group()
@click.version_option(version=__version__, prog_name="unity-mcp")
@click.option(
"--host", "-h",
default="127.0.0.1",
envvar="UNITY_MCP_HOST",
help="MCP server host address."
)
@click.option(
"--port", "-p",
default=8080,
type=int,
envvar="UNITY_MCP_HTTP_PORT",
help="MCP server port."
)
@click.option(
"--timeout", "-t",
default=30,
type=int,
envvar="UNITY_MCP_TIMEOUT",
help="Command timeout in seconds."
)
@click.option(
"--format", "-f",
type=click.Choice(["text", "json", "table"]),
default="text",
envvar="UNITY_MCP_FORMAT",
help="Output format."
)
@click.option(
"--instance", "-i",
default=None,
envvar="UNITY_MCP_INSTANCE",
help="Target Unity instance (hash or Name@hash)."
)
@click.option(
"--verbose", "-v",
is_flag=True,
help="Enable verbose output."
)
@pass_context
def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: Optional[str], verbose: bool):
"""Unity MCP Command Line Interface.
Control Unity Editor directly from the command line using the Model Context Protocol.
\b
Examples:
unity-mcp status
unity-mcp gameobject find "Player"
unity-mcp scene hierarchy --format json
unity-mcp editor play
\b
Environment Variables:
UNITY_MCP_HOST Server host (default: 127.0.0.1)
UNITY_MCP_HTTP_PORT Server port (default: 8080)
UNITY_MCP_TIMEOUT Timeout in seconds (default: 30)
UNITY_MCP_FORMAT Output format (default: text)
UNITY_MCP_INSTANCE Target Unity instance
"""
config = CLIConfig(
host=host,
port=port,
timeout=timeout,
format=format,
unity_instance=instance,
)
# Security warning for non-localhost connections
warn_if_remote_host(config)
set_config(config)
ctx.config = config
ctx.verbose = verbose
@cli.command("status")
@pass_context
def status(ctx: Context):
"""Check connection status to Unity MCP server."""
config = ctx.config or get_config()
click.echo(f"Checking connection to {config.host}:{config.port}...")
if run_check_connection(config):
print_success(
f"Connected to Unity MCP server at {config.host}:{config.port}")
# Try to get Unity instances
try:
result = run_list_instances(config)
instances = result.get("instances", []) if isinstance(
result, dict) else []
if instances:
click.echo("\nConnected Unity instances:")
for inst in instances:
project = inst.get("project", "Unknown")
version = inst.get("unity_version", "Unknown")
hash_id = inst.get("hash", "")[:8]
click.echo(f"{project} (Unity {version}) [{hash_id}]")
else:
print_info("No Unity instances currently connected")
except UnityConnectionError as e:
print_info(f"Could not retrieve Unity instances: {e}")
else:
print_error(
f"Cannot connect to Unity MCP server at {config.host}:{config.port}")
sys.exit(1)
@cli.command("instances")
@pass_context
def list_instances(ctx: Context):
"""List available Unity instances."""
config = ctx.config or get_config()
try:
instances = run_list_instances(config)
click.echo(format_output(instances, config.format))
except UnityConnectionError as e:
print_error(str(e))
sys.exit(1)
@cli.command("raw")
@click.argument("command_type")
@click.argument("params", nargs=-1)
@pass_context
def raw_command(ctx: Context, command_type: str, params: tuple):
"""Send a raw command to Unity.
\b
Examples:
unity-mcp raw manage_scene '{"action": "get_hierarchy"}'
unity-mcp raw read_console '{"count": 10}'
"""
import json
config = ctx.config or get_config()
# Join all remaining args into one string (Windows .exe entry points
# split quoted strings containing spaces into multiple args)
params_str = " ".join(params) if params else "{}"
try:
params_dict = json.loads(params_str)
except json.JSONDecodeError as e:
print_error(f"Invalid JSON params: {e}")
sys.exit(1)
try:
result = run_command(command_type, params_dict, config)
click.echo(format_output(result, config.format))
except UnityConnectionError as e:
print_error(str(e))
sys.exit(1)
# Import and register command groups
# These will be implemented in subsequent TODOs
def register_commands():
"""Register all command groups."""
def register_optional_command(module_name: str, command_name: str) -> None:
try:
module = import_module(module_name)
except ModuleNotFoundError as e:
if e.name == module_name:
return
print_error(
f"Failed to load command module '{module_name}': {e}"
)
return
except Exception as e:
print_error(
f"Failed to load command module '{module_name}': {e}"
)
return
command = getattr(module, command_name, None)
if command is None:
print_error(
f"Command '{command_name}' not found in '{module_name}'"
)
return
cli.add_command(command)
optional_commands = [
("cli.commands.tool", "tool"),
("cli.commands.tool", "custom_tool"),
("cli.commands.gameobject", "gameobject"),
("cli.commands.component", "component"),
("cli.commands.scene", "scene"),
("cli.commands.asset", "asset"),
("cli.commands.asset_gen", "asset_gen"),
("cli.commands.script", "script"),
("cli.commands.code", "code"),
("cli.commands.editor", "editor"),
("cli.commands.prefab", "prefab"),
("cli.commands.material", "material"),
("cli.commands.lighting", "lighting"),
("cli.commands.animation", "animation"),
("cli.commands.audio", "audio"),
("cli.commands.ui", "ui"),
("cli.commands.instance", "instance"),
("cli.commands.shader", "shader"),
("cli.commands.vfx", "vfx"),
("cli.commands.batch", "batch"),
("cli.commands.texture", "texture"),
("cli.commands.probuilder", "probuilder"),
("cli.commands.build", "build"),
("cli.commands.camera", "camera"),
("cli.commands.graphics", "graphics"),
("cli.commands.packages", "packages"),
("cli.commands.reflect", "reflect"),
("cli.commands.docs", "docs"),
("cli.commands.physics", "physics"),
("cli.commands.profiler", "profiler"),
]
for module_name, command_name in optional_commands:
register_optional_command(module_name, command_name)
# Register commands on import
register_commands()
def main():
"""Main entry point for the CLI."""
cli()
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
"""CLI utility modules."""
from cli.utils.config import CLIConfig, get_config, set_config
from cli.utils.connection import (
run_command,
run_check_connection,
run_list_instances,
UnityConnectionError,
)
from cli.utils.output import (
format_output,
print_success,
print_error,
print_warning,
print_info,
)
__all__ = [
"CLIConfig",
"UnityConnectionError",
"format_output",
"get_config",
"print_error",
"print_info",
"print_success",
"print_warning",
"run_check_connection",
"run_command",
"run_list_instances",
"set_config",
]
+58
View File
@@ -0,0 +1,58 @@
"""CLI Configuration utilities."""
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class CLIConfig:
"""Configuration for CLI connection to Unity."""
host: str = "127.0.0.1"
port: int = 8080
timeout: int = 30
format: str = "text" # text, json, table
unity_instance: Optional[str] = None
@classmethod
def from_env(cls) -> "CLIConfig":
port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080")
try:
port = int(port_raw)
except (ValueError, TypeError):
raise ValueError(
f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}")
timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30")
try:
timeout = int(timeout_raw)
except (ValueError, TypeError):
raise ValueError(
f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}")
return cls(
host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"),
port=port,
timeout=timeout,
format=os.environ.get("UNITY_MCP_FORMAT", "text"),
unity_instance=os.environ.get("UNITY_MCP_INSTANCE"),
)
# Global config instance
_config: Optional[CLIConfig] = None
def get_config() -> CLIConfig:
"""Get the current CLI configuration."""
global _config
if _config is None:
_config = CLIConfig.from_env()
return _config
def set_config(config: CLIConfig) -> None:
"""Set the CLI configuration."""
global _config
_config = config
+37
View File
@@ -0,0 +1,37 @@
"""Confirmation dialog utilities for CLI commands."""
import click
def confirm_destructive_action(
action: str,
item_type: str,
item_name: str,
force: bool,
extra_context: str = ""
) -> None:
"""Prompt user to confirm destructive action unless --force flag is set.
Args:
action: The action being performed (e.g., "Delete", "Remove")
item_type: The type of item (e.g., "script", "GameObject", "asset")
item_name: The name/path of the item
force: If True, skip confirmation prompt
extra_context: Optional additional context (e.g., "from 'Player'")
Raises:
click.Abort: If user declines confirmation
Examples:
confirm_destructive_action("Delete", "script", "MyScript.cs", force=False)
# Prompts: "Delete script 'MyScript.cs'?"
confirm_destructive_action("Remove", "Rigidbody", "Player", force=False, extra_context="from")
# Prompts: "Remove Rigidbody from 'Player'?"
"""
if not force:
if extra_context:
message = f"{action} {item_type} {extra_context} '{item_name}'?"
else:
message = f"{action} {item_type} '{item_name}'?"
click.confirm(message, abort=True)
+254
View File
@@ -0,0 +1,254 @@
"""Connection utilities for CLI to communicate with Unity via MCP server."""
import asyncio
import functools
import sys
from typing import Any, Callable, Dict, Optional, TypeVar
import httpx
from cli.utils.config import get_config, CLIConfig
class UnityConnectionError(Exception):
"""Raised when connection to Unity fails."""
pass
F = TypeVar("F", bound=Callable[..., Any])
def handle_unity_errors(func: F) -> F:
"""Decorator that handles UnityConnectionError consistently.
Wraps a CLI command function and catches UnityConnectionError,
printing a formatted error message and exiting with code 1.
Usage:
@scene.command("active")
@handle_unity_errors
def active():
config = get_config()
result = run_command("manage_scene", {"action": "get_active"}, config)
click.echo(format_output(result, config.format))
"""
from cli.utils.output import print_error
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return func(*args, **kwargs)
except UnityConnectionError as e:
print_error(str(e))
sys.exit(1)
return wrapper # type: ignore[return-value]
def warn_if_remote_host(config: CLIConfig) -> None:
"""Warn user if connecting to a non-localhost server.
This is a security measure to alert users that connecting to remote
servers exposes Unity control to potential network attacks.
Args:
config: CLI configuration with host setting
"""
import click
local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0")
if config.host.lower() not in local_hosts:
click.echo(
"⚠️ Security Warning: Connecting to non-localhost server.\n"
" The MCP CLI has no authentication. Anyone on the network could\n"
" intercept commands or send unauthorized commands to Unity.\n"
" Only proceed if you trust this network.\n",
err=True
)
async def send_command(
command_type: str,
params: Dict[str, Any],
config: Optional[CLIConfig] = None,
timeout: Optional[int] = None,
) -> Dict[str, Any]:
"""Send a command to Unity via the MCP HTTP server.
Args:
command_type: The command type (e.g., 'manage_gameobject', 'manage_scene')
params: Command parameters
config: Optional CLI configuration
timeout: Optional timeout override
Returns:
Response dict from Unity
Raises:
UnityConnectionError: If connection fails
"""
cfg = config or get_config()
url = f"http://{cfg.host}:{cfg.port}/api/command"
payload = {
"type": command_type,
"params": params,
}
if cfg.unity_instance:
payload["unity_instance"] = cfg.unity_instance
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
timeout=timeout or cfg.timeout,
)
response.raise_for_status()
return response.json()
except httpx.ConnectError as e:
raise UnityConnectionError(
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
f"Make sure the server is running and Unity is connected.\n"
f"Error: {e}"
)
except httpx.TimeoutException:
raise UnityConnectionError(
f"Connection to Unity timed out after {timeout or cfg.timeout}s. "
f"Unity may be busy or unresponsive."
)
except httpx.HTTPStatusError as e:
raise UnityConnectionError(
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
)
except Exception as e:
raise UnityConnectionError(f"Unexpected error: {e}")
def run_command(
command_type: str,
params: Dict[str, Any],
config: Optional[CLIConfig] = None,
timeout: Optional[int] = None,
) -> Dict[str, Any]:
"""Synchronous wrapper for send_command.
Args:
command_type: The command type
params: Command parameters
config: Optional CLI configuration
timeout: Optional timeout override
Returns:
Response dict from Unity
"""
return asyncio.run(send_command(command_type, params, config, timeout))
async def check_connection(config: Optional[CLIConfig] = None) -> bool:
"""Check if we can connect to the Unity MCP server.
Args:
config: Optional CLI configuration
Returns:
True if connection successful, False otherwise
"""
cfg = config or get_config()
url = f"http://{cfg.host}:{cfg.port}/health"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=5)
return response.status_code == 200
except Exception:
return False
def run_check_connection(config: Optional[CLIConfig] = None) -> bool:
"""Synchronous wrapper for check_connection."""
return asyncio.run(check_connection(config))
async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
"""List available Unity instances.
Args:
config: Optional CLI configuration
Returns:
Dict with list of Unity instances
"""
cfg = config or get_config()
url = f"http://{cfg.host}:{cfg.port}/api/instances"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10)
response.raise_for_status()
data = response.json()
if "instances" in data:
return data
except httpx.ConnectError as e:
raise UnityConnectionError(
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
f"Make sure the server is running and Unity is connected.\n"
f"Error: {e}"
)
except httpx.TimeoutException:
raise UnityConnectionError(
"Connection to Unity timed out while listing instances. "
"Unity may be busy or unresponsive."
)
except httpx.HTTPStatusError as e:
raise UnityConnectionError(
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
)
except Exception as e:
raise UnityConnectionError(f"Unexpected error: {e}")
raise UnityConnectionError("Failed to list Unity instances")
def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
"""Synchronous wrapper for list_unity_instances."""
return asyncio.run(list_unity_instances(config))
async def list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
"""List custom tools registered for the active Unity project."""
cfg = config or get_config()
url = f"http://{cfg.host}:{cfg.port}/api/custom-tools"
params: Dict[str, Any] = {}
if cfg.unity_instance:
params["instance"] = cfg.unity_instance
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=cfg.timeout)
response.raise_for_status()
return response.json()
except httpx.ConnectError as e:
raise UnityConnectionError(
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
f"Make sure the server is running and Unity is connected.\n"
f"Error: {e}"
)
except httpx.TimeoutException:
raise UnityConnectionError(
f"Connection to Unity timed out after {cfg.timeout}s. "
f"Unity may be busy or unresponsive."
)
except httpx.HTTPStatusError as e:
raise UnityConnectionError(
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
)
except Exception as e:
raise UnityConnectionError(f"Unexpected error: {e}")
def run_list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
"""Synchronous wrapper for list_custom_tools."""
return asyncio.run(list_custom_tools(config))
+23
View File
@@ -0,0 +1,23 @@
"""Common constants for CLI commands."""
import click
# Search method constants used across various CLI commands
# These define how GameObjects and other Unity objects can be located
# Full set of search methods (used by gameobject commands)
SEARCH_METHODS_FULL = ["by_name", "by_path", "by_id", "by_tag", "by_layer", "by_component"]
# Basic search methods (used by component, animation, audio commands)
SEARCH_METHODS_BASIC = ["by_id", "by_name", "by_path"]
# Extended search methods for renderer-based commands (material commands)
SEARCH_METHODS_RENDERER = ["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"]
# Tagged search methods (used by VFX commands)
SEARCH_METHODS_TAGGED = ["by_name", "by_path", "by_id", "by_tag", "by_layer"]
# Click choice options for each set
SEARCH_METHOD_CHOICE_FULL = click.Choice(SEARCH_METHODS_FULL)
SEARCH_METHOD_CHOICE_BASIC = click.Choice(SEARCH_METHODS_BASIC)
SEARCH_METHOD_CHOICE_RENDERER = click.Choice(SEARCH_METHODS_RENDERER)
SEARCH_METHOD_CHOICE_TAGGED = click.Choice(SEARCH_METHODS_TAGGED)
+195
View File
@@ -0,0 +1,195 @@
"""Output formatting utilities for CLI."""
import json
from typing import Any
import click
def format_output(data: Any, format_type: str = "text") -> str:
"""Format output based on requested format type.
Args:
data: Data to format
format_type: One of 'text', 'json', 'table'
Returns:
Formatted string
"""
if format_type == "json":
return format_as_json(data)
elif format_type == "table":
return format_as_table(data)
else:
return format_as_text(data)
def format_as_json(data: Any) -> str:
"""Format data as pretty-printed JSON."""
try:
return json.dumps(data, indent=2, default=str)
except (TypeError, ValueError) as e:
return json.dumps({"error": f"JSON serialization failed: {e}", "raw": str(data)})
def format_as_text(data: Any, indent: int = 0) -> str:
"""Format data as human-readable text."""
prefix = " " * indent
if data is None:
return f"{prefix}(none)"
if isinstance(data, dict):
# Check for error response
if "success" in data and not data.get("success"):
error = data.get("error") or data.get("message") or "Unknown error"
return f"{prefix}❌ Error: {error}"
# Check for success response with data
if "success" in data and data.get("success"):
result = data.get("data") or data.get("result") or data
if result != data:
return format_as_text(result, indent)
lines = []
for key, value in data.items():
if key in ("success", "error", "message") and "success" in data:
continue # Skip meta fields
if isinstance(value, dict):
lines.append(f"{prefix}{key}:")
lines.append(format_as_text(value, indent + 1))
elif isinstance(value, list):
lines.append(f"{prefix}{key}: [{len(value)} items]")
if len(value) <= 10:
for i, item in enumerate(value):
lines.append(
f"{prefix} [{i}] {_format_list_item(item)}")
else:
for i, item in enumerate(value[:5]):
lines.append(
f"{prefix} [{i}] {_format_list_item(item)}")
lines.append(f"{prefix} ... ({len(value) - 10} more)")
for i, item in enumerate(value[-5:], len(value) - 5):
lines.append(
f"{prefix} [{i}] {_format_list_item(item)}")
else:
lines.append(f"{prefix}{key}: {value}")
return "\n".join(lines)
if isinstance(data, list):
if not data:
return f"{prefix}(empty list)"
lines = [f"{prefix}[{len(data)} items]"]
for i, item in enumerate(data[:20]):
lines.append(f"{prefix} [{i}] {_format_list_item(item)}")
if len(data) > 20:
lines.append(f"{prefix} ... ({len(data) - 20} more)")
return "\n".join(lines)
return f"{prefix}{data}"
def _format_list_item(item: Any) -> str:
"""Format a single list item."""
if isinstance(item, dict):
# Try to find a name/id field for display
name = item.get("name") or item.get(
"Name") or item.get("id") or item.get("Id")
if name:
extra = ""
if "instanceID" in item:
extra = f" (ID: {item['instanceID']})"
elif "path" in item:
extra = f" ({item['path']})"
return f"{name}{extra}"
# Fallback to compact representation
return json.dumps(item, default=str)[:80]
return str(item)[:80]
def format_as_table(data: Any) -> str:
"""Format data as an ASCII table."""
if isinstance(data, dict):
# Check for success response with data
if "success" in data and data.get("success"):
result = data.get("data") or data.get(
"result") or data.get("items")
if isinstance(result, list):
return _build_table(result)
# Single dict as key-value table
rows = [[str(k), str(v)[:60]] for k, v in data.items()]
return _build_table(rows, headers=["Key", "Value"])
if isinstance(data, list):
return _build_table(data)
return str(data)
def _build_table(data: list[Any], headers: list[str] | None = None) -> str:
"""Build an ASCII table from list data."""
if not data:
return "(no data)"
# Convert list of dicts to rows
if isinstance(data[0], dict):
if headers is None:
headers = list(data[0].keys())
rows = [[str(item.get(h, ""))[:40] for h in headers] for item in data]
elif isinstance(data[0], (list, tuple)):
rows = [[str(cell)[:40] for cell in row] for row in data]
if headers is None:
headers = [f"Col{i}" for i in range(len(data[0]))]
else:
rows = [[str(item)[:60]] for item in data]
headers = headers or ["Value"]
# Calculate column widths
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(cell))
# Build table
lines = []
# Header
header_line = " | ".join(
h.ljust(col_widths[i]) for i, h in enumerate(headers))
lines.append(header_line)
lines.append("-+-".join("-" * w for w in col_widths))
# Rows
for row in rows[:50]: # Limit rows
row_line = " | ".join(
(row[i] if i < len(row) else "").ljust(col_widths[i])
for i in range(len(headers))
)
lines.append(row_line)
if len(rows) > 50:
lines.append(f"... ({len(rows) - 50} more rows)")
return "\n".join(lines)
def print_success(message: str) -> None:
"""Print a success message."""
click.echo(f"{message}")
def print_error(message: str) -> None:
"""Print an error message to stderr."""
click.echo(f"{message}", err=True)
def print_warning(message: str) -> None:
"""Print a warning message."""
click.echo(f"⚠️ {message}")
def print_info(message: str) -> None:
"""Print an info message."""
click.echo(f"{message}")
+112
View File
@@ -0,0 +1,112 @@
"""JSON and value parsing utilities for CLI commands."""
import json
import sys
from typing import Any
from cli.utils.output import print_error, print_info
def parse_value_safe(value: str) -> Any:
"""Parse a value, trying JSON → float → string fallback.
This is used for property values that could be JSON objects/arrays,
numbers, or strings. Never raises an exception.
Args:
value: The string value to parse
Returns:
Parsed JSON object/array, float, or original string
Examples:
>>> parse_value_safe('{"x": 1}')
{'x': 1}
>>> parse_value_safe('3.14')
3.14
>>> parse_value_safe('hello')
'hello'
"""
try:
return json.loads(value)
except json.JSONDecodeError:
# Try to parse as number
try:
return float(value)
except ValueError:
# Keep as string
return value
def parse_json_or_exit(value: str, context: str = "parameter") -> Any:
"""Parse JSON string, trying to fix common issues, or exit with error.
Attempts to parse JSON with automatic fixes for:
- Single quotes instead of double quotes
- Python-style True/False instead of true/false
Args:
value: The JSON string to parse
context: Description of what's being parsed (for error messages)
Returns:
Parsed JSON object
Exits:
Calls sys.exit(1) if JSON is invalid after attempting fixes
"""
try:
return json.loads(value)
except json.JSONDecodeError:
# Try to fix common shell quoting issues (single quotes, Python bools)
try:
fixed = value.replace("'", '"').replace("True", "true").replace("False", "false")
return json.loads(fixed)
except json.JSONDecodeError as e:
print_error(f"Invalid JSON for {context}: {e}")
print_info("Example: --params '{\"key\":\"value\"}'")
print_info("Tip: wrap JSON in single quotes to avoid shell escaping issues.")
sys.exit(1)
def parse_json_dict_or_exit(value: str, context: str = "parameter") -> dict[str, Any]:
"""Parse JSON object (dict), or exit with error.
Like parse_json_or_exit, but ensures result is a dictionary.
Args:
value: The JSON string to parse
context: Description of what's being parsed (for error messages)
Returns:
Parsed JSON object as dictionary
Exits:
Calls sys.exit(1) if JSON is invalid or not an object
"""
result = parse_json_or_exit(value, context)
if not isinstance(result, dict):
print_error(f"Invalid JSON for {context}: expected an object, got {type(result).__name__}")
sys.exit(1)
return result
def parse_json_list_or_exit(value: str, context: str = "parameter") -> list[Any]:
"""Parse JSON array (list), or exit with error.
Like parse_json_or_exit, but ensures result is a list.
Args:
value: The JSON string to parse
context: Description of what's being parsed (for error messages)
Returns:
Parsed JSON array as list
Exits:
Calls sys.exit(1) if JSON is invalid or not an array
"""
result = parse_json_or_exit(value, context)
if not isinstance(result, list):
print_error(f"Invalid JSON for {context}: expected an array, got {type(result).__name__}")
sys.exit(1)
return result
+34
View File
@@ -0,0 +1,34 @@
"""Helpers for CLI suggestion messages."""
from __future__ import annotations
import difflib
from typing import Iterable, List
def suggest_matches(
value: str,
choices: Iterable[str],
*,
limit: int = 3,
cutoff: float = 0.6,
) -> List[str]:
"""Return close matches for a value from a list of choices."""
try:
normalized = [c for c in choices if isinstance(c, str)]
except Exception:
normalized = []
if not value or not normalized:
return []
return difflib.get_close_matches(value, normalized, n=limit, cutoff=cutoff)
def format_suggestions(matches: Iterable[str]) -> str | None:
"""Format matches into a CLI-friendly suggestion string."""
items = [m for m in matches if m]
if not items:
return None
if len(items) == 1:
return f"Did you mean: {items[0]}"
joined = ", ".join(items)
return f"Did you mean one of: {joined}"
View File
+69
View File
@@ -0,0 +1,69 @@
"""
Configuration settings for the MCP for Unity Server.
This file contains all configurable parameters for the server.
"""
from dataclasses import dataclass
@dataclass
class ServerConfig:
"""Main configuration class for the MCP server."""
# Network settings
unity_host: str = "127.0.0.1"
unity_port: int = 6400
mcp_port: int = 6500
# Transport settings
transport_mode: str = "stdio"
# HTTP transport behaviour
http_remote_hosted: bool = False
# API key authentication (required when http_remote_hosted=True)
api_key_validation_url: str | None = None # POST endpoint to validate keys
api_key_login_url: str | None = None # URL for users to get/manage keys
# Cache TTL in seconds (5 min default)
api_key_cache_ttl: float = 300.0
# Optional service token for authenticating to the validation endpoint
api_key_service_token_header: str | None = None # e.g. "X-Service-Token"
api_key_service_token: str | None = None # The token value
# Connection settings
connection_timeout: float = 30.0
# Hard ceiling on a command's total time across all retries (wedged-socket guard).
command_total_timeout: float = 90.0
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# STDIO framing behaviour
require_framing: bool = True
handshake_timeout: float = 1.0
framed_receive_timeout: float = 2.0
max_heartbeat_frames: int = 16
heartbeat_timeout: float = 2.0
# Logging settings
log_level: str = "INFO"
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Server settings
max_retries: int = 5
retry_delay: float = 0.25
# Backoff hint returned to clients when Unity is reloading (milliseconds)
reload_retry_ms: int = 250
# Number of polite retries when Unity reports reloading
# 40 × 250ms ≈ 10s default window
reload_max_retries: int = 40
# Port discovery cache
port_registry_ttl: float = 5.0
# Telemetry settings
telemetry_enabled: bool = True
# Align with telemetry.py default Cloud Run endpoint
telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events"
# Create a global config instance
config = ServerConfig()
+4
View File
@@ -0,0 +1,4 @@
"""Server-wide protocol constants."""
# HTTP header name for API key authentication
API_KEY_HEADER = "X-API-Key"
+37
View File
@@ -0,0 +1,37 @@
import functools
import inspect
import logging
from typing import Callable, Any
logger = logging.getLogger("mcp-for-unity-server")
def log_execution(name: str, type_label: str):
"""Decorator to log input arguments and return value of a function."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def _sync_wrapper(*args, **kwargs) -> Any:
logger.info(
f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
try:
result = func(*args, **kwargs)
logger.info(f"{type_label} '{name}' returned: {result}")
return result
except Exception as e:
logger.info(f"{type_label} '{name}' failed: {e}")
raise
@functools.wraps(func)
async def _async_wrapper(*args, **kwargs) -> Any:
logger.info(
f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
try:
result = await func(*args, **kwargs)
logger.info(f"{type_label} '{name}' returned: {result}")
return result
except Exception as e:
logger.info(f"{type_label} '{name}' failed: {e}")
raise
return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
return decorator
+551
View File
@@ -0,0 +1,551 @@
"""
Privacy-focused, anonymous telemetry system for MCP for Unity
Inspired by Onyx's telemetry implementation with Unity-specific adaptations
Fire-and-forget telemetry sender with a single background worker.
- No context/thread-local propagation to avoid re-entrancy into tool resolution.
- Small network timeouts to prevent stalls.
"""
import contextlib
from dataclasses import dataclass
from enum import Enum
from importlib import import_module, metadata
import json
import logging
import os
from pathlib import Path
import platform
import queue
import sys
import threading
import time
from typing import Any
from urllib.parse import urlparse
import uuid
import tomli
try:
import httpx
HAS_HTTPX = True
except ImportError:
httpx = None # type: ignore
HAS_HTTPX = False
logger = logging.getLogger("unity-mcp-telemetry")
PACKAGE_NAME = "mcpforunityserver"
def _version_from_local_pyproject() -> str:
"""Locate the nearest pyproject.toml that matches our package name."""
current = Path(__file__).resolve()
for parent in current.parents:
candidate = parent / "pyproject.toml"
if not candidate.exists():
continue
try:
with candidate.open("rb") as f:
data = tomli.load(f)
except (OSError, tomli.TOMLDecodeError):
continue
project_table = data.get("project") or {}
poetry_table = data.get("tool", {}).get("poetry", {})
project_name = project_table.get("name") or poetry_table.get("name")
if project_name and project_name.lower() != PACKAGE_NAME.lower():
continue
version = project_table.get("version") or poetry_table.get("version")
if version:
return version
raise FileNotFoundError("pyproject.toml not found for mcpforunityserver")
def get_package_version() -> str:
"""
Get package version in different ways:
1. First we try the installed metadata - this is because uvx is used on the asset store
2. If that fails, we try to read from pyproject.toml - this is available for users who download via Git
Default is "unknown", but that should never happen
"""
try:
return metadata.version(PACKAGE_NAME)
except Exception:
# Fallback for development: read from pyproject.toml
try:
return _version_from_local_pyproject()
except Exception:
return "unknown"
MCP_VERSION = get_package_version()
class RecordType(str, Enum):
"""Types of telemetry records we collect"""
VERSION = "version"
STARTUP = "startup"
USAGE = "usage"
LATENCY = "latency"
FAILURE = "failure"
RESOURCE_RETRIEVAL = "resource_retrieval"
TOOL_EXECUTION = "tool_execution"
UNITY_CONNECTION = "unity_connection"
CLIENT_CONNECTION = "client_connection"
class MilestoneType(str, Enum):
"""Major user journey milestones"""
FIRST_STARTUP = "first_startup"
FIRST_TOOL_USAGE = "first_tool_usage"
FIRST_SCRIPT_CREATION = "first_script_creation"
FIRST_SCENE_MODIFICATION = "first_scene_modification"
MULTIPLE_SESSIONS = "multiple_sessions"
DAILY_ACTIVE_USER = "daily_active_user"
WEEKLY_ACTIVE_USER = "weekly_active_user"
@dataclass
class TelemetryRecord:
"""Structure for telemetry data"""
record_type: RecordType
timestamp: float
customer_uuid: str
session_id: str
data: dict[str, Any]
milestone: MilestoneType | None = None
class TelemetryConfig:
"""Telemetry configuration"""
def __init__(self):
"""
Prefer config file, then allow env overrides
"""
server_config = None
for modname in (
# Prefer plain module to respect test-time overrides and sys.path injection
"src.core.config",
"config",
"src.config",
"Server.config",
):
try:
mod = import_module(modname)
server_config = getattr(mod, "config", None)
if server_config is not None:
break
except Exception:
continue
# Determine enabled flag: config -> env DISABLE_* opt-out
cfg_enabled = True if server_config is None else bool(
getattr(server_config, "telemetry_enabled", True))
self.enabled = cfg_enabled and not self._is_disabled()
# Telemetry endpoint (Cloud Run default; override via env)
cfg_default = None if server_config is None else getattr(
server_config, "telemetry_endpoint", None)
default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
self.default_endpoint = default_ep
# Prefer config default; allow explicit env override only when set
env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT")
if env_ep is not None and env_ep != "":
self.endpoint = self._validated_endpoint(env_ep, default_ep)
else:
# Validate config-provided default as well to enforce scheme/host rules
self.endpoint = self._validated_endpoint(default_ep, default_ep)
try:
logger.info(
f"Telemetry configured: endpoint={self.endpoint} (default={default_ep}), timeout_env={os.environ.get('UNITY_MCP_TELEMETRY_TIMEOUT') or '<unset>'}")
except Exception:
pass
# Local storage for UUID and milestones
self.data_dir = self._get_data_directory()
self.uuid_file = self.data_dir / "customer_uuid.txt"
self.milestones_file = self.data_dir / "milestones.json"
# Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
try:
self.timeout = float(os.environ.get(
"UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
except Exception:
self.timeout = 1.5
try:
logger.info(f"Telemetry timeout={self.timeout:.2f}s")
except Exception:
pass
# Session tracking
self.session_id = str(uuid.uuid4())
def _is_disabled(self) -> bool:
"""Check if telemetry is disabled via environment variables"""
disable_vars = [
"DISABLE_TELEMETRY",
"UNITY_MCP_DISABLE_TELEMETRY",
"MCP_DISABLE_TELEMETRY"
]
for var in disable_vars:
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
return True
return False
def _get_data_directory(self) -> Path:
"""Get directory for storing telemetry data"""
if os.name == 'nt': # Windows
base_dir = Path(os.environ.get(
'APPDATA', Path.home() / 'AppData' / 'Roaming'))
elif os.name == 'posix': # macOS/Linux
if 'darwin' in os.uname().sysname.lower(): # macOS
base_dir = Path.home() / 'Library' / 'Application Support'
else: # Linux
base_dir = Path(os.environ.get('XDG_DATA_HOME',
Path.home() / '.local' / 'share'))
else:
base_dir = Path.home() / '.unity-mcp'
data_dir = base_dir / 'UnityMCP'
data_dir.mkdir(parents=True, exist_ok=True)
return data_dir
def _validated_endpoint(self, candidate: str, fallback: str) -> str:
"""Validate telemetry endpoint URL scheme; allow only http/https.
Falls back to the provided default on error.
"""
try:
parsed = urlparse(candidate)
if parsed.scheme not in ("https", "http"):
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
# Basic sanity: require network location and path
if not parsed.netloc:
raise ValueError("Missing netloc in endpoint")
# Reject localhost/loopback endpoints in production to avoid accidental local overrides
host = parsed.hostname or ""
if host in ("localhost", "127.0.0.1", "::1"):
raise ValueError(
"Localhost endpoints are not allowed for telemetry")
return candidate
except Exception as e:
logger.debug(
f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}",
exc_info=True,
)
return fallback
class TelemetryCollector:
"""Main telemetry collection class"""
def __init__(self):
self.config = TelemetryConfig()
self._customer_uuid: str | None = None
self._milestones: dict[str, dict[str, Any]] = {}
self._lock: threading.Lock = threading.Lock()
# Bounded queue with single background worker (records only; no context propagation)
self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
self._shutdown: bool = False
# Load persistent data before starting worker so first events have UUID
self._load_persistent_data()
self._worker: threading.Thread = threading.Thread(
target=self._worker_loop, daemon=True)
self._worker.start()
def _load_persistent_data(self):
"""Load UUID and milestones from disk"""
# Load customer UUID
try:
if self.config.uuid_file.exists():
self._customer_uuid = self.config.uuid_file.read_text(
encoding="utf-8").strip() or str(uuid.uuid4())
else:
self._customer_uuid = str(uuid.uuid4())
try:
self.config.uuid_file.write_text(
self._customer_uuid, encoding="utf-8")
if os.name == "posix":
os.chmod(self.config.uuid_file, 0o600)
except OSError as e:
logger.debug(
f"Failed to persist customer UUID: {e}", exc_info=True)
except OSError as e:
logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
self._customer_uuid = str(uuid.uuid4())
# Load milestones (failure here must not affect UUID)
try:
if self.config.milestones_file.exists():
content = self.config.milestones_file.read_text(
encoding="utf-8")
self._milestones = json.loads(content) or {}
if not isinstance(self._milestones, dict):
self._milestones = {}
except (OSError, json.JSONDecodeError, ValueError) as e:
logger.debug(f"Failed to load milestones: {e}", exc_info=True)
self._milestones = {}
def _save_milestones(self):
"""Save milestones to disk. Caller must hold self._lock."""
try:
self.config.milestones_file.write_text(
json.dumps(self._milestones, indent=2),
encoding="utf-8",
)
except OSError as e:
logger.warning(f"Failed to save milestones: {e}", exc_info=True)
def record_milestone(self, milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
"""Record a milestone event, returns True if this is the first occurrence"""
if not self.config.enabled:
return False
milestone_key = milestone.value
with self._lock:
if milestone_key in self._milestones:
return False # Already recorded
milestone_data = {
"timestamp": time.time(),
"data": data or {},
}
self._milestones[milestone_key] = milestone_data
self._save_milestones()
# Also send as telemetry record
self.record(
record_type=RecordType.USAGE,
data={"milestone": milestone_key, **(data or {})},
milestone=milestone
)
return True
def record(self,
record_type: RecordType,
data: dict[str, Any],
milestone: MilestoneType | None = None):
"""Record a telemetry event (async, non-blocking)"""
if not self.config.enabled:
return
# Allow fallback sender when httpx is unavailable (no early return)
record = TelemetryRecord(
record_type=record_type,
timestamp=time.time(),
customer_uuid=self._customer_uuid or "unknown",
session_id=self.config.session_id,
data=data,
milestone=milestone
)
# Enqueue for background worker (non-blocking). Drop on backpressure.
try:
self._queue.put_nowait(record)
except queue.Full:
logger.debug(
f"Telemetry queue full; dropping {record.record_type}")
def _worker_loop(self):
"""Background worker that serializes telemetry sends."""
while not self._shutdown:
try:
rec = self._queue.get(timeout=0.5)
except queue.Empty:
continue
try:
# Run sender directly; do not reuse caller context/thread-locals
self._send_telemetry(rec)
except Exception:
logger.debug("Telemetry worker send failed", exc_info=True)
finally:
with contextlib.suppress(Exception):
self._queue.task_done()
def shutdown(self):
"""Shutdown the telemetry collector and worker thread."""
self._shutdown = True
if self._worker and self._worker.is_alive():
self._worker.join(timeout=2.0)
def _send_telemetry(self, record: TelemetryRecord):
"""Send telemetry data to endpoint"""
try:
# System fingerprint (top-level remains concise; details stored in data JSON)
_platform = platform.system() # 'Darwin' | 'Linux' | 'Windows'
_source = sys.platform # 'darwin' | 'linux' | 'win32'
_platform_detail = f"{_platform} {platform.release()} ({platform.machine()})"
_python_version = platform.python_version()
# Enrich data JSON so BigQuery stores detailed fields without schema change
enriched_data = dict(record.data or {})
enriched_data.setdefault("platform_detail", _platform_detail)
enriched_data.setdefault("python_version", _python_version)
payload = {
"record": record.record_type.value,
"timestamp": record.timestamp,
"customer_uuid": record.customer_uuid,
"session_id": record.session_id,
"data": enriched_data,
"version": MCP_VERSION,
"platform": _platform,
"source": _source,
}
if record.milestone:
payload["milestone"] = record.milestone.value
# Prefer httpx when available; otherwise fall back to urllib
if httpx:
with httpx.Client(timeout=self.config.timeout) as client:
# Re-validate endpoint at send time to handle dynamic changes
endpoint = self.config._validated_endpoint(
self.config.endpoint, self.config.default_endpoint)
response = client.post(endpoint, json=payload)
if 200 <= response.status_code < 300:
logger.debug(f"Telemetry sent: {record.record_type}")
else:
logger.warning(
f"Telemetry failed: HTTP {response.status_code}")
else:
import urllib.request
import urllib.error
data_bytes = json.dumps(payload).encode("utf-8")
endpoint = self.config._validated_endpoint(
self.config.endpoint, self.config.default_endpoint)
req = urllib.request.Request(
endpoint,
data=data_bytes,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
if 200 <= resp.getcode() < 300:
logger.debug(
f"Telemetry sent (urllib): {record.record_type}")
else:
logger.warning(
f"Telemetry failed (urllib): HTTP {resp.getcode()}")
except urllib.error.URLError as ue:
logger.warning(f"Telemetry send failed (urllib): {ue}")
except Exception as e:
# Never let telemetry errors interfere with app functionality
logger.debug(f"Telemetry send failed: {e}")
# Global telemetry instance
_telemetry_collector: TelemetryCollector | None = None
def get_telemetry() -> TelemetryCollector:
"""Get the global telemetry collector instance"""
global _telemetry_collector
if _telemetry_collector is None:
_telemetry_collector = TelemetryCollector()
return _telemetry_collector
def reset_telemetry():
"""Reset the global telemetry collector. For testing only."""
global _telemetry_collector
if _telemetry_collector is not None:
_telemetry_collector.shutdown()
_telemetry_collector = None
def record_telemetry(record_type: RecordType,
data: dict[str, Any],
milestone: MilestoneType | None = None):
"""Convenience function to record telemetry"""
get_telemetry().record(record_type, data, milestone)
def record_milestone(milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
"""Convenience function to record a milestone"""
return get_telemetry().record_milestone(milestone, data)
def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: str | None = None, sub_action: str | None = None):
"""Record tool usage telemetry
Args:
tool_name: Name of the tool invoked (e.g., 'manage_scene').
success: Whether the tool completed successfully.
duration_ms: Execution duration in milliseconds.
error: Optional error message (truncated if present).
sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy').
"""
data = {
"tool_name": tool_name,
"success": success,
"duration_ms": round(duration_ms, 2)
}
if sub_action is not None:
try:
data["sub_action"] = str(sub_action)
except Exception:
# Ensure telemetry is never disruptive
data["sub_action"] = "unknown"
if error:
data["error"] = str(error)[:200] # Limit error message length
record_telemetry(RecordType.TOOL_EXECUTION, data)
def record_resource_usage(resource_name: str, success: bool, duration_ms: float, error: str | None = None):
"""Record resource usage telemetry
Args:
resource_name: Name of the resource invoked (e.g., 'get_tests').
success: Whether the resource completed successfully.
duration_ms: Execution duration in milliseconds.
error: Optional error message (truncated if present).
"""
data = {
"resource_name": resource_name,
"success": success,
"duration_ms": round(duration_ms, 2)
}
if error:
data["error"] = str(error)[:200] # Limit error message length
record_telemetry(RecordType.RESOURCE_RETRIEVAL, data)
def record_latency(operation: str, duration_ms: float, metadata: dict[str, Any] | None = None):
"""Record latency telemetry"""
data = {
"operation": operation,
"duration_ms": round(duration_ms, 2)
}
if metadata:
data.update(metadata)
record_telemetry(RecordType.LATENCY, data)
def record_failure(component: str, error: str, metadata: dict[str, Any] | None = None):
"""Record failure telemetry"""
data = {
"component": component,
"error": str(error)[:500] # Limit error message length
}
if metadata:
data.update(metadata)
record_telemetry(RecordType.FAILURE, data)
def is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled"""
return get_telemetry().config.enabled
+164
View File
@@ -0,0 +1,164 @@
"""
Telemetry decorator for MCP for Unity tools
"""
import functools
import inspect
import logging
import time
from typing import Callable, Any
from core.telemetry import record_resource_usage, record_tool_usage, record_milestone, MilestoneType
_log = logging.getLogger("unity-mcp-telemetry")
_decorator_log_count = 0
def telemetry_tool(tool_name: str):
"""Decorator to add telemetry tracking to MCP tools"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def _sync_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
# Extract sub-action (e.g., 'get_hierarchy') from bound args when available
sub_action = None
try:
sig = inspect.signature(func)
bound = sig.bind_partial(*args, **kwargs)
bound.apply_defaults()
sub_action = bound.arguments.get("action")
except Exception:
sub_action = None
try:
global _decorator_log_count
if _decorator_log_count < 10:
_log.info(f"telemetry_decorator sync: tool={tool_name}")
_decorator_log_count += 1
result = func(*args, **kwargs)
success = True
action_val = sub_action or kwargs.get("action")
try:
if tool_name == "manage_script" and action_val == "create":
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
elif tool_name.startswith("manage_scene"):
record_milestone(
MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
except Exception:
_log.debug("milestone emit failed", exc_info=True)
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success,
duration_ms, error, sub_action=sub_action)
except Exception:
_log.debug("record_tool_usage failed", exc_info=True)
@functools.wraps(func)
async def _async_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
# Extract sub-action (e.g., 'get_hierarchy') from bound args when available
sub_action = None
try:
sig = inspect.signature(func)
bound = sig.bind_partial(*args, **kwargs)
bound.apply_defaults()
sub_action = bound.arguments.get("action")
except Exception:
sub_action = None
try:
global _decorator_log_count
if _decorator_log_count < 10:
_log.info(f"telemetry_decorator async: tool={tool_name}")
_decorator_log_count += 1
result = await func(*args, **kwargs)
success = True
action_val = sub_action or kwargs.get("action")
try:
if tool_name == "manage_script" and action_val == "create":
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
elif tool_name.startswith("manage_scene"):
record_milestone(
MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
except Exception:
_log.debug("milestone emit failed", exc_info=True)
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success,
duration_ms, error, sub_action=sub_action)
except Exception:
_log.debug("record_tool_usage failed", exc_info=True)
return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
return decorator
def telemetry_resource(resource_name: str):
"""Decorator to add telemetry tracking to MCP resources"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def _sync_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
try:
global _decorator_log_count
if _decorator_log_count < 10:
_log.info(
f"telemetry_decorator sync: resource={resource_name}")
_decorator_log_count += 1
result = func(*args, **kwargs)
success = True
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_resource_usage(resource_name, success,
duration_ms, error)
except Exception:
_log.debug("record_resource_usage failed", exc_info=True)
@functools.wraps(func)
async def _async_wrapper(*args, **kwargs) -> Any:
start_time = time.time()
success = False
error = None
try:
global _decorator_log_count
if _decorator_log_count < 10:
_log.info(
f"telemetry_decorator async: resource={resource_name}")
_decorator_log_count += 1
result = await func(*args, **kwargs)
success = True
return result
except Exception as e:
error = str(e)
raise
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_resource_usage(resource_name, success,
duration_ms, error)
except Exception:
_log.debug("record_resource_usage failed", exc_info=True)
return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
return decorator
+940
View File
@@ -0,0 +1,940 @@
from starlette.requests import Request
from transport.unity_instance_middleware import (
UnityInstanceMiddleware,
get_unity_instance_middleware
)
from services.api_key_service import ApiKeyService
from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool
from services.tools import register_all_tools
from core.telemetry import record_milestone, record_telemetry, MilestoneType, RecordType, get_package_version
from services.resources import register_all_resources
from transport.plugin_registry import PluginRegistry
from transport.plugin_hub import PluginHub
from services.custom_tool_service import (
CustomToolService,
resolve_project_id_for_unity_instance,
)
from core.config import config
from starlette.routing import WebSocketRoute
from starlette.responses import JSONResponse
import argparse
import asyncio
# Fix to IPV4 Connection Issue #853
# Will disable features in ProactorEventLoop including subprocess pipes and named pipes
import sys
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
import logging
from contextlib import asynccontextmanager
import os
import threading
import time
from typing import AsyncIterator, Any
from urllib.parse import urlparse
# Workaround for environments where tool signature evaluation runs with a globals
# dict that does not include common `typing` names (e.g. when annotations are strings
# and evaluated via `eval()` during schema generation).
# Making these names available in builtins avoids `NameError: Annotated/Literal/... is not defined`.
try: # pragma: no cover - startup safety guard
import builtins
import typing as _typing
_typing_names = (
"Annotated",
"Literal",
"Any",
"Union",
"Optional",
"Dict",
"List",
"Tuple",
"Set",
"FrozenSet",
)
for _name in _typing_names:
if not hasattr(builtins, _name) and hasattr(_typing, _name):
# type: ignore[attr-defined]
setattr(builtins, _name, getattr(_typing, _name))
except Exception:
pass
from fastmcp import FastMCP
from logging.handlers import RotatingFileHandler
class WindowsSafeRotatingFileHandler(RotatingFileHandler):
"""RotatingFileHandler that gracefully handles Windows file locking during rotation."""
def doRollover(self):
"""Override to catch PermissionError on Windows when log file is locked."""
try:
super().doRollover()
except PermissionError:
# On Windows, another process may have the log file open.
# Skip rotation this time - we'll try again on the next rollover.
pass
# Configure logging using settings from config
logging.basicConfig(
level=getattr(logging, config.log_level),
format=config.log_format,
stream=None, # None -> defaults to sys.stderr; avoid stdout used by MCP stdio
force=True # Ensure our handler replaces any prior stdout handlers
)
logger = logging.getLogger("mcp-for-unity-server")
# Also write logs to a rotating file so logs are available when launched via stdio.
# Location follows OS conventions; override with UNITY_MCP_LOG_DIR.
try:
from utils.log_paths import resolve_log_dir
_log_dir = resolve_log_dir()
os.makedirs(_log_dir, exist_ok=True)
_file_path = os.path.join(_log_dir, "unity_mcp_server.log")
_fh = WindowsSafeRotatingFileHandler(
_file_path, maxBytes=512*1024, backupCount=2, encoding="utf-8")
_fh.setFormatter(logging.Formatter(config.log_format))
_fh.setLevel(getattr(logging, config.log_level))
logger.addHandler(_fh)
logger.propagate = False # Prevent double logging to root logger
# Add file handler to root logger so __name__-based loggers (e.g. utils.focus_nudge,
# services.tools.run_tests) also write to the log file. Named loggers with
# propagate=False won't double-log.
logging.getLogger().addHandler(_fh)
# Also route telemetry logger to the same rotating file and normal level
try:
tlog = logging.getLogger("unity-mcp-telemetry")
tlog.setLevel(getattr(logging, config.log_level))
tlog.addHandler(_fh)
tlog.propagate = False # Prevent double logging for telemetry too
except Exception as exc:
# Never let logging setup break startup
logger.debug("Failed to configure telemetry logger", exc_info=exc)
except Exception as exc:
# Never let logging setup break startup
logger.debug("Failed to configure main logger file handler", exc_info=exc)
# Quieten noisy third-party loggers to avoid clutter during stdio handshake
for noisy in ("httpx", "urllib3", "mcp.server.lowlevel.server"):
try:
logging.getLogger(noisy).setLevel(
max(logging.WARNING, getattr(logging, config.log_level)))
logging.getLogger(noisy).propagate = False
except Exception:
pass
# Import telemetry only after logging is configured to ensure its logs use stderr and proper levels
# Ensure a slightly higher telemetry timeout unless explicitly overridden by env
try:
# Ensure generous timeout unless explicitly overridden by env
if not os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT"):
os.environ["UNITY_MCP_TELEMETRY_TIMEOUT"] = "5.0"
except Exception:
pass
# Global connection pool
_unity_connection_pool: UnityConnectionPool | None = None
_plugin_registry: PluginRegistry | None = None
# Cached server version (set at startup to avoid repeated I/O)
_server_version: str | None = None
# In-memory custom tool service initialized after MCP construction
custom_tool_service: CustomToolService | None = None
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
"""Handle server startup and shutdown."""
global _unity_connection_pool, _server_version
_server_version = get_package_version()
logger.info(f"MCP for Unity Server v{_server_version} starting up")
# Register custom tool management endpoints with FastMCP
# Routes are declared globally below after FastMCP initialization
# Note: When using HTTP transport, FastMCP handles the HTTP server
# Tool registration will be handled through FastMCP endpoints
enable_http_server = os.environ.get(
"UNITY_MCP_ENABLE_HTTP_SERVER", "").lower() in ("1", "true", "yes", "on")
if enable_http_server:
http_host = os.environ.get("UNITY_MCP_HTTP_HOST", "localhost")
http_port = int(os.environ.get("UNITY_MCP_HTTP_PORT", "8080"))
logger.info(
f"HTTP tool registry will be available on http://{http_host}:{http_port}")
global _plugin_registry
if _plugin_registry is None:
_plugin_registry = PluginRegistry()
loop = asyncio.get_running_loop()
PluginHub.configure(_plugin_registry, loop, mcp=server)
# Record server startup telemetry
start_time = time.time()
start_clk = time.perf_counter()
# Defer initial telemetry by 1s to avoid stdio handshake interference
def _emit_startup():
try:
record_telemetry(RecordType.STARTUP, {
"server_version": _server_version,
"startup_time": start_time,
})
record_milestone(MilestoneType.FIRST_STARTUP)
except Exception:
logger.debug("Deferred startup telemetry failed", exc_info=True)
threading.Timer(1.0, _emit_startup).start()
try:
skip_connect = os.environ.get(
"UNITY_MCP_SKIP_STARTUP_CONNECT", "").lower() in ("1", "true", "yes", "on")
if skip_connect:
logger.info(
"Skipping Unity connection on startup (UNITY_MCP_SKIP_STARTUP_CONNECT=1)")
else:
# Initialize connection pool and discover instances
_unity_connection_pool = get_unity_connection_pool()
instances = _unity_connection_pool.discover_all_instances()
if instances:
logger.info(
f"Discovered {len(instances)} Unity instance(s): {[i.id for i in instances]}")
# Try to connect to default instance
try:
_unity_connection_pool.get_connection()
logger.info(
"Connected to default Unity instance on startup")
# In stdio mode, query Unity for tool enabled states and sync
# server-level visibility. In HTTP mode this is handled by
# register_tools via WebSocket in PluginHub.
if (config.transport_mode or "stdio").lower() != "http":
try:
from services.tools import sync_tool_visibility_from_unity
sync_result = await sync_tool_visibility_from_unity(notify=False)
if sync_result.get("synced"):
logger.info(
"Stdio startup: synced tool visibility from Unity — "
"enabled=[%s], disabled=[%s]",
", ".join(sync_result.get("enabled_groups", [])),
", ".join(sync_result.get("disabled_groups", [])),
)
else:
# Unsupported command = old Unity package; just debug-log
log_fn = logger.debug if sync_result.get("unsupported") else logger.warning
log_fn(
"Stdio startup: could not sync tool visibility: %s",
sync_result.get("error", "unknown"),
)
except Exception as sync_exc:
logger.debug(
"Stdio startup: tool visibility sync failed: %s", sync_exc)
# Record successful Unity connection (deferred)
threading.Timer(1.0, lambda: record_telemetry(
RecordType.UNITY_CONNECTION,
{
"status": "connected",
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
"instance_count": len(instances)
}
)).start()
except Exception as e:
logger.warning(
f"Could not connect to default Unity instance: {e}")
else:
logger.warning("No Unity instances found on startup")
except ConnectionError as e:
logger.warning(f"Could not connect to Unity on startup: {e}")
# Record connection failure (deferred)
_err_msg = str(e)[:200]
threading.Timer(1.0, lambda: record_telemetry(
RecordType.UNITY_CONNECTION,
{
"status": "failed",
"error": _err_msg,
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
}
)).start()
except Exception as e:
logger.warning(f"Unexpected error connecting to Unity on startup: {e}")
_err_msg = str(e)[:200]
threading.Timer(1.0, lambda: record_telemetry(
RecordType.UNITY_CONNECTION,
{
"status": "failed",
"error": _err_msg,
"connection_time_ms": (time.perf_counter() - start_clk) * 1000,
}
)).start()
try:
# Yield shared state for lifespan consumers (e.g., middleware)
yield {
"pool": _unity_connection_pool,
"plugin_registry": _plugin_registry,
}
finally:
if _unity_connection_pool:
_unity_connection_pool.disconnect_all()
logger.info("MCP for Unity Server shut down")
def _build_instructions(project_scoped_tools: bool) -> str:
if project_scoped_tools:
custom_tools_note = (
"I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first "
"to see what special capabilities are available for the current project."
)
else:
custom_tools_note = (
"Custom tools are registered as standard tools when Unity connects. "
"No project-scoped custom tools resource is available."
)
return f"""
This server provides tools to interact with the Unity Game Engine Editor.
{custom_tools_note}
Targeting Unity instances:
- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
- When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources to pin routing for the whole session. The server will error if multiple are connected and no active instance is set.
- Alternatively, pass unity_instance as a parameter on any individual tool call to route just that call (e.g. unity_instance="MyGame@abc123", unity_instance="abc" for a hash prefix, or unity_instance="6401" for a port number in stdio mode). This does not change the session default.
Important Workflows:
Resources vs Tools:
- Use RESOURCES to read editor state (editor_state, project_info, project_tags, tests, etc)
- Use TOOLS to perform actions and mutations (manage_editor for play mode control, tag/layer management, etc)
- Always check related resources before modifying the engine state with tools
Reading resources (read this before using ANY resource named below):
- Resources are addressed by URI, never by name. A resource's name and URI are NOT interchangeable: names use underscores (e.g. editor_state) while URIs use slashes (e.g. mcpforunity://editor/state). Do NOT build a URI by swapping separators in the name — you will 404.
- Always read the exact URI from your MCP client's resource listing (resources/list). Where these instructions mention a resource by name, look up its URI in that listing rather than guessing it.
- Resource payloads are wrapped: the content lives under a top-level `data` object, so field paths are `data.<section>.<field>` (e.g. `data.advice.ready_for_tools`), not bare top-level fields.
Script Management:
- After creating or modifying scripts (by your own tools or the `manage_script` tool) use `read_console` to check for compilation errors before proceeding
- Only after successful compilation can new components/types be used
- You can poll the `editor_state` resource's `isCompiling` field to check if the domain reload is complete
Scene Setup:
- Always include a Camera and main Light (Directional Light) in new scenes
- Create prefabs with `manage_asset` for reusable GameObjects
- Use `manage_scene` to load, save, and query scene information
Path Conventions:
- Unless specified otherwise, all paths are relative to the project's `Assets/` folder
- Use forward slashes (/) in paths for cross-platform compatibility
Console Monitoring:
- Check `read_console` regularly to catch errors, warnings, and compilation status
- Filter by log type (Error, Warning, Log) to focus on specific issues
Menu Items:
- Use `execute_menu_item` when you have read the menu items resource
- This lets you interact with Unity's menu system and third-party tools
Unity API Verification (requires 'docs' tool group):
- When the 'docs' tool group is active, use `unity_reflect` and `unity_docs` to verify Unity API details before answering questions or writing C# code. LLM training data frequently contains incorrect, outdated, or hallucinated Unity APIs.
- BEFORE answering Unity API questions: search the project's assets (`manage_asset`) and reflect the API (`unity_reflect`) to verify. Do NOT rely on training data alone.
- Common hallucination areas: shaders and materials (always search assets for actual shader names), package-specific APIs (Input System, Cinemachine, ProBuilder, NavMesh, URP/HDRP), and APIs that changed between Unity versions.
- Workflow: `unity_reflect search` → `unity_reflect get_type` → `unity_reflect get_member` → `unity_docs get_doc` (if you need examples/caveats).
- For shader/material questions: use `manage_asset(action="search", filter_type="Shader")` to find actual shaders in the project before recommending one.
Payload sizing & paging (important):
- Many Unity queries can return very large JSON. Prefer **paged + summary-first** calls.
- `manage_scene(action="get_hierarchy")`:
- Use `page_size` + `cursor` and follow `next_cursor` until null.
- `page_size` is **items per page**; recommended starting point: **50**.
- `manage_gameobject(action="get_components")`:
- Start with `include_properties=false` (metadata-only) and small `page_size` (e.g. **10-25**).
- Only request `include_properties=true` when needed; keep `page_size` small (e.g. **3-10**) to bound payloads.
- `manage_asset(action="search")`:
- Use paging (`page_size`, `page_number`) and keep `page_size` modest (e.g. **25-50**) to avoid token-heavy responses.
- Keep `generate_preview=false` unless you explicitly need thumbnails (previews may include large base64 payloads).
"""
def _normalize_instance_token(instance_token: str | None) -> tuple[str | None, str | None]:
if not instance_token:
return None, None
if "@" in instance_token:
name_part, _, hash_part = instance_token.partition("@")
return (name_part or None), (hash_part or None)
return None, instance_token
def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
mcp = FastMCP(
name="mcp-for-unity-server",
lifespan=server_lifespan,
instructions=_build_instructions(project_scoped_tools),
)
global custom_tool_service
custom_tool_service = CustomToolService(
mcp, project_scoped_tools=project_scoped_tools)
@mcp.custom_route("/health", methods=["GET"])
async def health_http(_: Request) -> JSONResponse:
return JSONResponse({
"status": "healthy",
"timestamp": time.time(),
"version": _server_version or "unknown",
"message": "MCP for Unity server is running"
})
@mcp.custom_route("/api/auth/login-url", methods=["GET"])
async def auth_login_url(_: Request) -> JSONResponse:
"""Return the login URL for users to obtain/manage API keys."""
if not config.api_key_login_url:
return JSONResponse(
{
"success": False,
"error": "API key management not configured. Contact your server administrator.",
},
status_code=404,
)
return JSONResponse({
"success": True,
"login_url": config.api_key_login_url,
})
# Only expose CLI routes if running locally (not in remote hosted mode)
if not config.http_remote_hosted:
@mcp.custom_route("/api/command", methods=["POST"])
async def cli_command_route(request: Request) -> JSONResponse:
"""REST endpoint for CLI commands to Unity."""
try:
body = await request.json()
command_type = body.get("type")
params = body.get("params", {})
unity_instance = body.get("unity_instance")
if not command_type:
return JSONResponse({"success": False, "error": "Missing 'type' field"}, status_code=400)
# Get available sessions
sessions = await PluginHub.get_sessions()
if not sessions.sessions:
return JSONResponse({
"success": False,
"error": "No Unity instances connected. Make sure Unity is running with MCP plugin."
}, status_code=503)
# Find target session
session_id = None
session_details = None
instance_name, instance_hash = _normalize_instance_token(
unity_instance)
if unity_instance:
# Try to match by hash or project name
for sid, details in sessions.sessions.items():
if details.hash == instance_hash or details.project in (instance_name, unity_instance):
session_id = sid
session_details = details
break
# If a specific unity_instance was requested but not found, return an error
# (Check done here so execute_custom_tool can also validate the instance)
if unity_instance and not session_id:
return JSONResponse(
{
"success": False,
"error": f"Unity instance '{unity_instance}' not found",
},
status_code=404,
)
# If no specific unity_instance requested, use first available session
# (Must be done before execute_custom_tool check so all command types benefit)
if not session_id:
try:
session_id = next(iter(sessions.sessions.keys()))
session_details = sessions.sessions.get(session_id)
except StopIteration:
# No sessions available - sessions.sessions is empty
# This should not happen since we checked at line 378, but handle gracefully
return JSONResponse({
"success": False,
"error": "No Unity instances connected. Make sure Unity is running with MCP plugin."
}, status_code=503)
# Custom tool execution - must be checked BEFORE the final PluginHub.send_command call
# This applies to both cases: with or without explicit unity_instance
if command_type == "execute_custom_tool":
# session_id and session_details are already set above
if not session_id or not session_details:
return JSONResponse(
{"success": False,
"error": "No valid Unity session available for custom tool execution"},
status_code=503,
)
tool_name = None
tool_params = {}
if isinstance(params, dict):
tool_name = params.get(
"tool_name") or params.get("name")
tool_params = params.get(
"parameters") or params.get("params") or {}
if not tool_name:
return JSONResponse(
{"success": False,
"error": "Missing 'tool_name' for execute_custom_tool"},
status_code=400,
)
if tool_params is None:
tool_params = {}
if not isinstance(tool_params, dict):
return JSONResponse(
{"success": False,
"error": "Tool parameters must be an object/dict"},
status_code=400,
)
# Prefer a concrete hash for project-scoped tools.
unity_instance_hint = unity_instance
if session_details and session_details.hash:
unity_instance_hint = session_details.hash
project_id = resolve_project_id_for_unity_instance(
unity_instance_hint)
if not project_id:
return JSONResponse(
{"success": False,
"error": "Could not resolve project id for custom tool"},
status_code=400,
)
service = CustomToolService.get_instance()
result = await service.execute_tool(
project_id, tool_name, unity_instance_hint, tool_params
)
return JSONResponse(result.model_dump())
# Send command to Unity
result = await PluginHub.send_command(session_id, command_type, params)
return JSONResponse(result)
except Exception as e:
logger.exception("CLI command error: %s", e)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@mcp.custom_route("/api/instances", methods=["GET"])
async def cli_instances_route(_: Request) -> JSONResponse:
"""REST endpoint to list connected Unity instances."""
try:
sessions = await PluginHub.get_sessions()
instances = []
for session_id, details in sessions.sessions.items():
instances.append({
"session_id": session_id,
"project": details.project,
"hash": details.hash,
"unity_version": details.unity_version,
"connected_at": details.connected_at,
})
return JSONResponse({"success": True, "instances": instances})
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@mcp.custom_route("/api/custom-tools", methods=["GET"])
async def cli_custom_tools_route(request: Request) -> JSONResponse:
"""REST endpoint to list custom tools for the active Unity project."""
try:
unity_instance = request.query_params.get("instance")
instance_name, instance_hash = _normalize_instance_token(
unity_instance)
sessions = await PluginHub.get_sessions()
if not sessions.sessions:
return JSONResponse({
"success": False,
"error": "No Unity instances connected. Make sure Unity is running with MCP plugin."
}, status_code=503)
session_details = None
if unity_instance:
# Try to match by hash or project name
for _, details in sessions.sessions.items():
if details.hash == instance_hash or details.project in (instance_name, unity_instance):
session_details = details
break
if not session_details:
return JSONResponse(
{
"success": False,
"error": f"Unity instance '{unity_instance}' not found",
},
status_code=404,
)
else:
# No specific unity_instance requested: use first available session
session_details = next(iter(sessions.sessions.values()))
unity_instance_hint = unity_instance
if session_details and session_details.hash:
unity_instance_hint = session_details.hash
project_id = resolve_project_id_for_unity_instance(
unity_instance_hint)
if not project_id:
return JSONResponse(
{"success": False,
"error": "Could not resolve project id for custom tools"},
status_code=400,
)
service = CustomToolService.get_instance()
tools = await service.list_registered_tools(project_id)
tools_payload = [
tool.model_dump() if hasattr(tool, "model_dump") else tool for tool in tools
]
return JSONResponse({
"success": True,
"project_id": project_id,
"tool_count": len(tools_payload),
"tools": tools_payload,
})
except Exception as e:
logger.exception("CLI custom tools error: %s", e)
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# Initialize and register middleware for session-based Unity instance routing
# Using the singleton getter ensures we use the same instance everywhere
unity_middleware = get_unity_instance_middleware()
mcp.add_middleware(unity_middleware)
logger.info("Registered Unity instance middleware for session-based routing")
# Initialize API key authentication if in remote-hosted mode
if config.http_remote_hosted and config.api_key_validation_url:
ApiKeyService(
validation_url=config.api_key_validation_url,
cache_ttl=config.api_key_cache_ttl,
service_token_header=config.api_key_service_token_header,
service_token=config.api_key_service_token,
)
logger.info(
"Initialized API key authentication service (validation URL: %s, TTL: %.0fs)",
config.api_key_validation_url,
config.api_key_cache_ttl,
)
# Mount plugin websocket hub at /hub/plugin when HTTP transport is active.
# NOTE: Uses FastMCP private API because custom_route() only supports HTTP
# methods, not WebSocket. _additional_http_routes accepts Starlette Route
# objects and is still present in FastMCP 3.x.
existing_routes = [
route for route in mcp._get_additional_http_routes()
if isinstance(route, WebSocketRoute) and route.path == "/hub/plugin"
]
if not existing_routes:
mcp._additional_http_routes.append(
WebSocketRoute("/hub/plugin", PluginHub))
# Register all tools
register_all_tools(mcp, project_scoped_tools=project_scoped_tools)
# Register all resources
register_all_resources(mcp, project_scoped_tools=project_scoped_tools)
return mcp
def main():
"""Entry point for uvx and console scripts."""
parser = argparse.ArgumentParser(
description="MCP for Unity Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Environment Variables:
UNITY_MCP_DEFAULT_INSTANCE Default Unity instance to target (project name, hash, or 'Name@hash')
UNITY_MCP_SKIP_STARTUP_CONNECT Skip initial Unity connection attempt (set to 1/true/yes/on)
UNITY_MCP_TELEMETRY_ENABLED Enable telemetry (set to 1/true/yes/on)
UNITY_MCP_TRANSPORT Transport protocol: stdio or http (default: stdio)
UNITY_MCP_HTTP_URL HTTP server URL (default: http://127.0.0.1:8080)
UNITY_MCP_HTTP_HOST HTTP server host (overrides URL host)
UNITY_MCP_HTTP_PORT HTTP server port (overrides URL port)
Examples:
# Use specific Unity project as default
python -m src.server --default-instance "MyProject"
# Start with HTTP transport
python -m src.server --transport http --http-url http://127.0.0.1:8080
# Start with stdio transport (default)
python -m src.server --transport stdio
# Use environment variable for transport
UNITY_MCP_TRANSPORT=http UNITY_MCP_HTTP_URL=http://localhost:9000 python -m src.server
"""
)
parser.add_argument(
"--default-instance",
type=str,
metavar="INSTANCE",
help="Default Unity instance to target (project name, hash, or 'Name@hash'). "
"Overrides UNITY_MCP_DEFAULT_INSTANCE environment variable."
)
parser.add_argument(
"--transport",
type=str,
choices=["stdio", "http"],
default="stdio",
help="Transport protocol to use: stdio or http (default: stdio). "
"Overrides UNITY_MCP_TRANSPORT environment variable."
)
parser.add_argument(
"--http-url",
type=str,
default="http://127.0.0.1:8080",
metavar="URL",
help="HTTP server URL (default: http://127.0.0.1:8080). "
"Can also set via UNITY_MCP_HTTP_URL environment variable."
)
parser.add_argument(
"--http-host",
type=str,
default=None,
metavar="HOST",
help="HTTP server host (overrides URL host). "
"Overrides UNITY_MCP_HTTP_HOST environment variable."
)
parser.add_argument(
"--http-port",
type=int,
default=None,
metavar="PORT",
help="HTTP server port (overrides URL port). "
"Overrides UNITY_MCP_HTTP_PORT environment variable."
)
parser.add_argument(
"--http-remote-hosted",
action="store_true",
help="Treat HTTP transport as remotely hosted (forces explicit Unity instance selection). "
"Can also set via UNITY_MCP_HTTP_REMOTE_HOSTED=true."
)
parser.add_argument(
"--api-key-validation-url",
type=str,
default=None,
metavar="URL",
help="External URL to validate API keys (POST with {'api_key': '...'}). "
"Required when --http-remote-hosted is set. "
"Can also set via UNITY_MCP_API_KEY_VALIDATION_URL."
)
parser.add_argument(
"--api-key-login-url",
type=str,
default=None,
metavar="URL",
help="URL where users can obtain/manage API keys. "
"Returned by /api/auth/login-url endpoint. "
"Can also set via UNITY_MCP_API_KEY_LOGIN_URL."
)
parser.add_argument(
"--api-key-cache-ttl",
type=float,
default=300.0,
metavar="SECONDS",
help="Cache TTL for validated API keys in seconds (default: 300). "
"Can also set via UNITY_MCP_API_KEY_CACHE_TTL."
)
parser.add_argument(
"--api-key-service-token-header",
type=str,
default=None,
metavar="HEADER",
help="Header name for service token sent to validation endpoint (e.g. X-Service-Token). "
"Can also set via UNITY_MCP_API_KEY_SERVICE_TOKEN_HEADER."
)
parser.add_argument(
"--api-key-service-token",
type=str,
default=None,
metavar="TOKEN",
help="Service token value sent to validation endpoint for server authentication. "
"WARNING: Prefer UNITY_MCP_API_KEY_SERVICE_TOKEN env var in production to avoid process listing exposure."
)
parser.add_argument(
"--unity-instance-token",
type=str,
default=None,
metavar="TOKEN",
help="Optional per-launch token set by Unity for deterministic lifecycle management. "
"Used by Unity to validate it is stopping the correct process."
)
parser.add_argument(
"--pidfile",
type=str,
default=None,
metavar="PATH",
help="Optional path where the server will write its PID on startup. "
"Used by Unity to stop the exact process it launched when running in a terminal."
)
parser.add_argument(
"--project-scoped-tools",
action="store_true",
help="Keep custom tools scoped to the active Unity project and enable the custom tools resource. "
"Can also set via UNITY_MCP_PROJECT_SCOPED_TOOLS=true."
)
args = parser.parse_args()
# Set environment variables from command line args
if args.default_instance:
os.environ["UNITY_MCP_DEFAULT_INSTANCE"] = args.default_instance
logger.info(
f"Using default Unity instance from command-line: {args.default_instance}")
# Set transport mode
config.transport_mode = args.transport or os.environ.get(
"UNITY_MCP_TRANSPORT", "stdio")
logger.info(f"Transport mode: {config.transport_mode}")
config.http_remote_hosted = (
bool(args.http_remote_hosted)
or os.environ.get("UNITY_MCP_HTTP_REMOTE_HOSTED", "").lower() in ("true", "1", "yes", "on")
)
# API key authentication configuration
config.api_key_validation_url = (
args.api_key_validation_url
or os.environ.get("UNITY_MCP_API_KEY_VALIDATION_URL")
)
config.api_key_login_url = (
args.api_key_login_url
or os.environ.get("UNITY_MCP_API_KEY_LOGIN_URL")
)
try:
cache_ttl_env = os.environ.get("UNITY_MCP_API_KEY_CACHE_TTL")
config.api_key_cache_ttl = (
float(cache_ttl_env) if cache_ttl_env else args.api_key_cache_ttl
)
except ValueError:
logger.warning(
"Invalid UNITY_MCP_API_KEY_CACHE_TTL value, using default 300.0"
)
config.api_key_cache_ttl = 300.0
# Service token for authenticating to validation endpoint
config.api_key_service_token_header = (
args.api_key_service_token_header
or os.environ.get("UNITY_MCP_API_KEY_SERVICE_TOKEN_HEADER")
)
config.api_key_service_token = (
args.api_key_service_token
or os.environ.get("UNITY_MCP_API_KEY_SERVICE_TOKEN")
)
# Validate: remote-hosted HTTP mode requires API key validation URL
if config.http_remote_hosted and config.transport_mode == "http" and not config.api_key_validation_url:
logger.error(
"--http-remote-hosted requires --api-key-validation-url or "
"UNITY_MCP_API_KEY_VALIDATION_URL environment variable"
)
raise SystemExit(1)
http_url = os.environ.get("UNITY_MCP_HTTP_URL", args.http_url)
parsed_url = urlparse(http_url)
# Allow individual host/port to override URL components
http_host = args.http_host or os.environ.get(
"UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "127.0.0.1"
# Safely parse optional environment port (may be None or non-numeric)
_env_port_str = os.environ.get("UNITY_MCP_HTTP_PORT")
try:
_env_port = int(_env_port_str) if _env_port_str is not None else None
except ValueError:
logger.warning(
"Invalid UNITY_MCP_HTTP_PORT value '%s', ignoring", _env_port_str)
_env_port = None
http_port = args.http_port or _env_port or parsed_url.port or 8080
os.environ["UNITY_MCP_HTTP_HOST"] = http_host
os.environ["UNITY_MCP_HTTP_PORT"] = str(http_port)
# Optional lifecycle handshake for Unity-managed terminal launches
if args.unity_instance_token:
os.environ["UNITY_MCP_INSTANCE_TOKEN"] = args.unity_instance_token
if args.pidfile:
try:
pid_dir = os.path.dirname(args.pidfile)
if pid_dir:
os.makedirs(pid_dir, exist_ok=True)
with open(args.pidfile, "w", encoding="ascii") as f:
f.write(str(os.getpid()))
except Exception as exc:
logger.warning(
"Failed to write pidfile '%s': %s", args.pidfile, exc)
if args.http_url != "http://127.0.0.1:8080":
logger.info(f"HTTP URL set to: {http_url}")
if args.http_host:
logger.info(f"HTTP host override: {http_host}")
if args.http_port:
logger.info(f"HTTP port override: {http_port}")
# Explicit CLI/env overrides always win
project_scoped_tools_explicit = (
bool(args.project_scoped_tools)
or os.environ.get("UNITY_MCP_PROJECT_SCOPED_TOOLS", "").lower() in ("true", "1", "yes", "on")
)
# If not explicitly set, check Unity status files for the default instance.
# In stdio mode there is typically only one instance, so "first match wins" is fine.
project_scoped_tools = project_scoped_tools_explicit
if not project_scoped_tools_explicit:
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances()
# If ANY discovered instance requests project-scoped tools, enable them
for inst in instances:
if getattr(inst, "project_scoped_tools", False):
project_scoped_tools = True
logger.info(
"Enabling project-scoped tools because Unity instance %s requested it",
inst.id,
)
break
except Exception:
logger.debug("Could not discover Unity instances for project-scoped tool default", exc_info=True)
mcp = create_mcp_server(project_scoped_tools)
# Determine transport mode
if config.transport_mode == 'http':
# Use HTTP transport for FastMCP
transport = 'http'
# Use the parsed host and port from URL/args
http_url = os.environ.get("UNITY_MCP_HTTP_URL", args.http_url)
parsed_url = urlparse(http_url)
host = args.http_host or os.environ.get(
"UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "127.0.0.1"
port = args.http_port or _env_port or parsed_url.port or 8080
logger.info(f"Starting FastMCP with HTTP transport on {host}:{port}")
mcp.run(transport=transport, host=host, port=port)
else:
# Use stdio transport for traditional MCP
logger.info("Starting FastMCP with stdio transport")
mcp.run(transport='stdio')
# Run the server
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
from .models import MCPResponse, UnityInstanceInfo
from .unity_response import normalize_unity_response, parse_resource_response
__all__ = ['MCPResponse', 'UnityInstanceInfo', 'normalize_unity_response', 'parse_resource_response']
+59
View File
@@ -0,0 +1,59 @@
from typing import Any
from datetime import datetime
from pydantic import BaseModel, Field
class MCPResponse(BaseModel):
success: bool
message: str | None = None
error: str | None = None
data: Any | None = None
# Optional hint for clients about how to handle the response.
# Supported values:
# - "retry": Unity is temporarily reloading; call should be retried politely.
hint: str | None = None
class ToolParameterModel(BaseModel):
name: str
description: str | None = None
type: str = Field(default="string")
required: bool = Field(default=True)
default_value: str | None = None
class ToolDefinitionModel(BaseModel):
name: str
description: str | None = None
structured_output: bool | None = True
requires_polling: bool | None = False
poll_action: str | None = "status"
max_poll_seconds: int = 0
parameters: list[ToolParameterModel] = Field(default_factory=list)
class UnityInstanceInfo(BaseModel):
"""Information about a Unity Editor instance"""
id: str # "ProjectName@hash" or fallback to hash
name: str # Project name extracted from path
path: str # Full project path (Assets folder)
hash: str # 8-char hash of project path
port: int # TCP port
status: str # "running", "reloading", "offline"
last_heartbeat: datetime | None = None
unity_version: str | None = None
project_scoped_tools: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
"id": self.id,
"name": self.name,
"path": self.path,
"hash": self.hash,
"port": self.port,
"status": self.status,
"last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
"unity_version": self.unity_version,
"project_scoped_tools": self.project_scoped_tools,
}
+70
View File
@@ -0,0 +1,70 @@
"""Utilities for normalizing Unity transport responses."""
from __future__ import annotations
from typing import Any, Type
from models.models import MCPResponse
def normalize_unity_response(response: Any) -> Any:
"""Normalize Unity's {status,result} payloads into MCPResponse shape."""
if not isinstance(response, dict):
return response
status = response.get("status")
result = response.get("result") if isinstance(
response.get("result"), dict) else response.get("result")
# Already MCPResponse-shaped
if "success" in response:
return response
if isinstance(result, dict) and "success" in result:
return result
if status is None:
return response
payload = result if isinstance(result, dict) else {}
success = status == "success"
message = payload.get("message") or response.get("message")
error = payload.get("error") or response.get("error")
data = payload.get("data")
if data is None and isinstance(payload, dict) and payload:
data = {k: v for k, v in payload.items() if k not in {
"message", "error", "status", "code"}}
if not data:
data = None
normalized: dict[str, Any] = {
"success": success,
"message": message,
"error": error if not success else None,
"data": data,
}
if not success and not normalized["error"]:
normalized["error"] = message or "Unity command failed"
return normalized
def parse_resource_response(response: Any, typed_cls: Type[MCPResponse]) -> MCPResponse:
"""Parse a Unity response into a typed response class.
Returns a base ``MCPResponse`` for error responses so that typed subclasses
with strict ``data`` fields (e.g. ``list[str]``) don't raise Pydantic
validation errors when ``data`` is ``None``.
"""
if not isinstance(response, dict):
return response
# Detect errors from both normalized (success=False) and raw (status="error") shapes.
if response.get("success") is False or response.get("status") == "error":
return MCPResponse(
success=False,
error=response.get("error"),
message=response.get("message"),
)
return typed_cls(**response)
View File
+235
View File
@@ -0,0 +1,235 @@
"""API Key validation service for remote-hosted mode."""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger("mcp-for-unity-server")
@dataclass
class ValidationResult:
"""Result of an API key validation."""
valid: bool
user_id: str | None = None
metadata: dict[str, Any] | None = None
error: str | None = None
cacheable: bool = True
class ApiKeyService:
"""Service for validating API keys against an external auth endpoint.
Follows the class-level singleton pattern for global access by MCP tools.
"""
_instance: "ApiKeyService | None" = None
# Request defaults (sensible hardening)
REQUEST_TIMEOUT: float = 5.0
MAX_RETRIES: int = 1
def __init__(
self,
validation_url: str,
cache_ttl: float = 300.0,
service_token_header: str | None = None,
service_token: str | None = None,
):
"""Initialize the API key service.
Args:
validation_url: External URL to validate API keys (POST with {"api_key": "..."})
cache_ttl: Cache TTL for validated keys in seconds (default: 300)
service_token_header: Optional header name for service authentication (e.g. "X-Service-Token")
service_token: Optional token value for service authentication
"""
self._validation_url = validation_url
self._cache_ttl = cache_ttl
self._service_token_header = service_token_header
self._service_token = service_token
# Cache: api_key -> (valid, user_id, metadata, expires_at)
self._cache: dict[str, tuple[bool, str |
None, dict[str, Any] | None, float]] = {}
self._cache_lock = asyncio.Lock()
ApiKeyService._instance = self
@classmethod
def get_instance(cls) -> "ApiKeyService":
"""Get the singleton instance.
Raises:
RuntimeError: If the service has not been initialized.
"""
if cls._instance is None:
raise RuntimeError("ApiKeyService not initialized")
return cls._instance
@classmethod
def is_initialized(cls) -> bool:
"""Check if the service has been initialized."""
return cls._instance is not None
async def validate(self, api_key: str) -> ValidationResult:
"""Validate an API key.
Returns:
ValidationResult with valid=True and user_id if valid,
or valid=False with error message if invalid.
"""
if not api_key:
return ValidationResult(valid=False, error="API key required")
# Check cache first
async with self._cache_lock:
cached = self._cache.get(api_key)
if cached is not None:
valid, user_id, metadata, expires_at = cached
if time.time() < expires_at:
if valid:
return ValidationResult(valid=True, user_id=user_id, metadata=metadata)
else:
return ValidationResult(valid=False, error="Invalid API key")
else:
# Expired, remove from cache
del self._cache[api_key]
# Call external validation URL
result = await self._validate_external(api_key)
# Only cache definitive results (valid keys and confirmed-invalid keys).
# Transient failures (auth service unavailable, timeouts, etc.) should
# not be cached to avoid locking out users during service outages.
if result.cacheable:
async with self._cache_lock:
expires_at = time.time() + self._cache_ttl
self._cache[api_key] = (
result.valid,
result.user_id,
result.metadata,
expires_at,
)
return result
async def _validate_external(self, api_key: str) -> ValidationResult:
"""Call external validation endpoint.
Failure mode: fail closed (treat as invalid on errors).
"""
# Redact API key from logs
redacted_key = f"{api_key[:4]}...{api_key[-4:]}" if len(
api_key) > 8 else "***"
for attempt in range(self.MAX_RETRIES + 1):
try:
async with httpx.AsyncClient(timeout=self.REQUEST_TIMEOUT) as client:
# Build request headers
headers = {"Content-Type": "application/json"}
if self._service_token_header and self._service_token:
headers[self._service_token_header] = self._service_token
response = await client.post(
self._validation_url,
json={"api_key": api_key},
headers=headers,
)
if response.status_code == 200:
data = response.json()
if data.get("valid"):
return ValidationResult(
valid=True,
user_id=data.get("user_id"),
metadata=data.get("metadata"),
)
else:
return ValidationResult(
valid=False,
error=data.get("error", "Invalid API key"),
)
elif response.status_code == 401:
return ValidationResult(valid=False, error="Invalid API key")
else:
logger.warning(
"API key validation returned status %d for key %s",
response.status_code,
redacted_key,
)
# Fail closed but don't cache (transient service error)
return ValidationResult(
valid=False,
error=f"Auth service error (status {response.status_code})",
cacheable=False,
)
except httpx.TimeoutException:
if attempt < self.MAX_RETRIES:
logger.debug(
"API key validation timeout for key %s, retrying...",
redacted_key,
)
await asyncio.sleep(0.1 * (attempt + 1))
continue
logger.warning(
"API key validation timeout for key %s after %d attempts",
redacted_key,
attempt + 1,
)
return ValidationResult(
valid=False,
error="Auth service timeout",
cacheable=False,
)
except httpx.RequestError as exc:
if attempt < self.MAX_RETRIES:
logger.debug(
"API key validation request error for key %s: %s, retrying...",
redacted_key,
exc,
)
await asyncio.sleep(0.1 * (attempt + 1))
continue
logger.warning(
"API key validation request error for key %s: %s",
redacted_key,
exc,
)
return ValidationResult(
valid=False,
error="Auth service unavailable",
cacheable=False,
)
except Exception as exc:
logger.error(
"Unexpected error validating API key %s: %s",
redacted_key,
exc,
)
return ValidationResult(
valid=False,
error="Auth service error",
cacheable=False,
)
# Should not reach here, but fail closed
return ValidationResult(valid=False, error="Auth service error", cacheable=False)
async def invalidate_cache(self, api_key: str) -> None:
"""Remove an API key from the cache."""
async with self._cache_lock:
self._cache.pop(api_key, None)
async def clear_cache(self) -> None:
"""Clear all cached validations."""
async with self._cache_lock:
self._cache.clear()
__all__ = ["ApiKeyService", "ValidationResult"]
+548
View File
@@ -0,0 +1,548 @@
import asyncio
import inspect
import logging
import time
from hashlib import sha256
from typing import Optional
from fastmcp import Context, FastMCP
from pydantic import BaseModel, Field, ValidationError
from starlette.requests import Request
from starlette.responses import JSONResponse
from core.config import config
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
from core.logging_decorator import log_execution
from core.telemetry_decorator import telemetry_tool
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import (
async_send_command_with_retry,
get_unity_connection_pool,
)
from transport.plugin_hub import PluginHub
from services.tools import get_unity_instance_from_context
from services.registry import get_registered_tools
logger = logging.getLogger("mcp-for-unity-server")
_DEFAULT_POLL_INTERVAL = 1.0
_MAX_POLL_SECONDS = 600
async def get_user_id_from_context(ctx: Context) -> str | None:
"""Read user_id from request-scoped context in remote-hosted mode."""
if not config.http_remote_hosted:
return None
get_state = getattr(ctx, "get_state", None)
if not callable(get_state):
return None
try:
user_id = await get_state("user_id")
except Exception:
return None
return user_id if isinstance(user_id, str) and user_id else None
class RegisterToolsPayload(BaseModel):
project_id: str
project_hash: str | None = None
tools: list[ToolDefinitionModel]
class ToolRegistrationResponse(BaseModel):
success: bool
registered: list[str]
replaced: list[str]
message: str
class CustomToolService:
_instance: "CustomToolService | None" = None
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
CustomToolService._instance = self
self._mcp = mcp
self._project_scoped_tools = project_scoped_tools
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
self._hash_to_project: dict[str, str] = {}
self._global_tools: dict[str, ToolDefinitionModel] = {}
self._register_http_routes()
@classmethod
def get_instance(cls) -> "CustomToolService":
if cls._instance is None:
raise RuntimeError("CustomToolService has not been initialized")
return cls._instance
# --- HTTP Routes -----------------------------------------------------
def _register_http_routes(self) -> None:
@self._mcp.custom_route("/register-tools", methods=["POST"])
async def register_tools(request: Request) -> JSONResponse:
try:
payload = RegisterToolsPayload.model_validate(await request.json())
except ValidationError as exc:
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
registered, replaced = self._register_project_tools(
payload.project_id, payload.tools, project_hash=payload.project_hash)
message = f"Registered {len(registered)} tool(s)"
if replaced:
message += f" (replaced: {', '.join(replaced)})"
response = ToolRegistrationResponse(
success=True,
registered=registered,
replaced=replaced,
message=message,
)
return JSONResponse(response.model_dump())
# --- Public API for MCP tools ---------------------------------------
async def list_registered_tools(
self,
project_id: str,
user_id: str | None = None,
) -> list[ToolDefinitionModel]:
legacy = list(self._project_tools.get(project_id, {}).values())
hub_tools = await PluginHub.get_tools_for_project(project_id, user_id=user_id)
return legacy + hub_tools
async def get_tool_definition(
self,
project_id: str,
tool_name: str,
user_id: str | None = None,
) -> ToolDefinitionModel | None:
tool = self._project_tools.get(project_id, {}).get(tool_name)
if tool:
return tool
tool = self._global_tools.get(tool_name)
if tool:
return tool
return await PluginHub.get_tool_definition(project_id, tool_name, user_id=user_id)
async def execute_tool(
self,
project_id: str,
tool_name: str,
unity_instance: str | None,
params: dict[str, object] | None = None,
user_id: str | None = None,
) -> MCPResponse:
params = params or {}
logger.info(
f"Executing tool '{tool_name}' for project '{project_id}' (instance={unity_instance}) with params: {params}"
)
definition = await self.get_tool_definition(project_id, tool_name, user_id=user_id)
if definition is None:
return MCPResponse(
success=False,
message=f"Tool '{tool_name}' not found for project {project_id}",
)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
tool_name,
params,
user_id=user_id,
)
if not definition.requires_polling:
result = self._normalize_response(response)
logger.info(f"Tool '{tool_name}' immediate response: {result}")
return result
result = await self._poll_until_complete(
tool_name,
unity_instance,
params,
response,
definition.poll_action or "status",
user_id=user_id,
max_poll_seconds=definition.max_poll_seconds or 0,
)
logger.info(f"Tool '{tool_name}' polled response: {result}")
return result
# --- Internal helpers ------------------------------------------------
def _is_registered(self, project_id: str, tool_name: str) -> bool:
return tool_name in self._project_tools.get(project_id, {})
def _register_tool(self, project_id: str, definition: ToolDefinitionModel) -> None:
self._project_tools.setdefault(project_id, {})[
definition.name] = definition
def get_project_id_for_hash(self, project_hash: str | None) -> str | None:
if not project_hash:
return None
return self._hash_to_project.get(project_hash.lower())
async def _poll_until_complete(
self,
tool_name: str,
unity_instance,
initial_params: dict[str, object],
initial_response,
poll_action: str,
user_id: str | None = None,
max_poll_seconds: int = 0,
) -> MCPResponse:
poll_params = dict(initial_params)
poll_params["action"] = poll_action or "status"
timeout = max_poll_seconds if max_poll_seconds > 0 else _MAX_POLL_SECONDS
deadline = time.time() + timeout
response = initial_response
while True:
status, poll_interval = self._interpret_status(response)
if status in ("complete", "error", "final"):
return self._normalize_response(response)
if time.time() > deadline:
return MCPResponse(
success=False,
message=f"Timeout waiting for {tool_name} to complete",
data=self._safe_response(response),
)
await asyncio.sleep(poll_interval)
try:
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
tool_name,
poll_params,
user_id=user_id,
)
except Exception as exc: # pragma: no cover - network/domain reload variability
logger.debug(f"Polling {tool_name} failed, will retry: {exc}")
# Back off modestly but stay responsive.
response = {
"_mcp_status": "pending",
"_mcp_poll_interval": min(max(poll_interval * 2, _DEFAULT_POLL_INTERVAL), 5.0),
"message": f"Retrying after transient error: {exc}",
}
def _interpret_status(self, response) -> tuple[str, float]:
if response is None:
return "pending", _DEFAULT_POLL_INTERVAL
if not isinstance(response, dict):
return "final", _DEFAULT_POLL_INTERVAL
status = response.get("_mcp_status")
if status is None:
if len(response.keys()) == 0:
return "pending", _DEFAULT_POLL_INTERVAL
return "final", _DEFAULT_POLL_INTERVAL
if status == "pending":
interval_raw = response.get(
"_mcp_poll_interval", _DEFAULT_POLL_INTERVAL)
try:
interval = float(interval_raw)
except (TypeError, ValueError):
interval = _DEFAULT_POLL_INTERVAL
interval = max(0.1, min(interval, 5.0))
return "pending", interval
if status == "complete":
return "complete", _DEFAULT_POLL_INTERVAL
if status == "error":
return "error", _DEFAULT_POLL_INTERVAL
return "final", _DEFAULT_POLL_INTERVAL
def _normalize_response(self, response) -> MCPResponse:
if isinstance(response, MCPResponse):
return response
if isinstance(response, dict):
return MCPResponse(
success=response.get("success", True),
message=response.get("message"),
error=response.get("error"),
data=response.get(
"data", response) if "data" not in response else response["data"],
)
success = True
message = None
error = None
data = None
if isinstance(response, dict):
success = response.get("success", True)
if "_mcp_status" in response and response["_mcp_status"] == "error":
success = False
message = str(response.get("message")) if response.get(
"message") else None
error = str(response.get("error")) if response.get(
"error") else None
data = response.get("data")
if "success" not in response and "_mcp_status" not in response:
data = response
else:
success = False
message = str(response)
return MCPResponse(success=success, message=message, error=error, data=data)
def _safe_response(self, response):
if isinstance(response, dict):
return response
if response is None:
return None
return {"message": str(response)}
def _register_project_tools(
self,
project_id: str,
tools: list[ToolDefinitionModel],
project_hash: str | None = None,
) -> tuple[list[str], list[str]]:
registered: list[str] = []
replaced: list[str] = []
for tool in tools:
if self._is_registered(project_id, tool.name):
replaced.append(tool.name)
self._register_tool(project_id, tool)
registered.append(tool.name)
if not self._project_scoped_tools:
self._register_global_tool(tool)
if project_hash:
self._hash_to_project[project_hash.lower()] = project_id
return registered, replaced
def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
# Global custom tools are always registered, even when project-scoped tools
# are enabled. Project-scoped tools can override globals by name, but
# disabling globals entirely would break shared tooling that projects expect.
builtin_names = self._get_builtin_tool_names()
for tool in tools:
if tool.name in builtin_names:
logger.info(
"Skipping global custom tool registration for built-in tool '%s'",
tool.name,
)
continue
self._register_global_tool(tool)
def _get_builtin_tool_names(self) -> set[str]:
return {tool["name"] for tool in get_registered_tools()}
def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
existing = self._global_tools.get(definition.name)
if existing:
if existing.model_dump() != definition.model_dump():
logger.warning(
"Custom tool '%s' already registered with a different schema; keeping existing definition.",
definition.name,
)
return
handler = self._build_global_tool_handler(definition)
wrapped = log_execution(definition.name, "Tool")(handler)
wrapped = telemetry_tool(definition.name)(wrapped)
try:
wrapped = self._mcp.tool(
name=definition.name,
description=definition.description,
)(wrapped)
except Exception as exc: # pragma: no cover - defensive against tool conflicts
logger.warning(
"Failed to register custom tool '%s' globally: %s",
definition.name,
exc,
)
return
self._global_tools[definition.name] = definition
def _build_global_tool_handler(self, definition: ToolDefinitionModel):
async def _handler(ctx: Context, **kwargs) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
if not unity_instance:
return MCPResponse(
success=False,
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
)
project_id = resolve_project_id_for_unity_instance(unity_instance)
if project_id is None:
return MCPResponse(
success=False,
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
)
params = {k: v for k, v in kwargs.items() if v is not None}
user_id = await get_user_id_from_context(ctx)
service = CustomToolService.get_instance()
return await service.execute_tool(
project_id,
definition.name,
unity_instance,
params,
user_id=user_id,
)
_handler.__name__ = f"custom_tool_{definition.name}"
_handler.__doc__ = definition.description or ""
_handler.__signature__ = self._build_signature(definition)
_handler.__annotations__ = self._build_annotations(definition)
return _handler
def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
params: list[inspect.Parameter] = [
inspect.Parameter(
"ctx",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=Context,
)
]
for param in definition.parameters:
if not param.name.isidentifier():
logger.warning(
"Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
definition.name,
param.name,
)
continue
default = inspect._empty if param.required else self._coerce_default(
param.default_value, param.type)
params.append(
inspect.Parameter(
param.name,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
default=default,
annotation=self._map_param_type(param),
)
)
return inspect.Signature(parameters=params)
def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
annotations: dict[str, object] = {"ctx": Context}
for param in definition.parameters:
if not param.name.isidentifier():
continue
annotations[param.name] = self._map_param_type(param)
return annotations
def _map_param_type(self, param: ToolParameterModel):
ptype = (param.type or "string").lower()
if ptype in ("integer", "int"):
return int
if ptype in ("number", "float", "double"):
return float
if ptype in ("bool", "boolean"):
return bool
if ptype in ("array", "list"):
return list
if ptype in ("object", "dict"):
return dict
return str
def _coerce_default(self, value: str | None, param_type: str | None):
if value is None:
return None
try:
ptype = (param_type or "string").lower()
if ptype in ("integer", "int"):
return int(value)
if ptype in ("number", "float", "double"):
return float(value)
if ptype in ("bool", "boolean"):
return str(value).lower() in ("1", "true", "yes", "on")
return value
except Exception:
return value
def compute_project_id(project_name: str, project_path: str) -> str:
"""
DEPRECATED: Computes a SHA256-based project ID.
This function is no longer used as of the multi-session fix.
Unity instances now use their native project_hash (SHA1-based) for consistency
across stdio and WebSocket transports.
"""
combined = f"{project_name}:{project_path}"
return sha256(combined.encode("utf-8")).hexdigest().upper()[:16]
def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | None:
if unity_instance is None:
return None
# stdio transport: resolve via discovered instances with name+path
try:
pool = get_unity_connection_pool()
instances = pool.discover_all_instances()
target = None
if "@" in unity_instance:
name_part, _, hash_hint = unity_instance.partition("@")
target = next(
(
inst for inst in instances
if inst.name == name_part and inst.hash.startswith(hash_hint)
),
None,
)
else:
target = next(
(
inst for inst in instances
if inst.id == unity_instance or inst.hash.startswith(unity_instance)
),
None,
)
if target:
# Return the project_hash from Unity (not a computed SHA256 hash).
# This matches the hash Unity uses when registering tools via WebSocket.
if target.hash:
return target.hash
logger.warning(
f"Unity instance {target.id} has empty hash; cannot resolve project ID")
return None
except Exception:
logger.debug(
f"Failed to resolve project id via connection pool for {unity_instance}")
# HTTP/WebSocket transport: resolve via PluginHub using project_hash
try:
hash_part: Optional[str] = None
if "@" in unity_instance:
_, _, suffix = unity_instance.partition("@")
hash_part = suffix or None
else:
hash_part = unity_instance
if hash_part:
lowered = hash_part.lower()
mapped: Optional[str] = None
try:
service = CustomToolService.get_instance()
mapped = service.get_project_id_for_hash(lowered)
except RuntimeError:
mapped = None
if mapped:
return mapped
return lowered
except Exception:
logger.debug(
f"Failed to resolve project id via plugin hub for {unity_instance}")
return None
+28
View File
@@ -0,0 +1,28 @@
"""
Registry package for MCP tool auto-discovery.
"""
from .tool_registry import (
mcp_for_unity_tool,
get_registered_tools,
get_group_tool_names,
clear_tool_registry,
TOOL_GROUPS,
DEFAULT_ENABLED_GROUPS,
)
from .resource_registry import (
mcp_for_unity_resource,
get_registered_resources,
clear_resource_registry,
)
__all__ = [
'mcp_for_unity_tool',
'get_registered_tools',
'get_group_tool_names',
'clear_tool_registry',
'TOOL_GROUPS',
'DEFAULT_ENABLED_GROUPS',
'mcp_for_unity_resource',
'get_registered_resources',
'clear_resource_registry'
]
@@ -0,0 +1,53 @@
"""
Resource registry for auto-discovery of MCP resources.
"""
from typing import Callable, Any
# Global registry to collect decorated resources
_resource_registry: list[dict[str, Any]] = []
def mcp_for_unity_resource(
uri: str,
name: str | None = None,
description: str | None = None,
**kwargs
) -> Callable:
"""
Decorator for registering MCP resources in the server's resources directory.
Resources are registered in the global resource registry.
Args:
name: Resource name (defaults to function name)
description: Resource description
**kwargs: Additional arguments passed to @mcp.resource()
Example:
@mcp_for_unity_resource("mcpforunity://resource", description="Gets something interesting")
async def my_custom_resource(ctx: Context, ...):
pass
"""
def decorator(func: Callable) -> Callable:
resource_name = name if name is not None else func.__name__
_resource_registry.append({
'func': func,
'uri': uri,
'name': resource_name,
'description': description,
'kwargs': kwargs
})
return func
return decorator
def get_registered_resources() -> list[dict[str, Any]]:
"""Get all registered resources"""
return _resource_registry.copy()
def clear_resource_registry():
"""Clear the resource registry (useful for testing)"""
_resource_registry.clear()
@@ -0,0 +1,130 @@
"""
Tool registry for auto-discovery of MCP tools.
Tools can be assigned to *groups* via the ``group`` parameter. Groups map to
FastMCP tags (``"group:<name>"``) which drive the per-session visibility
system exposed through the ``manage_tools`` meta-tool.
The special group value ``None`` means the tool is *always visible* and
cannot be disabled by the group system (used for server meta-tools like
``set_active_instance`` and ``manage_tools``).
"""
from typing import Callable, Any
# Global registry to collect decorated tools
_tool_registry: list[dict[str, Any]] = []
# Valid group names. ``None`` is also accepted (always-visible meta-tools).
TOOL_GROUPS: dict[str, str] = {
"core": "Essential scene, script, asset & editor tools (always on by default)",
"docs": "Unity API reflection and documentation lookup",
"vfx": "Visual effects VFX Graph, shaders, procedural textures",
"animation": "Animator control & AnimationClip creation",
"ui": "UI Toolkit (UXML, USS, UIDocument)",
"scripting_ext": "ScriptableObject management",
"testing": "Test runner & async test jobs",
"probuilder": "ProBuilder 3D modeling requires com.unity.probuilder package",
"profiling": "Unity Profiler session control, counters, memory snapshots & Frame Debugger",
"asset_gen": "AI asset generation 3D model gen/import & 2D image gen (bring-your-own-key)",
}
DEFAULT_ENABLED_GROUPS: set[str] = {"core"}
def mcp_for_unity_tool(
name: str | None = None,
description: str | None = None,
unity_target: str | None = "self",
group: str | None = "core",
**kwargs
) -> Callable:
"""
Decorator for registering MCP tools in the server's tools directory.
Tools are registered in the global tool registry.
Args:
name: Tool name (defaults to function name)
description: Tool description
unity_target: Visibility target used by middleware filtering.
- "self" (default): tool follows its own enabled state.
- None: server-only tool, always visible in tool listing.
- "<tool_name>": alias tool that follows another Unity tool state.
group: Tool group for dynamic visibility.
- A group name string (e.g. "core", "vfx") assigns the tool to
that group and adds a ``tags={"group:<name>"}`` entry.
- None: the tool is *always visible* (server meta-tools).
**kwargs: Additional arguments passed to @mcp.tool()
Example:
@mcp_for_unity_tool(description="Does something cool")
async def my_custom_tool(ctx: Context, ...):
pass
"""
def decorator(func: Callable) -> Callable:
tool_name = name if name is not None else func.__name__
# Safety guard: unity_target is internal metadata and must never leak into mcp.tool kwargs.
tool_kwargs = dict(kwargs) # Create a copy to avoid side effects
if "unity_target" in tool_kwargs:
del tool_kwargs["unity_target"]
if "group" in tool_kwargs:
del tool_kwargs["group"]
# Validate and normalize group
resolved_group: str | None = None
if group is not None:
if group not in TOOL_GROUPS:
raise ValueError(
f"Unknown group '{group}' for tool '{tool_name}'. "
f"Valid groups: {', '.join(sorted(TOOL_GROUPS))}."
)
resolved_group = group
# Merge the group tag into any existing tags the caller provided
existing_tags: set[str] = set(tool_kwargs.get("tags") or set())
existing_tags.add(f"group:{group}")
tool_kwargs["tags"] = existing_tags
if unity_target is None:
normalized_unity_target: str | None = None
elif isinstance(unity_target, str) and unity_target.strip():
normalized_unity_target = (
tool_name if unity_target == "self" else unity_target.strip()
)
else:
raise ValueError(
f"Invalid unity_target for tool '{tool_name}': {unity_target!r}. "
"Expected None or a non-empty string."
)
_tool_registry.append({
'func': func,
'name': tool_name,
'description': description,
'unity_target': normalized_unity_target,
'group': resolved_group,
'kwargs': tool_kwargs,
})
return func
return decorator
def get_registered_tools() -> list[dict[str, Any]]:
"""Get all registered tools"""
return _tool_registry.copy()
def get_group_tool_names() -> dict[str, list[str]]:
"""Return a mapping of group name -> list of tool names in that group."""
result: dict[str, list[str]] = {g: [] for g in TOOL_GROUPS}
for tool in _tool_registry:
g = tool.get("group")
if g and g in result:
result[g].append(tool["name"])
return result
def clear_tool_registry():
"""Clear the tool registry (useful for testing)"""
_tool_registry.clear()
+109
View File
@@ -0,0 +1,109 @@
"""
MCP Resources package - Auto-discovers and registers all resources in this directory.
"""
import functools
import inspect
import logging
from pathlib import Path
from fastmcp import FastMCP
from pydantic import BaseModel
from core.telemetry_decorator import telemetry_resource
from core.logging_decorator import log_execution
from services.registry import get_registered_resources
from utils.module_discovery import discover_modules
logger = logging.getLogger("mcp-for-unity-server")
# Export decorator for easy imports within tools
__all__ = ['register_all_resources']
def _serialize_pydantic(func):
"""Wrap a resource function so Pydantic models are serialized to JSON strings.
FastMCP 3.x expects resource functions to return str, bytes, or ResourceResult.
Our resource functions return MCPResponse (a Pydantic BaseModel). This wrapper
converts them to JSON strings automatically.
"""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
result = await func(*args, **kwargs)
if isinstance(result, BaseModel):
return result.model_dump_json()
if isinstance(result, dict):
import json
return json.dumps(result)
return result
return wrapper
def register_all_resources(mcp: FastMCP, *, project_scoped_tools: bool = True):
"""
Auto-discover and register all resources in the resources/ directory.
Any .py file in this directory or subdirectories with @mcp_for_unity_resource decorated
functions will be automatically registered.
"""
logger.info("Auto-discovering MCP for Unity Server resources...")
# Dynamic import of all modules in this directory
resources_dir = Path(__file__).parent
# Discover and import all modules
list(discover_modules(resources_dir, __package__))
resources = get_registered_resources()
if not resources:
logger.warning("No MCP resources registered!")
return
registered_count = 0
for resource_info in resources:
func = resource_info['func']
uri = resource_info['uri']
resource_name = resource_info['name']
description = resource_info['description']
kwargs = resource_info['kwargs']
if not project_scoped_tools and resource_name == "custom_tools":
logger.info(
"Skipping custom_tools resource registration (project-scoped tools disabled)")
continue
# Check if URI contains query parameters (e.g., {?unity_instance})
has_query_params = '{?' in uri
if has_query_params:
wrapped_template = _serialize_pydantic(func)
wrapped_template = log_execution(resource_name, "Resource")(wrapped_template)
wrapped_template = telemetry_resource(
resource_name)(wrapped_template)
wrapped_template = mcp.resource(
uri=uri,
name=resource_name,
description=description,
**kwargs,
)(wrapped_template)
logger.debug(
f"Registered resource template: {resource_name} - {uri}")
registered_count += 1
resource_info['func'] = wrapped_template
else:
wrapped = _serialize_pydantic(func)
wrapped = log_execution(resource_name, "Resource")(wrapped)
wrapped = telemetry_resource(resource_name)(wrapped)
wrapped = mcp.resource(
uri=uri,
name=resource_name,
description=description,
**kwargs,
)(wrapped)
resource_info['func'] = wrapped
logger.debug(
f"Registered resource: {resource_name} - {description}")
registered_count += 1
logger.info(
f"Registered {registered_count} MCP resources ({len(resources)} unique)")
@@ -0,0 +1,48 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class Vector3(BaseModel):
"""3D vector."""
x: float = 0.0
y: float = 0.0
z: float = 0.0
class ActiveToolData(BaseModel):
"""Active tool data fields."""
activeTool: str = ""
isCustom: bool = False
pivotMode: str = ""
pivotRotation: str = ""
handleRotation: Vector3 = Vector3()
handlePosition: Vector3 = Vector3()
class ActiveToolResponse(MCPResponse):
"""Information about the currently active editor tool."""
data: ActiveToolData = ActiveToolData()
@mcp_for_unity_resource(
uri="mcpforunity://editor/active-tool",
name="editor_active_tool",
description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings.\n\nURI: mcpforunity://editor/active-tool"
)
async def get_active_tool(ctx: Context) -> ActiveToolResponse | MCPResponse:
"""Get active editor tool information."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_active_tool",
{}
)
return parse_resource_response(response, ActiveToolResponse)
+30
View File
@@ -0,0 +1,30 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_resource(
uri="mcpforunity://scene/cameras",
name="cameras",
description=(
"List all cameras in the scene (Unity Camera + CinemachineCamera) with status. "
"Includes Brain state, Cinemachine camera priorities, pipeline components, "
"follow/lookAt targets, and Unity Camera info.\n\n"
"URI: mcpforunity://scene/cameras"
),
)
async def get_cameras(ctx: Context) -> MCPResponse:
"""Get all cameras in the scene."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_cameras",
{},
)
return parse_resource_response(response, MCPResponse)
@@ -0,0 +1,59 @@
from fastmcp import Context
from pydantic import BaseModel
from models import MCPResponse
from services.custom_tool_service import (
CustomToolService,
get_user_id_from_context,
resolve_project_id_for_unity_instance,
ToolDefinitionModel,
)
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
class CustomToolsData(BaseModel):
project_id: str
tool_count: int
tools: list[ToolDefinitionModel]
class CustomToolsResourceResponse(MCPResponse):
data: CustomToolsData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://custom-tools",
name="custom_tools",
description="Lists custom tools available for the active Unity project.\n\nURI: mcpforunity://custom-tools",
)
async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
if not unity_instance:
return MCPResponse(
success=False,
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
)
project_id = resolve_project_id_for_unity_instance(unity_instance)
if project_id is None:
return MCPResponse(
success=False,
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
)
service = CustomToolService.get_instance()
user_id = await get_user_id_from_context(ctx)
tools = await service.list_registered_tools(project_id, user_id=user_id)
data = CustomToolsData(
project_id=project_id,
tool_count=len(tools),
tools=tools,
)
return CustomToolsResourceResponse(
success=True,
message="Custom tools retrieved successfully.",
data=data,
)
@@ -0,0 +1,309 @@
import os
import time
from typing import Any
from fastmcp import Context
from pydantic import BaseModel
from core.config import config
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from services.state.external_changes_scanner import external_changes_scanner
import transport.unity_transport as unity_transport
from transport.legacy.unity_connection import async_send_command_with_retry
from transport.plugin_hub import PluginHub
class EditorStateUnity(BaseModel):
instance_id: str | None = None
unity_version: str | None = None
project_id: str | None = None
platform: str | None = None
is_batch_mode: bool | None = None
class EditorStatePlayMode(BaseModel):
is_playing: bool | None = None
is_paused: bool | None = None
is_changing: bool | None = None
class EditorStateActiveScene(BaseModel):
path: str | None = None
guid: str | None = None
name: str | None = None
class EditorStateEditor(BaseModel):
is_focused: bool | None = None
play_mode: EditorStatePlayMode | None = None
active_scene: EditorStateActiveScene | None = None
class EditorStateActivity(BaseModel):
phase: str | None = None
since_unix_ms: int | None = None
reasons: list[str] | None = None
class EditorStateCompilation(BaseModel):
is_compiling: bool | None = None
is_domain_reload_pending: bool | None = None
last_compile_started_unix_ms: int | None = None
last_compile_finished_unix_ms: int | None = None
last_domain_reload_before_unix_ms: int | None = None
last_domain_reload_after_unix_ms: int | None = None
class EditorStateRefresh(BaseModel):
is_refresh_in_progress: bool | None = None
last_refresh_requested_unix_ms: int | None = None
last_refresh_finished_unix_ms: int | None = None
class EditorStateAssets(BaseModel):
is_updating: bool | None = None
external_changes_dirty: bool | None = None
external_changes_last_seen_unix_ms: int | None = None
external_changes_dirty_since_unix_ms: int | None = None
external_changes_last_cleared_unix_ms: int | None = None
refresh: EditorStateRefresh | None = None
class EditorStateLastRun(BaseModel):
finished_unix_ms: int | None = None
result: str | None = None
counts: Any | None = None
class EditorStateTests(BaseModel):
is_running: bool | None = None
mode: str | None = None
current_job_id: str | None = None
started_unix_ms: int | None = None
started_by: str | None = None
last_run: EditorStateLastRun | None = None
class EditorStateTransport(BaseModel):
unity_bridge_connected: bool | None = None
last_message_unix_ms: int | None = None
class EditorStateSettings(BaseModel):
batch_execute_max_commands: int | None = None
class EditorStateAdvice(BaseModel):
ready_for_tools: bool | None = None
blocking_reasons: list[str] | None = None
recommended_retry_after_ms: int | None = None
recommended_next_action: str | None = None
class EditorStateStaleness(BaseModel):
age_ms: int | None = None
is_stale: bool | None = None
class EditorStateData(BaseModel):
schema_version: str
observed_at_unix_ms: int
sequence: int
unity: EditorStateUnity | None = None
editor: EditorStateEditor | None = None
activity: EditorStateActivity | None = None
compilation: EditorStateCompilation | None = None
assets: EditorStateAssets | None = None
tests: EditorStateTests | None = None
transport: EditorStateTransport | None = None
settings: EditorStateSettings | None = None
advice: EditorStateAdvice | None = None
staleness: EditorStateStaleness | None = None
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Avoid instance-discovery side effects during the Python integration test suite.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
async def infer_single_instance_id(ctx: Context) -> str | None:
"""
Best-effort: if exactly one Unity instance is connected, return its Name@hash id.
This makes editor_state outputs self-describing even when no explicit active instance is set.
"""
await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.")
transport = (config.transport_mode or "stdio").lower()
if transport == "http":
# HTTP/WebSocket transport: derive from PluginHub sessions.
try:
# In remote-hosted mode, filter sessions by user_id
user_id = (await ctx.get_state(
"user_id")) if config.http_remote_hosted else None
sessions_data = await PluginHub.get_sessions(user_id=user_id)
sessions = sessions_data.sessions if hasattr(
sessions_data, "sessions") else {}
if isinstance(sessions, dict) and len(sessions) == 1:
session = next(iter(sessions.values()))
project = getattr(session, "project", None)
project_hash = getattr(session, "hash", None)
if project and project_hash:
return f"{project}@{project_hash}"
except Exception:
return None
return None
# Stdio/TCP transport: derive from connection pool discovery.
try:
from transport.legacy.unity_connection import get_unity_connection_pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
if isinstance(instances, list) and len(instances) == 1:
inst = instances[0]
inst_id = getattr(inst, "id", None)
return str(inst_id) if inst_id else None
except Exception:
return None
return None
def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]:
now_ms = _now_unix_ms()
observed = state_v2.get("observed_at_unix_ms")
try:
observed_ms = int(observed)
except Exception:
observed_ms = now_ms
age_ms = max(0, now_ms - observed_ms)
# Conservative default: treat >2s as stale (covers common unfocused-editor throttling).
is_stale = age_ms > 2000
compilation = state_v2.get("compilation") or {}
tests = state_v2.get("tests") or {}
assets = state_v2.get("assets") or {}
refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {}
blocking: list[str] = []
if compilation.get("is_compiling") is True:
blocking.append("compiling")
if compilation.get("is_domain_reload_pending") is True:
blocking.append("domain_reload")
if tests.get("is_running") is True:
blocking.append("running_tests")
if refresh.get("is_refresh_in_progress") is True:
blocking.append("asset_refresh")
if is_stale:
blocking.append("stale_status")
ready_for_tools = len(blocking) == 0
state_v2["advice"] = {
"ready_for_tools": ready_for_tools,
"blocking_reasons": blocking,
"recommended_retry_after_ms": 0 if ready_for_tools else 500,
"recommended_next_action": "none" if ready_for_tools else "retry_later",
}
state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale}
return state_v2
@mcp_for_unity_resource(
uri="mcpforunity://editor/state",
name="editor_state",
description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.\n\nURI: mcpforunity://editor/state",
)
async def get_editor_state(ctx: Context) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
response = await unity_transport.send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_editor_state",
{},
)
# If Unity returns a structured retry hint or error, surface it directly.
if isinstance(response, dict) and not response.get("success", True):
return MCPResponse(**response)
state_v2 = response.get("data") if isinstance(
response, dict) and isinstance(response.get("data"), dict) else {}
state_v2.setdefault("schema_version", "unity-mcp/editor_state@2")
state_v2.setdefault("observed_at_unix_ms", _now_unix_ms())
state_v2.setdefault("sequence", 0)
# Ensure the returned snapshot is clearly associated with the targeted instance.
unity_section = state_v2.get("unity")
if not isinstance(unity_section, dict):
unity_section = {}
state_v2["unity"] = unity_section
current_instance_id = unity_section.get("instance_id")
if current_instance_id in (None, ""):
if unity_instance:
unity_section["instance_id"] = unity_instance
else:
inferred = await infer_single_instance_id(ctx)
if inferred:
unity_section["instance_id"] = inferred
# External change detection (server-side): compute per instance based on project root path.
try:
instance_id = unity_section.get("instance_id")
if isinstance(instance_id, str) and instance_id.strip():
from services.resources.project_info import get_project_info
proj_resp = await get_project_info(ctx)
proj = proj_resp.model_dump() if hasattr(
proj_resp, "model_dump") else proj_resp
proj_data = proj.get("data") if isinstance(proj, dict) else None
project_root = proj_data.get("projectRoot") if isinstance(
proj_data, dict) else None
if isinstance(project_root, str) and project_root.strip():
external_changes_scanner.set_project_root(
instance_id, project_root)
ext = external_changes_scanner.update_and_get(instance_id)
assets = state_v2.get("assets")
if not isinstance(assets, dict):
assets = {}
state_v2["assets"] = assets
assets["external_changes_dirty"] = bool(
ext.get("external_changes_dirty", False))
assets["external_changes_last_seen_unix_ms"] = ext.get(
"external_changes_last_seen_unix_ms")
assets["external_changes_dirty_since_unix_ms"] = ext.get(
"dirty_since_unix_ms")
assets["external_changes_last_cleared_unix_ms"] = ext.get(
"last_cleared_unix_ms")
except Exception:
pass
state_v2 = _enrich_advice_and_staleness(state_v2)
try:
if hasattr(EditorStateData, "model_validate"):
validated = EditorStateData.model_validate(state_v2)
else:
validated = EditorStateData.parse_obj(
state_v2) # type: ignore[attr-defined]
data = validated.model_dump() if hasattr(
validated, "model_dump") else validated.dict()
except Exception as e:
return MCPResponse(
success=False,
error="invalid_editor_state",
message=f"Editor state payload failed validation: {e}",
data={"raw": state_v2},
)
return MCPResponse(success=True, message="Retrieved editor state.", data=data)
+243
View File
@@ -0,0 +1,243 @@
"""
MCP Resources for reading GameObject data from Unity scenes.
These resources provide read-only access to:
- Single GameObject data (mcpforunity://scene/gameobject/{id})
- All components on a GameObject (mcpforunity://scene/gameobject/{id}/components)
- Single component on a GameObject (mcpforunity://scene/gameobject/{id}/component/{name})
"""
from typing import Any
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
def _normalize_response(response: dict | Any) -> MCPResponse:
"""Normalize Unity transport response to MCPResponse."""
if isinstance(response, dict):
return MCPResponse(**response)
return response
def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | None]:
"""
Validate and convert instance_id string to int.
Returns (id_int, None) on success or (None, error_response) on failure.
"""
try:
return int(instance_id), None
except ValueError:
return None, MCPResponse(success=False, error=f"Invalid instance ID: {instance_id}")
# =============================================================================
# Static Helper Resource (shows in UI)
# =============================================================================
@mcp_for_unity_resource(
uri="mcpforunity://scene/gameobject-api",
name="gameobject_api",
description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below.\n\nURI: mcpforunity://scene/gameobject-api"
)
async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse:
"""
Returns documentation for the GameObject resource API.
This is a helper resource that explains how to use the parameterized
GameObject resources which require an instance ID.
"""
docs = {
"overview": "GameObject resources provide read-only access to Unity scene objects.",
"workflow": [
"1. Use find_gameobjects tool to search for GameObjects and get instance IDs",
"2. Use the instance ID to access detailed data via resources below"
],
"best_practices": [
"⚡ Use batch_execute for multiple operations: Combine create/modify/component calls into one batch_execute call for 10-100x better performance",
"Example: Creating 5 cubes → 1 batch_execute with 5 manage_gameobject commands instead of 5 separate calls",
"Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands"
],
"resources": {
"mcpforunity://scene/gameobject/{instance_id}": {
"description": "Get basic GameObject data (name, tag, layer, transform, component type list)",
"example": "mcpforunity://scene/gameobject/-81840",
"returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"]
},
"mcpforunity://scene/gameobject/{instance_id}/components": {
"description": "Get all components with full property serialization (paginated)",
"example": "mcpforunity://scene/gameobject/-81840/components",
"parameters": {
"page_size": "Number of components per page (default: 25)",
"cursor": "Pagination offset (default: 0)",
"include_properties": "Include full property data (default: true)"
}
},
"mcpforunity://scene/gameobject/{instance_id}/component/{component_name}": {
"description": "Get a single component by type name with full properties",
"example": "mcpforunity://scene/gameobject/-81840/component/Camera",
"note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')"
}
},
"related_tools": {
"find_gameobjects": "Search for GameObjects by name, tag, layer, component, or path",
"manage_components": "Add, remove, or modify components on GameObjects",
"manage_gameobject": "Create, modify, or delete GameObjects"
}
}
return MCPResponse(success=True, data=docs)
class TransformData(BaseModel):
"""Transform component data."""
position: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
localPosition: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
rotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
localRotation: dict[str, float] = {"x": 0.0, "y": 0.0, "z": 0.0}
scale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
lossyScale: dict[str, float] = {"x": 1.0, "y": 1.0, "z": 1.0}
class GameObjectData(BaseModel):
"""Data for a single GameObject (without full component serialization)."""
instanceID: int
name: str
tag: str = "Untagged"
layer: int = 0
layerName: str = "Default"
active: bool = True
activeInHierarchy: bool = True
isStatic: bool = False
transform: TransformData = TransformData()
parent: int | None = None
children: list[int] = []
componentTypes: list[str] = []
path: str = ""
# TODO: Use these typed response classes for better type safety once
# we update the endpoints to validate response structure more strictly.
class GameObjectResponse(MCPResponse):
"""Response containing GameObject data."""
data: GameObjectData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://scene/gameobject/{instance_id}",
name="gameobject",
description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties).\n\nURI: mcpforunity://scene/gameobject/{instance_id}"
)
async def get_gameobject(ctx: Context, instance_id: str) -> MCPResponse:
"""Get GameObject data by instance ID."""
unity_instance = await get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject",
{"instanceID": id_int}
)
return _normalize_response(response)
class ComponentsData(BaseModel):
"""Data for components on a GameObject."""
gameObjectID: int
gameObjectName: str
components: list[Any] = []
cursor: int = 0
pageSize: int = 25
nextCursor: int | None = None
totalCount: int = 0
hasMore: bool = False
includeProperties: bool = True
class ComponentsResponse(MCPResponse):
"""Response containing components data."""
data: ComponentsData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://scene/gameobject/{instance_id}/components",
name="gameobject_components",
description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters.\n\nURI: mcpforunity://scene/gameobject/{instance_id}/components"
)
async def get_gameobject_components(
ctx: Context,
instance_id: str,
page_size: int = 25,
cursor: int = 0,
include_properties: bool = True
) -> MCPResponse:
"""Get all components on a GameObject."""
unity_instance = await get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject_components",
{
"instanceID": id_int,
"pageSize": page_size,
"cursor": cursor,
"includeProperties": include_properties
}
)
return _normalize_response(response)
class SingleComponentData(BaseModel):
"""Data for a single component."""
gameObjectID: int
gameObjectName: str
component: Any = None
class SingleComponentResponse(MCPResponse):
"""Response containing single component data."""
data: SingleComponentData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://scene/gameobject/{instance_id}/component/{component_name}",
name="gameobject_component",
description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties.\n\nURI: mcpforunity://scene/gameobject/{instance_id}/component/{component_name}"
)
async def get_gameobject_component(
ctx: Context,
instance_id: str,
component_name: str
) -> MCPResponse:
"""Get a specific component on a GameObject."""
unity_instance = await get_unity_instance_from_context(ctx)
id_int, error = _validate_instance_id(instance_id)
if error:
return error
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_gameobject_component",
{
"instanceID": id_int,
"componentName": component_name
}
)
return _normalize_response(response)
+30
View File
@@ -0,0 +1,30 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class LayersResponse(MCPResponse):
"""Dictionary of layer indices to layer names."""
data: dict[int, str] = {}
@mcp_for_unity_resource(
uri="mcpforunity://project/layers",
name="project_layers",
description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools.\n\nURI: mcpforunity://project/layers"
)
async def get_layers(ctx: Context) -> LayersResponse | MCPResponse:
"""Get all project layers with their indices."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_layers",
{}
)
return parse_resource_response(response, LayersResponse)
@@ -0,0 +1,35 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class GetMenuItemsResponse(MCPResponse):
data: list[str] = []
@mcp_for_unity_resource(
uri="mcpforunity://menu-items",
name="menu_items",
description="Provides a list of all menu items.\n\nURI: mcpforunity://menu-items"
)
async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse:
"""Provides a list of all menu items.
"""
unity_instance = await get_unity_instance_from_context(ctx)
params = {
"refresh": True,
"search": "",
}
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_menu_items",
params,
)
return parse_resource_response(response, GetMenuItemsResponse)
+192
View File
@@ -0,0 +1,192 @@
"""
MCP Resources for reading Prefab data from Unity.
These resources provide read-only access to:
- Prefab info by asset path (mcpforunity://prefab/{path})
- Prefab hierarchy by asset path (mcpforunity://prefab/{path}/hierarchy)
- Currently open prefab stage (mcpforunity://editor/prefab-stage - see prefab_stage.py)
"""
from typing import Any
from urllib.parse import unquote
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
def _normalize_response(response: dict | MCPResponse | Any) -> MCPResponse:
"""Normalize Unity transport response to MCPResponse."""
if isinstance(response, dict):
return MCPResponse(**response)
if isinstance(response, MCPResponse):
return response
# Fallback: wrap unexpected types in an error response
return MCPResponse(success=False, error=f"Unexpected response type: {type(response).__name__}")
def _decode_prefab_path(encoded_path: str) -> str:
"""
Decode a URL-encoded prefab path.
Handles paths like 'Assets%2FPrefabs%2FMyPrefab.prefab' -> 'Assets/Prefabs/MyPrefab.prefab'
"""
return unquote(encoded_path)
# =============================================================================
# Static Helper Resource (shows in UI)
# =============================================================================
@mcp_for_unity_resource(
uri="mcpforunity://prefab-api",
name="prefab_api",
description="Documentation for Prefab resources. Use manage_asset action=search filterType=Prefab to find prefabs, then access resources below.\n\nURI: mcpforunity://prefab-api"
)
async def get_prefab_api_docs(_ctx: Context) -> MCPResponse:
"""
Returns documentation for the Prefab resource API.
This is a helper resource that explains how to use the parameterized
Prefab resources which require an asset path.
"""
docs = {
"overview": "Prefab resources provide read-only access to Unity prefab assets.",
"workflow": [
"1. Use manage_asset action=search filterType=Prefab to find prefabs",
"2. Use the asset path to access detailed data via resources below",
"3. Use manage_prefabs action=open_prefab_stage / save_prefab_stage / close_prefab_stage for prefab editing UI transitions"
],
"path_encoding": {
"note": "Prefab paths must be URL-encoded when used in resource URIs",
"example": "Assets/Prefabs/MyPrefab.prefab -> Assets%2FPrefabs%2FMyPrefab.prefab"
},
"resources": {
"mcpforunity://prefab/{encoded_path}": {
"description": "Get prefab asset info (type, root name, components, variant info)",
"example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab",
"returns": ["assetPath", "guid", "prefabType", "rootObjectName", "rootComponentTypes", "childCount", "isVariant", "parentPrefab"]
},
"mcpforunity://prefab/{encoded_path}/hierarchy": {
"description": "Get full prefab hierarchy with nested prefab information",
"example": "mcpforunity://prefab/Assets%2FPrefabs%2FPlayer.prefab/hierarchy",
"returns": ["prefabPath", "total", "items (with name, instanceId, path, componentTypes, prefab nesting info)"]
},
"mcpforunity://editor/prefab-stage": {
"description": "Get info about the currently open prefab stage (if any)",
"returns": ["isOpen", "assetPath", "prefabRootName", "mode", "isDirty"]
}
},
"related_tools": {
"manage_editor": "Editor controls (play/pause/stop, active tool, tags/layers, package deploy/restore)",
"manage_prefabs": "Prefab stage lifecycle (open/save/close) and headless prefab inspection/modification",
"manage_asset": "Search for prefab assets, get asset info",
"manage_gameobject": "Modify GameObjects in open prefab stage",
"manage_components": "Add/remove/modify components on prefab GameObjects"
}
}
return MCPResponse(success=True, data=docs)
# =============================================================================
# Prefab Info Resource
# =============================================================================
# TODO: Use these typed response classes for better type safety once
# we update the endpoints to validate response structure more strictly.
class PrefabInfoData(BaseModel):
"""Data for a prefab asset."""
assetPath: str
guid: str = ""
prefabType: str = "Regular"
rootObjectName: str = ""
rootComponentTypes: list[str] = []
childCount: int = 0
isVariant: bool = False
parentPrefab: str | None = None
class PrefabInfoResponse(MCPResponse):
"""Response containing prefab info data."""
data: PrefabInfoData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://prefab/{encoded_path}",
name="prefab_info",
description="Get detailed information about a prefab asset by URL-encoded path. Returns prefab type, root object name, component types, child count, and variant info.\n\nURI: mcpforunity://prefab/{encoded_path}"
)
async def get_prefab_info(ctx: Context, encoded_path: str) -> MCPResponse:
"""Get prefab asset info by path."""
unity_instance = await get_unity_instance_from_context(ctx)
# Decode the URL-encoded path
decoded_path = _decode_prefab_path(encoded_path)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_prefabs",
{
"action": "get_info",
"prefabPath": decoded_path
}
)
return _normalize_response(response)
# =============================================================================
# Prefab Hierarchy Resource
# =============================================================================
class PrefabHierarchyItem(BaseModel):
"""Single item in prefab hierarchy."""
name: str
instanceId: int
path: str
activeSelf: bool = True
childCount: int = 0
componentTypes: list[str] = []
prefab: dict[str, Any] = {}
class PrefabHierarchyData(BaseModel):
"""Data for prefab hierarchy."""
prefabPath: str
total: int = 0
items: list[PrefabHierarchyItem] = []
class PrefabHierarchyResponse(MCPResponse):
"""Response containing prefab hierarchy data."""
data: PrefabHierarchyData | None = None
@mcp_for_unity_resource(
uri="mcpforunity://prefab/{encoded_path}/hierarchy",
name="prefab_hierarchy",
description="Get the full hierarchy of a prefab with nested prefab information. Returns all GameObjects with their components and nesting depth.\n\nURI: mcpforunity://prefab/{encoded_path}/hierarchy"
)
async def get_prefab_hierarchy(ctx: Context, encoded_path: str) -> MCPResponse:
"""Get prefab hierarchy by path."""
unity_instance = await get_unity_instance_from_context(ctx)
# Decode the URL-encoded path
decoded_path = _decode_prefab_path(encoded_path)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_prefabs",
{
"action": "get_hierarchy",
"prefabPath": decoded_path
}
)
return _normalize_response(response)
@@ -0,0 +1,40 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class PrefabStageData(BaseModel):
"""Prefab stage data fields."""
isOpen: bool = False
assetPath: str | None = None
prefabRootName: str | None = None
mode: str | None = None
isDirty: bool = False
class PrefabStageResponse(MCPResponse):
"""Information about the current prefab editing context."""
data: PrefabStageData = PrefabStageData()
@mcp_for_unity_resource(
uri="mcpforunity://editor/prefab-stage",
name="editor_prefab_stage",
description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited.\n\nURI: mcpforunity://editor/prefab-stage"
)
async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse:
"""Get current prefab stage information."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_prefab_stage",
{}
)
return parse_resource_response(response, PrefabStageResponse)
@@ -0,0 +1,40 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class ProjectInfoData(BaseModel):
"""Project info data fields."""
projectRoot: str = ""
projectName: str = ""
unityVersion: str = ""
platform: str = ""
assetsPath: str = ""
class ProjectInfoResponse(MCPResponse):
"""Static project configuration information."""
data: ProjectInfoData = ProjectInfoData()
@mcp_for_unity_resource(
uri="mcpforunity://project/info",
name="project_info",
description="Static project information including root path, Unity version, and platform. This data rarely changes.\n\nURI: mcpforunity://project/info"
)
async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse:
"""Get static project configuration information."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_project_info",
{}
)
return parse_resource_response(response, ProjectInfoResponse)
@@ -0,0 +1,21 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_resource(
uri="mcpforunity://pipeline/renderer-features",
name="renderer_features",
description="Lists all URP renderer features on the active renderer with type, name, and active state.",
)
async def get_renderer_features(ctx: Context) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry, unity_instance, "get_renderer_features", {}
)
return parse_resource_response(response, MCPResponse)
@@ -0,0 +1,21 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_resource(
uri="mcpforunity://rendering/stats",
name="rendering_stats",
description="Snapshot of rendering performance statistics (draw calls, batches, triangles, frame time, etc.).",
)
async def get_rendering_stats(ctx: Context) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry, unity_instance, "get_rendering_stats", {}
)
return parse_resource_response(response, MCPResponse)
@@ -0,0 +1,56 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class SelectionObjectInfo(BaseModel):
"""Information about a selected object."""
name: str | None = None
type: str | None = None
instanceID: int | None = None
class SelectionGameObjectInfo(BaseModel):
"""Information about a selected GameObject."""
name: str | None = None
instanceID: int | None = None
class SelectionData(BaseModel):
"""Selection data fields."""
activeObject: str | None = None
activeGameObject: str | None = None
activeTransform: str | None = None
activeInstanceID: int = 0
count: int = 0
objects: list[SelectionObjectInfo] = []
gameObjects: list[SelectionGameObjectInfo] = []
assetGUIDs: list[str] = []
class SelectionResponse(MCPResponse):
"""Detailed information about the current editor selection."""
data: SelectionData = SelectionData()
@mcp_for_unity_resource(
uri="mcpforunity://editor/selection",
name="editor_selection",
description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties.\n\nURI: mcpforunity://editor/selection"
)
async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse:
"""Get detailed editor selection information."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_selection",
{}
)
return parse_resource_response(response, SelectionResponse)
+31
View File
@@ -0,0 +1,31 @@
from pydantic import Field
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class TagsResponse(MCPResponse):
"""List of all tags in the project."""
data: list[str] = Field(default_factory=list)
@mcp_for_unity_resource(
uri="mcpforunity://project/tags",
name="project_tags",
description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools.\n\nURI: mcpforunity://project/tags"
)
async def get_tags(ctx: Context) -> TagsResponse | MCPResponse:
"""Get all project tags."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tags",
{}
)
return parse_resource_response(response, TagsResponse)
+88
View File
@@ -0,0 +1,88 @@
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class TestItem(BaseModel):
name: Annotated[str, Field(description="The name of the test.")]
full_name: Annotated[str, Field(description="The full name of the test.")]
mode: Annotated[Literal["EditMode", "PlayMode"],
Field(description="The mode the test is for.")]
class PaginatedTestsData(BaseModel):
"""Paginated test results."""
items: list[TestItem] = Field(description="Tests on current page")
cursor: int = Field(description="Current page cursor (0-based)")
nextCursor: Optional[int] = Field(None, description="Next page cursor, null if last page")
totalCount: int = Field(description="Total number of tests across all pages")
pageSize: int = Field(description="Number of items per page")
hasMore: bool = Field(description="Whether there are more items after this page")
class GetTestsResponse(MCPResponse):
"""Response containing paginated test data."""
data: PaginatedTestsData = Field(description="Paginated test data")
@mcp_for_unity_resource(
uri="mcpforunity://tests",
name="get_tests",
description="Provides the first page of Unity tests (default 50 items). "
"For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests"
)
async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
"""Provides a paginated list of all Unity tests.
Returns the first page of tests using Unity's default pagination (50 items).
For advanced filtering or pagination control, use the run_tests tool which
accepts mode, filter, page_size, and cursor parameters.
"""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests",
{},
)
return parse_resource_response(response, GetTestsResponse)
@mcp_for_unity_resource(
uri="mcpforunity://tests/{mode}",
name="get_tests_for_mode",
description="Provides the first page of tests for a specific mode (EditMode or PlayMode). "
"For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests/{mode}"
)
async def get_tests_for_mode(
ctx: Context,
mode: Annotated[Literal["EditMode", "PlayMode"], Field(
description="The mode to filter tests by (EditMode or PlayMode)."
)],
) -> GetTestsResponse | MCPResponse:
"""Provides the first page of tests for a specific mode.
Args:
mode: The test mode to filter by (EditMode or PlayMode)
Returns the first page of tests using Unity's default pagination (50 items).
For advanced filtering or pagination control, use the run_tests tool.
"""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_tests_for_mode",
{"mode": mode},
)
return parse_resource_response(response, GetTestsResponse)
@@ -0,0 +1,44 @@
"""
tool_groups resource exposes available tool groups and their metadata.
URI: mcpforunity://tool-groups
"""
from typing import Any
from fastmcp import Context
from services.registry import (
mcp_for_unity_resource,
TOOL_GROUPS,
DEFAULT_ENABLED_GROUPS,
get_group_tool_names,
)
@mcp_for_unity_resource(
uri="mcpforunity://tool-groups",
name="tool_groups",
description=(
"Available tool groups and their tools. "
"Use manage_tools to activate/deactivate groups per session.\n\n"
"URI: mcpforunity://tool-groups"
),
)
async def get_tool_groups(ctx: Context) -> dict[str, Any]:
group_tools = get_group_tool_names()
groups = []
for name in sorted(TOOL_GROUPS.keys()):
tools = group_tools.get(name, [])
groups.append({
"name": name,
"description": TOOL_GROUPS[name],
"default_enabled": name in DEFAULT_ENABLED_GROUPS,
"tools": tools,
"tool_count": len(tools),
})
return {
"groups": groups,
"total_groups": len(groups),
"default_enabled": sorted(DEFAULT_ENABLED_GROUPS),
"usage": "Call manage_tools(action='activate', group='<name>') to enable a group.",
}
@@ -0,0 +1,125 @@
"""
Resource to list all available Unity Editor instances.
"""
from typing import Any
from fastmcp import Context
from services.registry import mcp_for_unity_resource
from transport.legacy.unity_connection import get_unity_connection_pool
from transport.plugin_hub import PluginHub
from core.config import config
@mcp_for_unity_resource(
uri="mcpforunity://instances",
name="unity_instances",
description="Lists all running Unity Editor instances with their details.\n\nURI: mcpforunity://instances"
)
async def unity_instances(ctx: Context) -> dict[str, Any]:
"""
List all available Unity Editor instances.
Returns information about each instance including:
- id: Unique identifier (ProjectName@hash)
- name: Project name
- path: Full project path (stdio only)
- hash: 8-character hash of project path
- port: TCP port number (stdio only)
- status: Current status (running, reloading, etc.) (stdio only)
- last_heartbeat: Last heartbeat timestamp (stdio only)
- unity_version: Unity version (if available)
- connected_at: Connection timestamp (HTTP only)
Returns:
Dictionary containing list of instances and metadata
"""
await ctx.info("Listing Unity instances")
try:
transport = (config.transport_mode or "stdio").lower()
if transport == "http":
# HTTP/WebSocket transport: query PluginHub
# In remote-hosted mode, filter sessions by user_id
user_id = (await ctx.get_state(
"user_id")) if config.http_remote_hosted else None
sessions_data = await PluginHub.get_sessions(user_id=user_id)
sessions = sessions_data.sessions
instances = []
for session_id, session_info in sessions.items():
project = session_info.project
project_hash = session_info.hash
if not project or not project_hash:
raise ValueError(
"PluginHub session missing required 'project' or 'hash' fields."
)
instances.append({
"id": f"{project}@{project_hash}",
"name": project,
"hash": project_hash,
"unity_version": session_info.unity_version,
"connected_at": session_info.connected_at,
"session_id": session_id,
})
# Check for duplicate project names
name_counts = {}
for inst in instances:
name_counts[inst["name"]] = name_counts.get(
inst["name"], 0) + 1
duplicates = [name for name,
count in name_counts.items() if count > 1]
result = {
"success": True,
"transport": transport,
"instance_count": len(instances),
"instances": instances,
}
if duplicates:
result["warning"] = (
f"Multiple instances found with duplicate project names: {duplicates}. "
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
)
return result
else:
# Stdio/TCP transport: query connection pool
pool = get_unity_connection_pool()
instances = pool.discover_all_instances(force_refresh=False)
# Check for duplicate project names
name_counts = {}
for inst in instances:
name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
duplicates = [name for name,
count in name_counts.items() if count > 1]
result = {
"success": True,
"transport": transport,
"instance_count": len(instances),
"instances": [inst.to_dict() for inst in instances],
}
if duplicates:
result["warning"] = (
f"Multiple instances found with duplicate project names: {duplicates}. "
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
)
return result
except Exception as e:
await ctx.error(f"Error listing Unity instances: {e}")
return {
"success": False,
"error": f"Failed to list Unity instances: {str(e)}",
"instance_count": 0,
"instances": []
}
+20
View File
@@ -0,0 +1,20 @@
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_resource(
uri="mcpforunity://scene/volumes",
name="volumes",
description="Lists all Volume components in the active scene with their profiles, effects, and settings.",
)
async def get_volumes(ctx: Context) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry, unity_instance, "get_volumes", {}
)
return parse_resource_response(response, MCPResponse)
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel
from fastmcp import Context
from models import MCPResponse
from models.unity_response import parse_resource_response
from services.registry import mcp_for_unity_resource
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
class WindowPosition(BaseModel):
"""Window position and size."""
x: float = 0.0
y: float = 0.0
width: float = 0.0
height: float = 0.0
class WindowInfo(BaseModel):
"""Information about an editor window."""
title: str = ""
typeName: str = ""
isFocused: bool = False
position: WindowPosition = WindowPosition()
instanceID: int = 0
class WindowsResponse(MCPResponse):
"""List of all open editor windows."""
data: list[WindowInfo] = []
@mcp_for_unity_resource(
uri="mcpforunity://editor/windows",
name="editor_windows",
description="All currently open editor windows with their titles, types, positions, and focus state.\n\nURI: mcpforunity://editor/windows"
)
async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
"""Get all open editor windows."""
unity_instance = await get_unity_instance_from_context(ctx)
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"get_windows",
{}
)
return parse_resource_response(response, WindowsResponse)
@@ -0,0 +1,245 @@
from __future__ import annotations
import os
import json
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
def _now_unix_ms() -> int:
return int(time.time() * 1000)
def _in_pytest() -> bool:
# Keep scanner inert during the Python integration suite unless explicitly invoked.
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
@dataclass
class ExternalChangesState:
project_root: str | None = None
last_scan_unix_ms: int | None = None
last_seen_mtime_ns: int | None = None
dirty: bool = False
dirty_since_unix_ms: int | None = None
external_changes_last_seen_unix_ms: int | None = None
last_cleared_unix_ms: int | None = None
# Cached package roots referenced by Packages/manifest.json "file:" dependencies
extra_roots: list[str] | None = None
manifest_last_mtime_ns: int | None = None
class ExternalChangesScanner:
"""
Lightweight external-changes detector using recursive max-mtime scan.
This is intentionally conservative:
- It only marks dirty when it sees a strictly newer mtime than the baseline.
- It scans at most once per scan_interval_ms per instance to keep overhead bounded.
"""
def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
self._states: dict[str, ExternalChangesState] = {}
self._scan_interval_ms = int(scan_interval_ms)
self._max_entries = int(max_entries)
def _get_state(self, instance_id: str) -> ExternalChangesState:
return self._states.setdefault(instance_id, ExternalChangesState())
def set_project_root(self, instance_id: str, project_root: str | None) -> None:
st = self._get_state(instance_id)
if project_root:
st.project_root = project_root
def clear_dirty(self, instance_id: str) -> None:
st = self._get_state(instance_id)
st.dirty = False
st.dirty_since_unix_ms = None
st.last_cleared_unix_ms = _now_unix_ms()
# Reset baseline to “now” on next scan.
st.last_seen_mtime_ns = None
def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
newest: int | None = None
entries = 0
for root in roots:
if not root.exists():
continue
# Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
for dirpath, dirnames, filenames in os.walk(str(root)):
entries += 1
if entries > self._max_entries:
return newest
dp = Path(dirpath)
name = dp.name.lower()
if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
dirnames[:] = []
continue
# Allow skipping hidden directories quickly
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for fn in filenames:
if fn.startswith("."):
continue
entries += 1
if entries > self._max_entries:
return newest
p = dp / fn
try:
stat = p.stat()
except OSError:
continue
m = getattr(stat, "st_mtime_ns", None)
if m is None:
# Fallback when st_mtime_ns is unavailable
m = int(stat.st_mtime * 1_000_000_000)
newest = m if newest is None else max(newest, int(m))
return newest
def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
"""
Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
Returns a list of Paths that exist and are directories.
"""
manifest_path = project_root / "Packages" / "manifest.json"
try:
stat = manifest_path.stat()
except OSError:
st.extra_roots = []
st.manifest_last_mtime_ns = None
return []
mtime_ns = getattr(stat, "st_mtime_ns", int(
stat.st_mtime * 1_000_000_000))
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
return [Path(p) for p in st.extra_roots if p]
try:
raw = manifest_path.read_text(encoding="utf-8")
doc = json.loads(raw)
except Exception:
st.extra_roots = []
st.manifest_last_mtime_ns = mtime_ns
return []
deps = doc.get("dependencies") if isinstance(doc, dict) else None
if not isinstance(deps, dict):
st.extra_roots = []
st.manifest_last_mtime_ns = mtime_ns
return []
roots: list[str] = []
base_dir = manifest_path.parent
for _, ver in deps.items():
if not isinstance(ver, str):
continue
v = ver.strip()
if not v.startswith("file:"):
continue
suffix = v[len("file:"):].strip()
# Handle file:///abs/path or file:/abs/path
if suffix.startswith("///"):
candidate = Path("/" + suffix.lstrip("/"))
elif suffix.startswith("/"):
candidate = Path(suffix)
else:
candidate = (base_dir / suffix).resolve()
try:
if candidate.exists() and candidate.is_dir():
roots.append(str(candidate))
except OSError:
continue
# De-dupe, preserve order
deduped: list[str] = []
seen = set()
for r in roots:
if r not in seen:
seen.add(r)
deduped.append(r)
st.extra_roots = deduped
st.manifest_last_mtime_ns = mtime_ns
return [Path(p) for p in deduped if p]
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
"""
Returns a small dict suitable for embedding in editor_state_v2.assets:
- external_changes_dirty
- external_changes_last_seen_unix_ms
- dirty_since_unix_ms
- last_cleared_unix_ms
"""
st = self._get_state(instance_id)
if _in_pytest():
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
now = _now_unix_ms()
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
st.last_scan_unix_ms = now
project_root = st.project_root
if not project_root:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
root = Path(project_root)
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
# Include any local package roots referenced by file: deps in Packages/manifest.json
try:
paths.extend(self._resolve_manifest_extra_roots(root, st))
except Exception:
pass
newest = self._scan_paths_max_mtime_ns(paths)
if newest is None:
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
if st.last_seen_mtime_ns is None:
st.last_seen_mtime_ns = newest
elif newest > st.last_seen_mtime_ns:
st.last_seen_mtime_ns = newest
st.external_changes_last_seen_unix_ms = now
if not st.dirty:
st.dirty = True
st.dirty_since_unix_ms = now
return {
"external_changes_dirty": st.dirty,
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
"dirty_since_unix_ms": st.dirty_since_unix_ms,
"last_cleared_unix_ms": st.last_cleared_unix_ms,
}
# Global singleton (simple, process-local)
external_changes_scanner = ExternalChangesScanner()
+286
View File
@@ -0,0 +1,286 @@
"""MCP tools package - auto-discovery and Unity routing helpers."""
import logging
import os
from pathlib import Path
from typing import TypeVar
from fastmcp import Context, FastMCP
from core.telemetry_decorator import telemetry_tool
from core.logging_decorator import log_execution
from utils.module_discovery import discover_modules
from services.registry import get_registered_tools, TOOL_GROUPS, DEFAULT_ENABLED_GROUPS
logger = logging.getLogger("mcp-for-unity-server")
# Export decorator and helpers for easy imports within tools
__all__ = [
"register_all_tools",
"sync_tool_visibility_from_unity",
"get_unity_instance_from_context",
]
def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
"""
Auto-discover and register all tools in the tools/ directory.
Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
functions will be automatically registered.
After registration, non-default tool groups are disabled at the server level
so that new sessions only see the *core* tools (plus always-visible meta-tools).
Clients can activate additional groups at any time via ``manage_tools``.
"""
logger.info("Auto-discovering MCP for Unity Server tools...")
# Dynamic import of all modules in this directory
tools_dir = Path(__file__).parent
# Discover and import all modules
list(discover_modules(tools_dir, __package__))
tools = get_registered_tools()
if not tools:
logger.warning("No MCP tools registered!")
return
for tool_info in tools:
func = tool_info['func']
tool_name = tool_info['name']
description = tool_info['description']
kwargs = tool_info['kwargs']
if not project_scoped_tools and tool_name == "execute_custom_tool":
logger.info(
"Skipping execute_custom_tool registration (project-scoped tools disabled)")
continue
# Apply decorators: logging -> telemetry -> mcp.tool
# Note: Parameter normalization (camelCase -> snake_case) is handled by
# ParamNormalizerMiddleware before FastMCP validation
wrapped = log_execution(tool_name, "Tool")(func)
wrapped = telemetry_tool(tool_name)(wrapped)
wrapped = mcp.tool(
name=tool_name, description=description, **kwargs)(wrapped)
tool_info['func'] = wrapped
logger.debug(f"Registered tool: {tool_name} - {description}")
logger.info(f"Registered {len(tools)} MCP tools")
# In HTTP mode, disable non-default groups at the server level so new
# sessions start lean. Unity will re-enable groups via register_tools
# (PluginHub._sync_server_tool_visibility) once it connects.
# In stdio mode we skip this: the legacy TCP bridge has no register_tools
# message, so disabled groups would stay invisible for the entire session.
# Tools with group=None (no tag) are unaffected and always visible.
from core.config import config as server_config
if (server_config.transport_mode or "stdio").lower() == "http":
groups_to_disable = set(TOOL_GROUPS.keys()) - DEFAULT_ENABLED_GROUPS
for group_name in sorted(groups_to_disable):
tag = f"group:{group_name}"
mcp.disable(tags={tag}, components={"tool"})
logger.debug(f"Disabled tool group at startup: {group_name}")
logger.info(
f"Default tool groups: {', '.join(sorted(DEFAULT_ENABLED_GROUPS))}. "
f"Disabled: {', '.join(sorted(groups_to_disable))}. "
"Use manage_tools to activate more."
)
else:
logger.info(
"Stdio transport: all tool groups enabled at startup. "
"Will sync with Unity's tool states after connecting."
)
async def sync_tool_visibility_from_unity(
instance_id: str | None = None,
notify: bool = True,
) -> dict:
"""Query Unity for tool enabled/disabled states and sync server-level visibility.
This bridges the gap in stdio mode where Unity can't push ``register_tools``
messages. The Python server queries Unity's ``get_tool_states`` resource via
the legacy TCP connection and feeds the result into
``PluginHub._sync_server_tool_visibility``.
Args:
instance_id: Optional Unity instance identifier.
notify: If True, send ``tools/list_changed`` to connected MCP sessions.
Returns:
dict with sync results (enabled/disabled groups, tool count).
"""
from transport.legacy.unity_connection import async_send_command_with_retry
from transport.plugin_hub import PluginHub
try:
response = await async_send_command_with_retry(
"get_tool_states", {}, instance_id=instance_id,
)
# Detect unsupported command (Unity package too old)
if isinstance(response, dict):
error_msg = response.get("error") or response.get("message") or ""
if isinstance(error_msg, str) and (
"unknown" in error_msg.lower()
or "unsupported command" in error_msg.lower()
):
logger.debug(
"Unity does not support get_tool_states yet — "
"update the MCPForUnity package to enable tool toggle sync"
)
return {
"error": "Unity package does not support get_tool_states. "
"Update MCPForUnity to the latest version to enable "
"tool toggle syncing from the Unity Editor GUI.",
"unsupported": True,
}
# Extract tool list from response
tools = None
if isinstance(response, dict):
# SuccessResponse wraps data in "data" key
data = response.get("data")
if isinstance(data, dict):
tools = data.get("tools")
elif isinstance(data, list):
tools = data
# Fallback: maybe tools directly in response
if tools is None:
tools = response.get("tools")
if not tools or not isinstance(tools, list):
logger.debug(
"sync_tool_visibility_from_unity: no tool data in Unity response: %s",
response,
)
return {"error": "No tool data returned from Unity"}
# Filter to enabled tools only — _sync_server_tool_visibility treats
# the list as "registered" (i.e. enabled) tools.
enabled_tools = [t for t in tools if t.get("enabled", True)]
logger.info(
"Syncing tool visibility from Unity: %d/%d tools enabled",
len(enabled_tools), len(tools),
)
PluginHub._sync_server_tool_visibility(enabled_tools)
# Register custom (non-built-in) tools via CustomToolService.
# The extended get_tool_states response includes is_built_in,
# description, parameters, etc. If those fields are missing
# (older Unity package), we skip custom tool registration.
custom_tool_count = 0
has_extended_metadata = any(
"is_built_in" in t for t in enabled_tools
)
if has_extended_metadata:
custom_tool_dicts = [
t for t in enabled_tools if not t.get("is_built_in", True)
]
if custom_tool_dicts:
try:
from models.models import ToolDefinitionModel, ToolParameterModel
from services.custom_tool_service import CustomToolService
custom_tool_models = []
for td in custom_tool_dicts:
params = [
ToolParameterModel(
name=p.get("name", ""),
description=p.get("description", ""),
type=p.get("type", "string"),
required=p.get("required", True),
default_value=p.get("default_value"),
)
for p in td.get("parameters", [])
]
custom_tool_models.append(
ToolDefinitionModel(
name=td["name"],
description=td.get("description", ""),
structured_output=td.get("structured_output", True),
requires_polling=td.get("requires_polling", False),
poll_action=td.get("poll_action") or "status",
max_poll_seconds=td.get("max_poll_seconds", 0),
parameters=params,
)
)
service = CustomToolService.get_instance()
service.register_global_tools(custom_tool_models)
custom_tool_count = len(custom_tool_models)
logger.info(
"Registered %d custom tool(s) from Unity via stdio sync",
custom_tool_count,
)
except RuntimeError as exc:
logger.debug(
"Skipping custom tool registration: "
"CustomToolService not initialized yet (%s)",
exc,
)
except Exception as exc:
logger.warning(
"Failed to register custom tools from Unity: %s",
exc,
)
else:
logger.debug(
"Unity response does not include extended tool metadata "
"(is_built_in); skipping custom tool registration. "
"Update MCPForUnity to enable custom tool sync in stdio mode."
)
if notify:
await PluginHub._notify_mcp_tool_list_changed()
# Build summary
from services.registry import get_group_tool_names
group_tools = get_group_tool_names()
enabled_names = {t.get("name") for t in enabled_tools if t.get("name")}
enabled_groups = []
disabled_groups = []
for group_name in sorted(TOOL_GROUPS.keys()):
tool_names = group_tools.get(group_name, [])
if any(n in enabled_names for n in tool_names):
enabled_groups.append(group_name)
else:
disabled_groups.append(group_name)
return {
"synced": True,
"enabled_groups": enabled_groups,
"disabled_groups": disabled_groups,
"enabled_tool_count": len(enabled_tools),
"total_tool_count": len(tools),
"custom_tool_count": custom_tool_count,
}
except Exception as exc:
logger.warning(
"Failed to sync tool visibility from Unity: %s", exc,
)
return {"error": str(exc)}
async def get_unity_instance_from_context(
ctx: Context,
key: str = "unity_instance",
) -> str | None:
"""Extract the unity_instance value from middleware state.
The instance is set via the set_active_instance tool and injected into
request state by UnityInstanceMiddleware.
"""
get_state_fn = getattr(ctx, "get_state", None)
if callable(get_state_fn):
try:
return await get_state_fn(key)
except Exception: # pragma: no cover - defensive
pass
return None
+147
View File
@@ -0,0 +1,147 @@
"""Defines the batch_execute tool for orchestrating multiple Unity MCP commands."""
from __future__ import annotations
import logging
from typing import Annotated, Any
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
logger = logging.getLogger(__name__)
# Fallback used when the Unity-side configured limit is not yet known.
DEFAULT_MAX_COMMANDS_PER_BATCH = 25
# Hard ceiling matching the C# AbsoluteMaxCommandsPerBatch.
ABSOLUTE_MAX_COMMANDS_PER_BATCH = 100
# Module-level cache for the Unity-configured limit (populated from editor state).
_cached_max_commands: int | None = None
async def _get_max_commands_from_editor_state(ctx: Context) -> int:
"""
Attempt to read the configured batch limit from the Unity editor state.
Falls back to DEFAULT_MAX_COMMANDS_PER_BATCH if unavailable.
"""
global _cached_max_commands
if _cached_max_commands is not None:
return _cached_max_commands
try:
from services.resources.editor_state import get_editor_state
state_resp = await get_editor_state(ctx)
data = state_resp.data if hasattr(state_resp, "data") else (
state_resp.get("data") if isinstance(state_resp, dict) else None
)
if isinstance(data, dict):
settings = data.get("settings")
if isinstance(settings, dict):
limit = settings.get("batch_execute_max_commands")
if isinstance(limit, int) and 1 <= limit <= ABSOLUTE_MAX_COMMANDS_PER_BATCH:
_cached_max_commands = limit
return limit
except Exception as exc:
logger.debug("Could not read batch limit from editor state: %s", exc)
return DEFAULT_MAX_COMMANDS_PER_BATCH
def invalidate_cached_max_commands() -> None:
"""Reset the cached limit so the next call re-reads from editor state."""
global _cached_max_commands
_cached_max_commands = None
@mcp_for_unity_tool(
name="batch_execute",
description=(
"Executes multiple MCP commands in a single batch for dramatically better performance. "
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
"or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
"sequential tool calls. The max commands per batch is configurable in the Unity MCP Tools window "
f"(default {DEFAULT_MAX_COMMANDS_PER_BATCH}, hard max {ABSOLUTE_MAX_COMMANDS_PER_BATCH}). "
"Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
),
annotations=ToolAnnotations(
title="Batch Execute",
destructiveHint=True,
),
)
async def batch_execute(
ctx: Context,
commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."],
parallel: Annotated[bool | None,
"Attempt to run read-only commands in parallel"] = None,
fail_fast: Annotated[bool | None,
"Stop processing after the first failure"] = None,
max_parallelism: Annotated[int | None,
"Hint for the maximum number of parallel workers"] = None,
) -> dict[str, Any]:
"""Proxy the batch_execute tool to the Unity Editor transporter."""
unity_instance = await get_unity_instance_from_context(ctx)
if not isinstance(commands, list) or not commands:
raise ValueError(
"'commands' must be a non-empty list of command specifications")
max_commands = await _get_max_commands_from_editor_state(ctx)
if len(commands) > max_commands:
raise ValueError(
f"batch_execute supports up to {max_commands} commands (configured in Unity); received {len(commands)}"
)
normalized_commands: list[dict[str, Any]] = []
for index, command in enumerate(commands):
if not isinstance(command, dict):
raise ValueError(
f"Command at index {index} must be an object with 'tool' and 'params' keys")
tool_name = command.get("tool")
params = command.get("params", {})
if not tool_name or not isinstance(tool_name, str):
raise ValueError(
f"Command at index {index} is missing a valid 'tool' name")
if params is None:
params = {}
if not isinstance(params, dict):
raise ValueError(
f"Command '{tool_name}' must specify parameters as an object/dict")
if "unity_instance" in params:
raise ValueError(
f"Command '{tool_name}' at index {index} contains 'unity_instance'. "
"Per-command instance routing is not supported inside batch_execute. "
"Set unity_instance on the outer batch_execute call to route the entire batch."
)
normalized_commands.append({
"tool": tool_name,
"params": params,
})
payload: dict[str, Any] = {
"commands": normalized_commands,
}
if parallel is not None:
payload["parallel"] = bool(parallel)
if fail_fast is not None:
payload["failFast"] = bool(fail_fast)
if max_parallelism is not None:
payload["maxParallelism"] = int(max_parallelism)
return await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"batch_execute",
payload,
)
@@ -0,0 +1,83 @@
from typing import Any
import os
import sys
from core.telemetry import get_package_version
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from transport.unity_instance_middleware import get_unity_instance_middleware
from transport.plugin_hub import PluginHub
@mcp_for_unity_tool(
unity_target=None,
group=None,
description="Return the current FastMCP request context details (client_id, session_id, and meta dump).",
annotations=ToolAnnotations(
title="Debug Request Context",
readOnlyHint=True,
),
)
async def debug_request_context(ctx: Context) -> dict[str, Any]:
# Check request_context properties
rc = getattr(ctx, "request_context", None)
rc_client_id = getattr(rc, "client_id", None)
rc_session_id = getattr(rc, "session_id", None)
meta = getattr(rc, "meta", None)
# Check direct ctx properties (per latest FastMCP docs)
ctx_session_id = getattr(ctx, "session_id", None)
ctx_client_id = getattr(ctx, "client_id", None)
meta_dump = None
if meta is not None:
try:
dump_fn = getattr(meta, "model_dump", None)
if callable(dump_fn):
meta_dump = dump_fn(exclude_none=False)
elif isinstance(meta, dict):
meta_dump = dict(meta)
except Exception as e:
meta_dump = {"_error": str(e)}
# List all ctx attributes for debugging
ctx_attrs = [attr for attr in dir(ctx) if not attr.startswith("_")]
# Get session state info via middleware. Active-instance storage now lives
# in FastMCP's session-scoped state store, keyed by ctx.session_id, so
# there is no global dict to enumerate — that snapshot was a footgun
# anyway (it exposed every connected client's selection).
middleware = get_unity_instance_middleware()
active_instance = await middleware.get_active_instance(ctx)
# Debugging PluginHub state
plugin_hub_configured = PluginHub.is_configured()
return {
"success": True,
"data": {
"server": {
"version": get_package_version(),
"cwd": os.getcwd(),
"argv": list(sys.argv),
},
"request_context": {
"client_id": rc_client_id,
"session_id": rc_session_id,
"meta": meta_dump,
},
"direct_properties": {
"session_id": ctx_session_id,
"client_id": ctx_client_id,
},
"session_state": {
"active_instance": active_instance,
"plugin_hub_configured": plugin_hub_configured,
"middleware_id": id(middleware),
},
"available_attributes": ctx_attrs,
},
}
+101
View File
@@ -0,0 +1,101 @@
"""
Execute arbitrary C# code inside the Unity Editor.
Supports execute, history, replay, and clear actions with basic blocked-pattern
checks. Code is compiled in-memory via CSharpCodeProvider — no script files created.
WARNING: This tool runs arbitrary code in the Unity Editor process.
Safety checks block known dangerous patterns but are NOT a security sandbox.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
description=(
"Execute arbitrary C# code inside the Unity Editor. "
"The code runs as a method body with access to UnityEngine and UnityEditor namespaces. "
"Use 'return' to send data back. Compiled in-memory — no script files created. "
"Actions: execute (run code), get_history (list past executions), "
"replay (re-run a history entry), clear_history. "
"NOTE: safety_checks blocks known dangerous patterns but is not a full sandbox. "
"Compiler options: 'auto' (Roslyn if available, else CodeDom), 'roslyn' (C# 12+, requires Microsoft.CodeAnalysis), 'codedom' (C# 6 only)."
),
group="scripting_ext",
annotations=ToolAnnotations(
title="Execute Code",
destructiveHint=True,
),
)
async def execute_code(
ctx: Context,
action: Annotated[
Literal["execute", "get_history", "replay", "clear_history"],
"Action to perform.",
],
code: Annotated[
str,
"C# code to execute (for 'execute' action). Must be a valid method body. "
"Access UnityEngine and UnityEditor namespaces. Use 'return' to send data back.",
] | None = None,
safety_checks: Annotated[
bool,
"Enable basic blocked-pattern checks (File.Delete, Process.Start, infinite loops, etc). "
"Not a full sandbox — advanced bypass is possible. Default: true.",
] = True,
index: Annotated[
int,
"History entry index to replay (for 'replay' action).",
] | None = None,
limit: Annotated[
int,
"Number of history entries to return (for 'get_history' action, 1-50). Default: 10.",
] = 10,
compiler: Annotated[
Literal["auto", "roslyn", "codedom"],
"Compiler backend for 'execute' action. "
"'auto' uses Roslyn if Microsoft.CodeAnalysis is installed, else falls back to CodeDom. "
"'roslyn' forces Roslyn (C# 12+). 'codedom' forces legacy CSharpCodeProvider (C# 6). Default: auto.",
] = "auto",
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict: dict[str, Any] = {"action": action}
if action == "execute":
if code is None:
return {"success": False, "message": "Parameter 'code' is required for 'execute' action."}
params_dict["code"] = code
params_dict["safety_checks"] = safety_checks
params_dict["compiler"] = compiler
elif action == "replay":
if index is None:
return {"success": False, "message": "Parameter 'index' is required for 'replay' action."}
params_dict["index"] = index
elif action == "get_history":
params_dict["limit"] = max(1, min(limit, 50))
params_dict = {k: v for k, v in params_dict.items() if v is not None}
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"execute_code",
params_dict,
)
if not isinstance(response, dict):
return {"success": False, "message": str(response)}
return {
"success": response.get("success", False),
"message": response.get("message", response.get("error", "")),
"data": response.get("data"),
}
@@ -0,0 +1,59 @@
from typing import Any
from fastmcp import Context
from mcp.types import ToolAnnotations
from models.models import MCPResponse
from services.custom_tool_service import (
CustomToolService,
get_user_id_from_context,
resolve_project_id_for_unity_instance,
)
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
@mcp_for_unity_tool(
name="execute_custom_tool",
unity_target=None,
group=None,
description="Execute a project-scoped custom tool registered by Unity.",
annotations=ToolAnnotations(
title="Execute Custom Tool",
destructiveHint=True,
),
)
async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict[str, Any] | None = None) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
if not unity_instance:
return MCPResponse(
success=False,
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
)
project_id = resolve_project_id_for_unity_instance(unity_instance)
if project_id is None:
return MCPResponse(
success=False,
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
)
# The signature accepts None (parameter-less custom tools). Treat it as an empty
# dict rather than rejecting — the previous behavior contradicted the optional type.
if parameters is None:
parameters = {}
elif not isinstance(parameters, dict):
return MCPResponse(
success=False,
message="parameters must be an object/dictionary",
)
service = CustomToolService.get_instance()
user_id = await get_user_id_from_context(ctx)
return await service.execute_tool(
project_id,
tool_name,
unity_instance,
parameters,
user_id=user_id,
)
@@ -0,0 +1,32 @@
"""
Defines the execute_menu_item tool for executing and reading Unity Editor menu items.
"""
from typing import Annotated, Any
from fastmcp import Context
from mcp.types import ToolAnnotations
from models import MCPResponse
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
description="Execute a Unity menu item by path.",
annotations=ToolAnnotations(
title="Execute Menu Item",
destructiveHint=True,
),
)
async def execute_menu_item(
ctx: Context,
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
) -> MCPResponse:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict: dict[str, Any] = {"menuPath": menu_path}
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
return MCPResponse(**result) if isinstance(result, dict) else result
@@ -0,0 +1,116 @@
"""
Tool for searching GameObjects in Unity scenes.
Returns only instance IDs with pagination support for efficient searches.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from pydantic import Field
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.utils import coerce_bool, coerce_int
from services.tools.preflight import preflight
@mcp_for_unity_tool(
description=(
"Search for GameObjects in the scene by name, tag, layer, component type, or path. "
"Returns instance IDs only (paginated). "
"Then use mcpforunity://scene/gameobject/{id} resource for full data, "
"or mcpforunity://scene/gameobject/{id}/components for component details. "
"For CRUD operations (create/modify/delete), use manage_gameobject instead."
)
)
async def find_gameobjects(
ctx: Context,
search_term: Annotated[
str,
Field(description="The value to search for (name, tag, layer name, component type, or path)")
],
search_method: Annotated[
Literal["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"],
Field(
default="by_name",
description="How to search for GameObjects"
)
] = "by_name",
include_inactive: Annotated[
bool | str | None,
Field(
default=None,
description="Include inactive GameObjects in search"
)
] = None,
page_size: Annotated[
int | str | None,
Field(
default=None,
description="Number of results per page (default: 50, max: 500)"
)
] = None,
cursor: Annotated[
int | str | None,
Field(
default=None,
description="Pagination cursor (offset for next page)"
)
] = None,
) -> dict[str, Any]:
"""
Search for GameObjects and return their instance IDs.
This is a focused search tool optimized for finding GameObjects efficiently.
It returns only instance IDs to minimize payload size.
For detailed GameObject information, use the returned IDs with:
- mcpforunity://scene/gameobject/{id} - Get full GameObject data
- mcpforunity://scene/gameobject/{id}/components - Get all components
- mcpforunity://scene/gameobject/{id}/component/{name} - Get specific component
"""
unity_instance = await get_unity_instance_from_context(ctx)
# Validate required parameters before preflight I/O
if not search_term:
return {
"success": False,
"message": "Missing required parameter 'search_term'. Specify what to search for."
}
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
# Coerce parameters
include_inactive = coerce_bool(include_inactive, default=False)
page_size = coerce_int(page_size, default=50)
cursor = coerce_int(cursor, default=0)
try:
params = {
"searchMethod": search_method,
"searchTerm": search_term,
"includeInactive": include_inactive,
"pageSize": page_size,
"cursor": cursor,
}
params = {k: v for k, v in params.items() if v is not None}
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"find_gameobjects",
params,
)
if isinstance(response, dict) and response.get("success"):
return {
"success": True,
"message": response.get("message", "Search completed."),
"data": response.get("data")
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e:
return {"success": False, "message": f"Error searching GameObjects: {e!s}"}
+182
View File
@@ -0,0 +1,182 @@
import base64
import os
import re
from typing import Annotated, Any
from urllib.parse import unquote, urlparse
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
def _split_uri(uri: str) -> tuple[str, str]:
"""Split an incoming URI or path into (name, directory) suitable for Unity.
Rules:
- mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize)
- file://... → percent-decode, normalize, strip host and leading slashes,
then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
Otherwise, fall back to original name/dir behavior.
- plain paths → decode/normalize separators; if they contain an 'Assets' segment,
return relative to 'Assets'.
"""
raw_path: str
if uri.startswith("mcpforunity://path/"):
raw_path = uri[len("mcpforunity://path/"):]
elif uri.startswith("file://"):
parsed = urlparse(uri)
host = (parsed.netloc or "").strip()
p = parsed.path or ""
# UNC: file://server/share/... -> //server/share/...
if host and host.lower() != "localhost":
p = f"//{host}{p}"
# Use percent-decoded path, preserving leading slashes
raw_path = unquote(p)
else:
raw_path = uri
# Percent-decode any residual encodings and normalize separators
raw_path = unquote(raw_path).replace("\\", "/")
# Strip leading slash only for Windows drive-letter forms like "/C:/..."
if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
raw_path = raw_path[1:]
# Normalize path (collapse ../, ./)
norm = os.path.normpath(raw_path).replace("\\", "/")
# If an 'Assets' segment exists, compute path relative to it (case-insensitive)
parts = [p for p in norm.split("/") if p not in ("", ".")]
idx = next((i for i, seg in enumerate(parts)
if seg.lower() == "assets"), None)
assets_rel = "/".join(parts[idx:]) if idx is not None else None
effective_path = assets_rel if assets_rel else norm
# For POSIX absolute paths outside Assets, drop the leading '/'
# to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
if effective_path.startswith("/"):
effective_path = effective_path[1:]
name = os.path.splitext(os.path.basename(effective_path))[0]
directory = os.path.dirname(effective_path)
return name, directory
@mcp_for_unity_tool(
unity_target="manage_script",
description="Searches a file with a regex pattern and returns line numbers and excerpts.",
annotations=ToolAnnotations(
title="Find in File",
readOnlyHint=True,
),
)
async def find_in_file(
ctx: Context,
uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
pattern: Annotated[str, "The regex pattern to search for"],
project_root: Annotated[str | None, "Optional project root path"] = None,
max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
ignore_case: Annotated[bool | str | None,
"Case insensitive search"] = True,
) -> dict[str, Any]:
# project_root is currently unused but kept for interface consistency
unity_instance = await get_unity_instance_from_context(ctx)
await ctx.info(
f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
name, directory = _split_uri(uri)
# 1. Read file content via Unity
read_resp = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_script",
{
"action": "read",
"name": name,
"path": directory,
},
)
if not isinstance(read_resp, dict) or not read_resp.get("success"):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data", {})
contents = data.get("contents")
if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
try:
contents = base64.b64decode(data.get("encodedContents", "").encode(
"utf-8")).decode("utf-8", "replace")
except (ValueError, TypeError, base64.binascii.Error):
contents = contents or ""
if contents is None:
return {"success": False, "message": "Could not read file content."}
# 2. Perform regex search
flags = re.MULTILINE
# Handle ignore_case which can be boolean or string from some clients
ic = ignore_case
if isinstance(ic, str):
ic = ic.lower() in ("true", "1", "yes")
if ic:
flags |= re.IGNORECASE
try:
regex = re.compile(pattern, flags)
except re.error as e:
return {"success": False, "message": f"Invalid regex pattern: {e}"}
# If the regex is not multiline specific (doesn't contain \n literal match logic),
# we could iterate lines. But users might use multiline regexes.
# Let's search the whole content and map back to lines.
found = list(regex.finditer(contents))
results = []
count = 0
for m in found:
if count >= max_results:
break
start_idx = m.start()
end_idx = m.end()
# Calculate line number
# Count newlines up to start_idx
line_num = contents.count('\n', 0, start_idx) + 1
# Get line content for excerpt
# Find start of line
line_start = contents.rfind('\n', 0, start_idx) + 1
# Find end of line
line_end = contents.find('\n', start_idx)
if line_end == -1:
line_end = len(contents)
line_content = contents[line_start:line_end]
# Create excerpt
# We can just return the line content as excerpt
results.append({
"line": line_num,
"content": line_content.strip(), # detailed match info?
"match": m.group(0),
"start": start_idx,
"end": end_idx
})
count += 1
return {
"success": True,
"data": {
"matches": results,
"count": len(results),
"total_matches": len(found)
}
}
@@ -0,0 +1,88 @@
"""
Defines the generate_image tool for AI 2D image generation in Unity.
Thin pass-through: this tool carries NO API keys and NO file bytes. The C# side
reads the user's provider key from the OS secure store, performs the provider
HTTPS call, downloads/decodes the result, and imports it as a texture.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
group="asset_gen",
description=(
"Generate 2D images with AI providers (fal.ai, OpenRouter) and import them as "
"textures/sprites into the Unity project. Bring-your-own-key: provider keys live "
"in the editor's secure store and never cross the bridge.\n\n"
"ACTIONS:\n"
"- generate: Submit an image job (text->image or image->image). Returns { job_id }; "
"poll with the status action. Params: "
"provider, mode (text|image), prompt, image_path|image_url, model, transparent, "
"width, height, name, output_folder.\n"
"- remove_background: Unsupported in this version; returns an error instead of a job_id.\n"
"- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n"
"- cancel: Cancel an in-flight job by job_id.\n"
"- list_providers: List configured image providers and capabilities (no key values)."
),
annotations=ToolAnnotations(
title="Generate Image",
destructiveHint=False,
),
)
async def generate_image(
ctx: Context,
action: Annotated[Literal["generate", "remove_background", "status", "cancel", "list_providers"],
"Action to perform."],
provider: Annotated[str, "Provider id (fal, openrouter)."] | None = None,
mode: Annotated[str, "Generation mode: text or image."] | None = None,
prompt: Annotated[str, "Text prompt for text->image."] | None = None,
image_path: Annotated[str, "Path to a source image for image->image mode."] | None = None,
image_url: Annotated[str, "URL of a source image for image->image."] | None = None,
model: Annotated[str, "Provider model id/slug (e.g. FLUX, gemini-2.5-flash-image)."] | None = None,
transparent: Annotated[bool, "Mark the imported texture as alpha-is-transparency. NOTE: fal/FLUX "
"and OpenRouter have no generation-time transparency, so this only sets the "
"Unity import flag — it does not make the model render a transparent background."] | None = None,
width: Annotated[int, "Output width in pixels."] | None = None,
height: Annotated[int, "Output height in pixels."] | None = None,
name: Annotated[str, "Base name for the imported asset."] | None = None,
output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None,
job_id: Annotated[str, "Job id for status/cancel."] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict = {
"action": action.lower(),
"provider": provider,
"mode": mode,
"prompt": prompt,
"imagePath": image_path,
"imageUrl": image_url,
"model": model,
"transparent": transparent,
"width": width,
"height": height,
"name": name,
"outputFolder": output_folder,
"jobId": job_id,
}
# Remove None values
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"generate_image",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -0,0 +1,85 @@
"""
Defines the generate_model tool for AI 3D model generation in Unity.
Thin pass-through: this tool carries NO API keys and NO file bytes. The C# side
reads the user's provider key from the OS secure store, performs the provider
HTTPS call, downloads the result, and imports it into the Unity project.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
group="asset_gen",
description=(
"Generate 3D models with AI providers (Tripo, Meshy) and import them "
"into the Unity project. Bring-your-own-key: provider keys live in the editor's "
"secure store and never cross the bridge.\n\n"
"ACTIONS:\n"
"- generate: Submit a generation job (text->3D or image->3D). Returns { job_id } "
"immediately; poll with the status action. Params: provider, mode (text|image), "
"prompt, image_path|image_url, format (glb|fbx|obj|usdz), target_size, texture, "
"tier, name, output_folder.\n"
"- status: Poll an async job by job_id -> { state, progress, assetPath?, error? }.\n"
"- cancel: Cancel an in-flight job by job_id.\n"
"- list_providers: List configured 3D providers and capabilities (no key values)."
),
annotations=ToolAnnotations(
title="Generate Model",
destructiveHint=False,
),
)
async def generate_model(
ctx: Context,
action: Annotated[Literal["generate", "status", "cancel", "list_providers"],
"Action to perform."],
provider: Annotated[str, "Provider id (tripo, meshy)."] | None = None,
mode: Annotated[str, "Generation mode: text or image."] | None = None,
prompt: Annotated[str, "Text prompt for text->3D."] | None = None,
image_path: Annotated[str, "Path to a source image for image->3D."] | None = None,
image_url: Annotated[str, "URL of a source image for image->3D."] | None = None,
format: Annotated[str, "Output model format: glb, fbx, obj, or usdz."] | None = None,
target_size: Annotated[float, "Normalize the largest dimension to this size (meters)."] | None = None,
texture: Annotated[bool, "Whether to generate textures for the model."] | None = None,
tier: Annotated[str, "Provider quality/cost tier."] | None = None,
name: Annotated[str, "Base name for the imported asset."] | None = None,
output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None,
job_id: Annotated[str, "Job id for status/cancel."] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict = {
"action": action.lower(),
"provider": provider,
"mode": mode,
"prompt": prompt,
"imagePath": image_path,
"imageUrl": image_url,
"format": format,
"targetSize": target_size,
"texture": texture,
"tier": tier,
"name": name,
"outputFolder": output_folder,
"jobId": job_id,
}
# Remove None values
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"generate_model",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
+84
View File
@@ -0,0 +1,84 @@
"""
Defines the import_model tool for importing 3D models from the Sketchfab
marketplace into Unity.
Thin pass-through: this tool carries NO API keys and NO file bytes. The C# side
reads the user's Sketchfab token from the OS secure store, performs the search /
download, and imports the model into the Unity project.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
group="asset_gen",
description=(
"Import 3D models from the Sketchfab marketplace into the Unity project. "
"Bring-your-own-key: the Sketchfab token lives in the editor's secure store and "
"never crosses the bridge.\n\n"
"ACTIONS:\n"
"- search: Search Sketchfab. Params: query, categories, downloadable, count, "
"cursor -> results with model uids.\n"
"- preview: Fetch model metadata (name, thumbnail URLs, license, vertex/face counts) "
"for a uid before import.\n"
"- import: Download + import a model by uid. Returns { job_id } immediately; poll "
"with the status action. Params: uid, target_size, name, output_folder.\n"
"- status: Poll an async import job by job_id -> { state, progress, assetPath?, error? }.\n"
"- cancel: Cancel an in-flight import by job_id.\n"
"- list_providers: List configured marketplace providers (no key values)."
),
annotations=ToolAnnotations(
title="Import Model",
destructiveHint=False,
),
)
async def import_model(
ctx: Context,
action: Annotated[Literal["search", "preview", "import", "status", "cancel", "list_providers"],
"Action to perform."],
query: Annotated[str, "Search query for the search action."] | None = None,
categories: Annotated[str, "Filter search by category."] | None = None,
downloadable: Annotated[bool, "Restrict search to downloadable models."] | None = None,
count: Annotated[int, "Maximum number of search results."] | None = None,
cursor: Annotated[str, "Pagination cursor for search."] | None = None,
uid: Annotated[str, "Sketchfab model uid for preview/import."] | None = None,
target_size: Annotated[float, "Normalize the largest dimension to this size (meters)."] | None = None,
name: Annotated[str, "Base name for the imported asset."] | None = None,
output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None,
job_id: Annotated[str, "Job id for status/cancel."] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict = {
"action": action.lower(),
"query": query,
"categories": categories,
"downloadable": downloadable,
"count": count,
"cursor": cursor,
"uid": uid,
"targetSize": target_size,
"name": name,
"outputFolder": output_folder,
"jobId": job_id,
}
# Remove None values
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"import_model",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -0,0 +1,72 @@
"""
Defines the import_model_file tool: import a local 3D model file (already on disk,
e.g. exported from Blender) into the Unity project.
Thin pass-through: NO API keys and NO file bytes cross the bridge. The C# side copies
the file under Assets/ and runs the shared model-import pipeline.
"""
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
@mcp_for_unity_tool(
group="asset_gen",
description=(
"Import a local 3D model file that already exists on disk (e.g. an FBX/OBJ/glTF "
"exported from Blender or another DCC tool) into the Unity project. The file is copied "
"under Assets/ and run through Unity's model-import pipeline (scale-normalize, material "
"settings; glTF requires glTFast). Carries no API keys and no file bytes over the bridge.\n\n"
"Params: source_path (absolute or Assets-relative path to a .fbx/.obj/.glb/.gltf/.zip), "
"name, output_folder (under Assets/), target_size, animation_type. "
"Returns { asset_path, asset_guid }.\n\n"
"animation_type (FBX/OBJ only): pass 'generic' or 'humanoid' for a rigged/animated mesh so "
"Unity surfaces its AnimationClips; omitted or 'none' imports no rig (this is the usual "
"cause of a rigged FBX importing with zero clips); 'legacy' selects Unity's legacy Animation "
"system (rarely needed). glTF/GLB ignore it — glTFast imports animation itself.\n\n"
"For multi-file exports (a text .gltf with an external .bin, or an .obj with a sibling "
".mtl/textures), zip them and pass the .zip — a bare .gltf/.obj is copied without its sidecars."
),
annotations=ToolAnnotations(
title="Import Model File",
destructiveHint=False,
),
)
async def import_model_file(
ctx: Context,
source_path: Annotated[str, "Path to the model file on disk (.fbx/.obj/.glb/.gltf/.zip)."],
name: Annotated[str, "Base name for the imported asset."] | None = None,
output_folder: Annotated[str, "Destination folder under Assets/ for the import."] | None = None,
target_size: Annotated[float, "Normalize the largest dimension to this size (meters)."] | None = None,
animation_type: Annotated[
Literal["none", "generic", "humanoid", "legacy"],
"FBX/OBJ only: rig/animation import mode. 'generic' or 'humanoid' surface the model's "
"AnimationClips; 'legacy' selects Unity's legacy Animation system (rarely needed); "
"omitted or 'none' imports no rig. Ignored for glTF/GLB.",
] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
params_dict = {
"sourcePath": source_path,
"name": name,
"outputFolder": output_folder,
"targetSize": target_size,
"animationType": animation_type,
}
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"import_model_file",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -0,0 +1,113 @@
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
ANIMATOR_ACTIONS = [
"animator_get_info", "animator_get_parameter",
"animator_play", "animator_crossfade",
"animator_set_parameter", "animator_set_speed", "animator_set_enabled",
]
CONTROLLER_ACTIONS = [
"controller_create", "controller_add_state", "controller_add_transition",
"controller_add_parameter", "controller_get_info", "controller_assign",
"controller_add_layer", "controller_remove_layer", "controller_set_layer_weight",
"controller_create_blend_tree_1d", "controller_create_blend_tree_2d", "controller_add_blend_tree_child",
]
CLIP_ACTIONS = [
"clip_create", "clip_get_info",
"clip_add_curve", "clip_set_curve", "clip_set_vector_curve",
"clip_create_preset", "clip_assign",
"clip_add_event", "clip_remove_event",
]
ALL_ACTIONS = ANIMATOR_ACTIONS + CONTROLLER_ACTIONS + CLIP_ACTIONS #Not loaded in the MCP context, but will return this in the error response (1 Shot)
@mcp_for_unity_tool(
group="animation",
description=(
"Manage Unity animation: Animator control and AnimationClip creation. "
"Action prefixes: animator_* (play, crossfade, set parameters, get info), "
"controller_* (create AnimatorControllers, add states/transitions/parameters), "
"clip_* (create clips, add keyframe curves, assign to GameObjects). "
"Action-specific parameters go in `properties` (keys match ManageAnimation.cs)."
),
annotations=ToolAnnotations(
title="Manage Animation",
destructiveHint=True,
),
)
async def manage_animation(
ctx: Context,
action: Annotated[str, "Action to perform (prefix: animator_, controller_, clip_)."],
target: Annotated[str | None, "Target GameObject (name/path/id)."] = None,
search_method: Annotated[
Literal["by_id", "by_name", "by_path", "by_tag", "by_layer"] | None,
"How to find the target GameObject.",
] = None,
clip_path: Annotated[str | None, "Asset path for AnimationClip (e.g. 'Assets/Animations/Walk.anim')."] = None,
controller_path: Annotated[str | None, "Asset path for AnimatorController (e.g. 'Assets/Animators/Player.controller')."] = None,
properties: Annotated[
dict[str, Any] | str | None,
"Action-specific parameters (dict or JSON string).",
] = None,
) -> dict[str, Any]:
"""Unified animation management tool."""
action_normalized = action.lower()
if action_normalized not in ALL_ACTIONS:
prefix = action_normalized.split("_")[0] + "_" if "_" in action_normalized else ""
available_by_prefix = {
"animator_": ANIMATOR_ACTIONS,
"controller_": CONTROLLER_ACTIONS,
"clip_": CLIP_ACTIONS,
}
suggestions = available_by_prefix.get(prefix, [])
if suggestions:
return {
"success": False,
"message": f"Unknown action '{action}'. Available {prefix}* actions: {', '.join(suggestions)}",
}
else:
return {
"success": False,
"message": (
f"Unknown action '{action}'. Use prefixes: "
"animator_* (Animator control), controller_* (AnimatorController CRUD), "
"clip_* (AnimationClip operations)."
),
}
unity_instance = await get_unity_instance_from_context(ctx)
params_dict: dict[str, Any] = {"action": action_normalized}
if properties is not None:
params_dict["properties"] = properties
if target is not None:
params_dict["target"] = target
if search_method is not None:
params_dict["searchMethod"] = search_method
if clip_path is not None:
params_dict["clipPath"] = clip_path
if controller_path is not None:
params_dict["controllerPath"] = controller_path
params_dict = {k: v for k, v in params_dict.items() if v is not None}
result = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
"manage_animation",
params_dict,
)
return result if isinstance(result, dict) else {"success": False, "message": str(result)}
+119
View File
@@ -0,0 +1,119 @@
"""
Defines the manage_asset tool for interacting with Unity assets.
"""
import asyncio
import json
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from services.tools.utils import parse_json_payload, coerce_int, normalize_properties
from transport.unity_transport import send_with_unity_instance
from transport.legacy.unity_connection import async_send_command_with_retry
from services.tools.preflight import preflight
@mcp_for_unity_tool(
description=(
"Performs asset operations (import, create, modify, delete, etc.) in Unity.\n\n"
"Tip (payload safety): for `action=\"search\"`, prefer paging (`page_size`, `page_number`) and keep "
"`generate_preview=false` (previews can add large base64 blobs)."
),
annotations=ToolAnnotations(
title="Manage Asset",
destructiveHint=True,
),
)
async def manage_asset(
ctx: Context,
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope (e.g., 'Assets')."],
asset_type: Annotated[str,
"Asset type (e.g., 'Material', 'Folder') - required for 'create'. Note: For ScriptableObjects, use manage_scriptable_object."] | None = None,
properties: Annotated[dict[str, Any] | str,
"Dictionary of properties for 'create'/'modify'. Keys are property names, values are property values."] | None = None,
destination: Annotated[str,
"Target path for 'duplicate'/'move'."] | None = None,
generate_preview: Annotated[bool,
"Generate a preview/thumbnail for the asset when supported. "
"Warning: previews may include large base64 payloads; keep false unless needed."] = False,
search_pattern: Annotated[str,
"Search pattern (e.g., '*.prefab' or AssetDatabase filters like 't:MonoScript'). "
"Recommended: put queries like 't:MonoScript' here and set path='Assets'."] | None = None,
filter_type: Annotated[str, "Filter type for search"] | None = None,
filter_date_after: Annotated[str,
"Date after which to filter"] | None = None,
page_size: Annotated[int | float | str,
"Page size for pagination. Recommended: 25 (smaller for LLM-friendly responses)."] | None = None,
page_number: Annotated[int | float | str,
"Page number for pagination (1-based)."] | None = None,
) -> dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
# Best-effort guard: if Unity is compiling/reloading or known external changes are pending,
# wait/refresh to avoid stale reads and flaky timeouts.
gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True)
if gate is not None:
return gate.model_dump()
# --- Normalize properties using robust module-level helper ---
properties, parse_error = normalize_properties(properties)
if parse_error:
await ctx.error(f"manage_asset: {parse_error}")
return {"success": False, "message": parse_error}
page_size = coerce_int(page_size)
page_number = coerce_int(page_number)
# --- Payload-safe normalization for common LLM mistakes (search) ---
# Unity's C# handler treats `path` as a folder scope. If a model mistakenly puts a query like
# "t:MonoScript" into `path`, Unity will consider it an invalid folder and fall back to searching
# the entire project, which is token-heavy. Normalize such cases into search_pattern + Assets scope.
action_l = (action or "").lower()
if action_l == "search":
try:
raw_path = (path or "").strip()
except (AttributeError, TypeError):
# Handle case where path is not a string despite type annotation
raw_path = ""
# If the caller put an AssetDatabase query into `path`, treat it as `search_pattern`.
if (not search_pattern) and raw_path.startswith("t:"):
search_pattern = raw_path
path = "Assets"
await ctx.info("manage_asset(search): normalized query from `path` into `search_pattern` and set path='Assets'")
# If the caller used `asset_type` to mean a search filter, map it to filter_type.
# (In Unity, filterType becomes `t:<filterType>`.)
if (not filter_type) and asset_type and isinstance(asset_type, str):
filter_type = asset_type
await ctx.info("manage_asset(search): mapped `asset_type` into `filter_type` for safer server-side filtering")
# Prepare parameters for the C# handler
params_dict = {
"action": action.lower(),
"path": path,
"assetType": asset_type,
"properties": properties,
"destination": destination,
"generatePreview": generate_preview,
"searchPattern": search_pattern,
"filterType": filter_type,
"filterDateAfter": filter_date_after,
"pageSize": page_size,
"pageNumber": page_number
}
# Remove None values to avoid sending unnecessary nulls
params_dict = {k: v for k, v in params_dict.items() if v is not None}
# Get the current asyncio event loop
loop = asyncio.get_running_loop()
# Use centralized async retry helper with instance routing
result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity
return result if isinstance(result, dict) else {"success": False, "message": str(result)}

Some files were not shown because too many files have changed in this diff Show More