chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# MCP for Unity Server (Docker Image)
|
||||
|
||||
[](https://modelcontextprotocol.io/introduction)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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)
|
||||
@@ -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 []
|
||||
@@ -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.
|
||||
@@ -0,0 +1,290 @@
|
||||
# MCP for Unity Server
|
||||
|
||||
[](https://modelcontextprotocol.io/introduction)
|
||||
[](https://www.python.org)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://discord.gg/y4p8KfzrN4)
|
||||
[](https://pypi.org/project/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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"typeCheckingMode": "basic",
|
||||
"reportMissingImports": "none",
|
||||
"pythonVersion": "3.11",
|
||||
"executionEnvironments": [
|
||||
{
|
||||
"root": ".",
|
||||
"pythonVersion": "3.11"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# MCP for Unity Server
|
||||
@@ -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"
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Unity MCP Command Line Interface."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""CLI command modules."""
|
||||
|
||||
# Commands will be registered in main.py
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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']}")
|
||||
@@ -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}'")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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.")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Server-wide protocol constants."""
|
||||
|
||||
# HTTP header name for API key authentication
|
||||
API_KEY_HEADER = "X-API-Key"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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}"}
|
||||
@@ -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)}
|
||||
@@ -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)}
|
||||
@@ -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
Reference in New Issue
Block a user