unity-mcp-server 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unity_mcp_server-1.0.0/.dockerignore +5 -0
- unity_mcp_server-1.0.0/.gitignore +111 -0
- unity_mcp_server-1.0.0/Dockerfile +18 -0
- unity_mcp_server-1.0.0/PKG-INFO +79 -0
- unity_mcp_server-1.0.0/README.md +56 -0
- unity_mcp_server-1.0.0/docker-compose.yml +15 -0
- unity_mcp_server-1.0.0/pyproject.toml +37 -0
- unity_mcp_server-1.0.0/unity_mcp_server/__init__.py +2 -0
- unity_mcp_server-1.0.0/unity_mcp_server/config.py +23 -0
- unity_mcp_server-1.0.0/unity_mcp_server/server.py +400 -0
- unity_mcp_server-1.0.0/unity_mcp_server/tools/__init__.py +1 -0
- unity_mcp_server-1.0.0/unity_mcp_server/tools/asset_validator.py +77 -0
- unity_mcp_server-1.0.0/unity_mcp_server/tools/script_analyzer.py +56 -0
- unity_mcp_server-1.0.0/unity_mcp_server/unity_connection.py +143 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# This .gitignore file should be placed at the root of your Unity project directory
|
|
2
|
+
#
|
|
3
|
+
# Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore
|
|
4
|
+
#
|
|
5
|
+
.utmp/
|
|
6
|
+
/[Ll]ibrary/
|
|
7
|
+
/[Tt]emp/
|
|
8
|
+
/[Oo]bj/
|
|
9
|
+
/[Bb]uild/
|
|
10
|
+
/[Bb]uilds/
|
|
11
|
+
/[Ll]ogs/
|
|
12
|
+
/[Uu]ser[Ss]ettings/
|
|
13
|
+
*.log
|
|
14
|
+
|
|
15
|
+
# By default unity supports Blender asset imports, *.blend1 blender files do not need to be commited to version control.
|
|
16
|
+
*.blend1
|
|
17
|
+
*.blend1.meta
|
|
18
|
+
|
|
19
|
+
# MemoryCaptures can get excessive in size.
|
|
20
|
+
# They also could contain extremely sensitive data
|
|
21
|
+
/[Mm]emoryCaptures/
|
|
22
|
+
|
|
23
|
+
# Recordings can get excessive in size
|
|
24
|
+
/[Rr]ecordings/
|
|
25
|
+
|
|
26
|
+
# Uncomment this line if you wish to ignore the asset store tools plugin
|
|
27
|
+
# /[Aa]ssets/AssetStoreTools*
|
|
28
|
+
|
|
29
|
+
# Autogenerated Jetbrains Rider plugin
|
|
30
|
+
/[Aa]ssets/Plugins/Editor/JetBrains*
|
|
31
|
+
# Jetbrains Rider personal-layer settings
|
|
32
|
+
*.DotSettings.user
|
|
33
|
+
|
|
34
|
+
# Visual Studio cache directory
|
|
35
|
+
.vs/
|
|
36
|
+
|
|
37
|
+
# Gradle cache directory
|
|
38
|
+
.gradle/
|
|
39
|
+
|
|
40
|
+
# Autogenerated VS/MD/Consulo solution and project files
|
|
41
|
+
ExportedObj/
|
|
42
|
+
.consulo/
|
|
43
|
+
*.csproj
|
|
44
|
+
*.unityproj
|
|
45
|
+
*.sln
|
|
46
|
+
*.suo
|
|
47
|
+
*.tmp
|
|
48
|
+
*.user
|
|
49
|
+
*.userprefs
|
|
50
|
+
*.pidb
|
|
51
|
+
*.booproj
|
|
52
|
+
*.svd
|
|
53
|
+
*.pdb
|
|
54
|
+
*.mdb
|
|
55
|
+
*.opendb
|
|
56
|
+
*.VC.db
|
|
57
|
+
|
|
58
|
+
# Unity3D generated meta files
|
|
59
|
+
*.pidb.meta
|
|
60
|
+
*.pdb.meta
|
|
61
|
+
*.mdb.meta
|
|
62
|
+
|
|
63
|
+
# Unity3D generated file on crash reports
|
|
64
|
+
sysinfo.txt
|
|
65
|
+
|
|
66
|
+
# Mono auto generated files
|
|
67
|
+
mono_crash.*
|
|
68
|
+
|
|
69
|
+
# Builds
|
|
70
|
+
*.apk
|
|
71
|
+
*.aab
|
|
72
|
+
*.unitypackage
|
|
73
|
+
*.unitypackage.meta
|
|
74
|
+
*.app
|
|
75
|
+
|
|
76
|
+
# Crashlytics generated file
|
|
77
|
+
crashlytics-build.properties
|
|
78
|
+
|
|
79
|
+
# TestRunner generated files
|
|
80
|
+
InitTestScene*.unity*
|
|
81
|
+
|
|
82
|
+
# Addressables default ignores, before user customizations
|
|
83
|
+
/ServerData
|
|
84
|
+
/[Aa]ssets/StreamingAssets/aa*
|
|
85
|
+
/[Aa]ssets/AddressableAssetsData/link.xml*
|
|
86
|
+
/[Aa]ssets/Addressables_Temp*
|
|
87
|
+
# By default, Addressables content builds will generate addressables_content_state.bin
|
|
88
|
+
# files in platform-specific subfolders, for example:
|
|
89
|
+
# /Assets/AddressableAssetsData/OSX/addressables_content_state.bin
|
|
90
|
+
/[Aa]ssets/AddressableAssetsData/*/*.bin*
|
|
91
|
+
|
|
92
|
+
# Visual Scripting auto-generated files
|
|
93
|
+
/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db
|
|
94
|
+
/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Flow/UnitOptions.db.meta
|
|
95
|
+
/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers
|
|
96
|
+
/[Aa]ssets/Unity.VisualScripting.Generated/VisualScripting.Core/Property Providers.meta
|
|
97
|
+
|
|
98
|
+
# Auto-generated scenes by play mode tests
|
|
99
|
+
/[Aa]ssets/[Ii]nit[Tt]est[Ss]cene*.unity*
|
|
100
|
+
|
|
101
|
+
McpProjects/*
|
|
102
|
+
|
|
103
|
+
# Bridge build artifacts (use build_bridge.sh to rebuild)
|
|
104
|
+
unity-bridge/bin/
|
|
105
|
+
unity-bridge/obj/
|
|
106
|
+
|
|
107
|
+
unity-server/dist/
|
|
108
|
+
|
|
109
|
+
# NOTE: unity-mcp/Bridge~/ is NOT ignored — it contains bundled bridge
|
|
110
|
+
# binaries that must be committed for git URL installs to work.
|
|
111
|
+
# Run ./scripts/build_bridge.sh to populate it before publishing.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Install dependencies first (cache layer)
|
|
6
|
+
COPY pyproject.toml .
|
|
7
|
+
RUN pip install --no-cache-dir "mcp>=1.0.0"
|
|
8
|
+
|
|
9
|
+
# Copy source code and install package
|
|
10
|
+
COPY unity_mcp_server/ unity_mcp_server/
|
|
11
|
+
RUN pip install --no-cache-dir --no-deps .
|
|
12
|
+
|
|
13
|
+
# Default environment
|
|
14
|
+
ENV UNITY_MCP_HOST=host.docker.internal
|
|
15
|
+
ENV UNITY_MCP_PORT=52345
|
|
16
|
+
ENV UNITY_MCP_TIMEOUT=60
|
|
17
|
+
|
|
18
|
+
ENTRYPOINT ["unity-mcp-server"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unity-mcp-server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python MCP server for Unity Editor — enables AI assistants to control Unity via the Model Context Protocol
|
|
5
|
+
Project-URL: Homepage, https://github.com/mzbswh/unity-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/mzbswh/unity-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/mzbswh/unity-mcp/issues
|
|
8
|
+
Author: mzbswh
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai,game-development,llm,mcp,unity
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Games/Entertainment
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: mcp>=1.0.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# unity-mcp-server
|
|
25
|
+
|
|
26
|
+
Python MCP server for [Unity MCP](https://github.com/mzbswh/unity-mcp) — enables AI assistants (Claude, Cursor, VS Code Copilot, Windsurf) to control the Unity Editor via the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### 1. Install the Unity package
|
|
31
|
+
|
|
32
|
+
In Unity: `Window > Package Manager > + > Add package from git URL`:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
https://github.com/mzbswh/unity-mcp.git?path=unity-mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Configure your MCP client
|
|
39
|
+
|
|
40
|
+
Add to your MCP client config (e.g. `.cursor/mcp.json`, `.vscode/mcp.json`):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"unity": {
|
|
46
|
+
"command": "uvx",
|
|
47
|
+
"args": ["unity-mcp-server"],
|
|
48
|
+
"env": {
|
|
49
|
+
"UNITY_MCP_PORT": "52345"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or use **Window > Unity MCP > Clients** tab in Unity for one-click configuration.
|
|
57
|
+
|
|
58
|
+
### 3. Verify
|
|
59
|
+
|
|
60
|
+
Ask your AI assistant: *"List all GameObjects in my Unity scene"*
|
|
61
|
+
|
|
62
|
+
## Environment Variables
|
|
63
|
+
|
|
64
|
+
| Variable | Default | Description |
|
|
65
|
+
|----------|---------|-------------|
|
|
66
|
+
| `UNITY_MCP_HOST` | `127.0.0.1` | Unity Editor host address |
|
|
67
|
+
| `UNITY_MCP_PORT` | `52345` | Unity Editor TCP port |
|
|
68
|
+
| `UNITY_MCP_TIMEOUT` | `60` | Request timeout (seconds) |
|
|
69
|
+
|
|
70
|
+
## Extra Tools
|
|
71
|
+
|
|
72
|
+
In addition to all Unity Editor tools (60+), this server provides:
|
|
73
|
+
|
|
74
|
+
- `analyze_script` — C# script static analysis
|
|
75
|
+
- `validate_assets` — Asset naming and directory validation
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
[MIT](https://github.com/mzbswh/unity-mcp/blob/main/LICENSE)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# unity-mcp-server
|
|
2
|
+
|
|
3
|
+
Python MCP server for [Unity MCP](https://github.com/mzbswh/unity-mcp) — enables AI assistants (Claude, Cursor, VS Code Copilot, Windsurf) to control the Unity Editor via the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
### 1. Install the Unity package
|
|
8
|
+
|
|
9
|
+
In Unity: `Window > Package Manager > + > Add package from git URL`:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
https://github.com/mzbswh/unity-mcp.git?path=unity-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2. Configure your MCP client
|
|
16
|
+
|
|
17
|
+
Add to your MCP client config (e.g. `.cursor/mcp.json`, `.vscode/mcp.json`):
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"unity": {
|
|
23
|
+
"command": "uvx",
|
|
24
|
+
"args": ["unity-mcp-server"],
|
|
25
|
+
"env": {
|
|
26
|
+
"UNITY_MCP_PORT": "52345"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or use **Window > Unity MCP > Clients** tab in Unity for one-click configuration.
|
|
34
|
+
|
|
35
|
+
### 3. Verify
|
|
36
|
+
|
|
37
|
+
Ask your AI assistant: *"List all GameObjects in my Unity scene"*
|
|
38
|
+
|
|
39
|
+
## Environment Variables
|
|
40
|
+
|
|
41
|
+
| Variable | Default | Description |
|
|
42
|
+
|----------|---------|-------------|
|
|
43
|
+
| `UNITY_MCP_HOST` | `127.0.0.1` | Unity Editor host address |
|
|
44
|
+
| `UNITY_MCP_PORT` | `52345` | Unity Editor TCP port |
|
|
45
|
+
| `UNITY_MCP_TIMEOUT` | `60` | Request timeout (seconds) |
|
|
46
|
+
|
|
47
|
+
## Extra Tools
|
|
48
|
+
|
|
49
|
+
In addition to all Unity Editor tools (60+), this server provides:
|
|
50
|
+
|
|
51
|
+
- `analyze_script` — C# script static analysis
|
|
52
|
+
- `validate_assets` — Asset naming and directory validation
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
[MIT](https://github.com/mzbswh/unity-mcp/blob/main/LICENSE)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
services:
|
|
2
|
+
unity-mcp-server:
|
|
3
|
+
build: .
|
|
4
|
+
environment:
|
|
5
|
+
# host.docker.internal lets the container reach Unity on the host machine
|
|
6
|
+
- UNITY_MCP_HOST=host.docker.internal
|
|
7
|
+
- UNITY_MCP_PORT=${UNITY_MCP_PORT:-52345}
|
|
8
|
+
- UNITY_MCP_TIMEOUT=${UNITY_MCP_TIMEOUT:-60}
|
|
9
|
+
# stdio mode: MCP client connects via stdin/stdout
|
|
10
|
+
stdin_open: true
|
|
11
|
+
# For Streamable HTTP mode, uncomment:
|
|
12
|
+
# ports:
|
|
13
|
+
# - "8000:8000"
|
|
14
|
+
extra_hosts:
|
|
15
|
+
- "host.docker.internal:host-gateway"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unity-mcp-server"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Python MCP server for Unity Editor — enables AI assistants to control Unity via the Model Context Protocol"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "mzbswh" }
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "unity", "ai", "llm", "game-development"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Games/Entertainment",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"mcp>=1.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/mzbswh/unity-mcp"
|
|
29
|
+
Repository = "https://github.com/mzbswh/unity-mcp"
|
|
30
|
+
Issues = "https://github.com/mzbswh/unity-mcp/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
unity-mcp-server = "unity_mcp_server.server:main"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Unity MCP Server.
|
|
2
|
+
|
|
3
|
+
The UNITY_MCP_PORT environment variable is set automatically by the Unity Editor
|
|
4
|
+
when launching this server (see ServerProcessManager.CreatePythonStartInfo).
|
|
5
|
+
If not set, falls back to 0 which will cause an explicit connection error rather
|
|
6
|
+
than silently connecting to the wrong port.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
UNITY_HOST = os.environ.get("UNITY_MCP_HOST", "127.0.0.1")
|
|
12
|
+
|
|
13
|
+
_port_str = os.environ.get("UNITY_MCP_PORT", "")
|
|
14
|
+
if _port_str:
|
|
15
|
+
UNITY_PORT = int(_port_str)
|
|
16
|
+
else:
|
|
17
|
+
logging.warning(
|
|
18
|
+
"UNITY_MCP_PORT not set. The Unity Editor should set this automatically. "
|
|
19
|
+
"Set it manually or check your MCP client configuration (env field)."
|
|
20
|
+
)
|
|
21
|
+
UNITY_PORT = 0
|
|
22
|
+
|
|
23
|
+
REQUEST_TIMEOUT = float(os.environ.get("UNITY_MCP_TIMEOUT", "60.0"))
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Unity MCP Server - FastMCP entry point.
|
|
2
|
+
|
|
3
|
+
This Python server acts as a dynamic bridge between MCP clients (Claude, etc.)
|
|
4
|
+
and the Unity Editor. It discovers tools/resources/prompts from Unity via TCP
|
|
5
|
+
and forwards all calls dynamically.
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
from .unity_connection import UnityConnection
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .config import UNITY_HOST, UNITY_PORT
|
|
15
|
+
|
|
16
|
+
logging.basicConfig(level=logging.INFO)
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnityToolError(Exception):
|
|
21
|
+
"""Raised when a Unity tool reports isError=true. Propagates to FastMCP as an MCP error."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
# Track registered names to avoid duplicates on reconnect
|
|
25
|
+
_registered_tools: set[str] = set()
|
|
26
|
+
_registered_resources: set[str] = set()
|
|
27
|
+
_registered_prompts: set[str] = set()
|
|
28
|
+
|
|
29
|
+
# Global connection instance (initialized in lifespan)
|
|
30
|
+
unity: UnityConnection | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def lifespan(server: FastMCP):
|
|
35
|
+
"""Startup/shutdown lifecycle for the MCP server."""
|
|
36
|
+
global unity
|
|
37
|
+
unity = UnityConnection(UNITY_HOST, UNITY_PORT)
|
|
38
|
+
try:
|
|
39
|
+
await _discover_and_register(server)
|
|
40
|
+
except ConnectionRefusedError:
|
|
41
|
+
logger.warning(
|
|
42
|
+
"Unity not available at startup. "
|
|
43
|
+
"Tools will fail until Unity is running with MCP enabled."
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error(f"Failed to discover tools during startup: {e}")
|
|
47
|
+
yield
|
|
48
|
+
# Shutdown
|
|
49
|
+
try:
|
|
50
|
+
if unity and unity.connected:
|
|
51
|
+
await unity.disconnect()
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"Error during shutdown: {e}")
|
|
54
|
+
finally:
|
|
55
|
+
unity = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
mcp = FastMCP("Unity MCP", version=__version__, lifespan=lifespan)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _ensure_connected():
|
|
62
|
+
"""Ensure connection to Unity, reconnecting if needed."""
|
|
63
|
+
if unity is None:
|
|
64
|
+
raise ConnectionError("Server not initialized")
|
|
65
|
+
await unity.ensure_connected()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _try_rediscover(server: FastMCP):
|
|
69
|
+
"""Attempt to re-discover tools/resources/prompts from Unity if none are registered."""
|
|
70
|
+
if _registered_tools and _registered_resources and _registered_prompts:
|
|
71
|
+
return # Already discovered
|
|
72
|
+
try:
|
|
73
|
+
await _discover_and_register(server)
|
|
74
|
+
if _registered_tools:
|
|
75
|
+
logger.info(f"Re-discovery successful: {len(_registered_tools)} tools registered")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.debug(f"Re-discovery attempt failed: {e}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _forward_tool(tool_name: str, **kwargs) -> str:
|
|
81
|
+
"""Forward a tool call to Unity and return the result as JSON string."""
|
|
82
|
+
try:
|
|
83
|
+
await _ensure_connected()
|
|
84
|
+
# If no tools have been registered yet (Unity was offline at startup),
|
|
85
|
+
# attempt re-discovery now that we have a connection
|
|
86
|
+
if not _registered_tools:
|
|
87
|
+
await _try_rediscover(mcp)
|
|
88
|
+
# Filter out None values (optional params not provided by client)
|
|
89
|
+
filtered_args = {k: v for k, v in kwargs.items() if v is not None}
|
|
90
|
+
result = await unity.send_request("tools/call", {
|
|
91
|
+
"name": tool_name,
|
|
92
|
+
"arguments": filtered_args
|
|
93
|
+
})
|
|
94
|
+
if "error" in result:
|
|
95
|
+
error = result["error"]
|
|
96
|
+
msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
97
|
+
raise UnityToolError(msg)
|
|
98
|
+
# Unity returns MCP content structure: {"content":[{"type":"text","text":"..."}], "isError":...}
|
|
99
|
+
# Extract the inner text to avoid double-wrapping by FastMCP
|
|
100
|
+
mcp_result = result.get("result", {})
|
|
101
|
+
is_error = mcp_result.get("isError", False)
|
|
102
|
+
content_list = mcp_result.get("content", [])
|
|
103
|
+
if content_list and isinstance(content_list, list):
|
|
104
|
+
first = content_list[0]
|
|
105
|
+
if isinstance(first, dict) and first.get("type") == "text":
|
|
106
|
+
text = first.get("text", "")
|
|
107
|
+
if is_error:
|
|
108
|
+
raise UnityToolError(text)
|
|
109
|
+
return text
|
|
110
|
+
return json.dumps(mcp_result, indent=2)
|
|
111
|
+
except UnityToolError:
|
|
112
|
+
raise # Let FastMCP handle this as an MCP error response
|
|
113
|
+
except asyncio.TimeoutError:
|
|
114
|
+
logger.error(f"Tool call timeout: {tool_name}")
|
|
115
|
+
raise UnityToolError(f"Tool execution timeout: {tool_name}")
|
|
116
|
+
except ConnectionError as e:
|
|
117
|
+
logger.error(f"Tool connection error [{tool_name}]: {e}")
|
|
118
|
+
raise UnityToolError(f"Unity connection error: {e}")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Tool forwarding error [{tool_name}]: {e}")
|
|
121
|
+
raise UnityToolError(f"Tool forwarding error: {e}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _forward_resource(uri: str) -> str:
|
|
125
|
+
"""Forward a resource read to Unity and return the result as JSON string."""
|
|
126
|
+
try:
|
|
127
|
+
await _ensure_connected()
|
|
128
|
+
if not _registered_resources:
|
|
129
|
+
await _try_rediscover(mcp)
|
|
130
|
+
result = await unity.send_request("resources/read", {"uri": uri})
|
|
131
|
+
if "error" in result:
|
|
132
|
+
error = result["error"]
|
|
133
|
+
msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
134
|
+
raise UnityToolError(f"Resource error: {msg}")
|
|
135
|
+
# Unity returns MCP contents structure: {"contents":[{"uri":"...","mimeType":"...","text":"..."}]}
|
|
136
|
+
# Extract the inner text to avoid double-wrapping by FastMCP
|
|
137
|
+
mcp_result = result.get("result", {})
|
|
138
|
+
contents = mcp_result.get("contents", [])
|
|
139
|
+
if contents and isinstance(contents, list):
|
|
140
|
+
first = contents[0]
|
|
141
|
+
if isinstance(first, dict) and "text" in first:
|
|
142
|
+
return first["text"]
|
|
143
|
+
return json.dumps(mcp_result, indent=2)
|
|
144
|
+
except UnityToolError:
|
|
145
|
+
raise
|
|
146
|
+
except asyncio.TimeoutError:
|
|
147
|
+
logger.error(f"Resource read timeout: {uri}")
|
|
148
|
+
raise UnityToolError(f"Resource read timeout: {uri}")
|
|
149
|
+
except ConnectionError as e:
|
|
150
|
+
logger.error(f"Resource connection error [{uri}]: {e}")
|
|
151
|
+
raise UnityToolError(f"Unity connection error: {e}")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Resource forwarding error [{uri}]: {e}")
|
|
154
|
+
raise UnityToolError(f"Resource forwarding error: {e}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _discover_and_register(server: FastMCP):
|
|
158
|
+
"""Discover tools and resources from Unity and register them with FastMCP."""
|
|
159
|
+
try:
|
|
160
|
+
await _ensure_connected()
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.warning(f"Cannot connect to Unity for discovery: {e}")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Discover tools
|
|
166
|
+
try:
|
|
167
|
+
tools_response = await unity.send_request("tools/list", {})
|
|
168
|
+
tools = tools_response.get("result", {}).get("tools", [])
|
|
169
|
+
for tool_def in tools:
|
|
170
|
+
name = tool_def.get("name", "")
|
|
171
|
+
if not name or name in _registered_tools:
|
|
172
|
+
continue
|
|
173
|
+
_register_dynamic_tool(server, name, tool_def)
|
|
174
|
+
_registered_tools.add(name)
|
|
175
|
+
logger.info(f"Registered {len(_registered_tools)} tools from Unity")
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Failed to discover tools: {e}")
|
|
178
|
+
|
|
179
|
+
# Discover resources
|
|
180
|
+
try:
|
|
181
|
+
res_response = await unity.send_request("resources/list", {})
|
|
182
|
+
resources = res_response.get("result", {}).get("resources", [])
|
|
183
|
+
for res_def in resources:
|
|
184
|
+
uri = res_def.get("uri", "")
|
|
185
|
+
if not uri or uri in _registered_resources:
|
|
186
|
+
continue
|
|
187
|
+
_register_dynamic_resource(server, uri, res_def)
|
|
188
|
+
_registered_resources.add(uri)
|
|
189
|
+
logger.info(f"Registered {len(_registered_resources)} resources from Unity")
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Failed to discover resources: {e}")
|
|
192
|
+
|
|
193
|
+
# Discover prompts
|
|
194
|
+
try:
|
|
195
|
+
prompts_response = await unity.send_request("prompts/list", {})
|
|
196
|
+
prompts = prompts_response.get("result", {}).get("prompts", [])
|
|
197
|
+
for prompt_def in prompts:
|
|
198
|
+
name = prompt_def.get("name", "")
|
|
199
|
+
if not name or name in _registered_prompts:
|
|
200
|
+
continue
|
|
201
|
+
_register_dynamic_prompt(server, name, prompt_def)
|
|
202
|
+
_registered_prompts.add(name)
|
|
203
|
+
logger.info(f"Registered {len(_registered_prompts)} prompts from Unity")
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error(f"Failed to discover prompts: {e}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _register_dynamic_tool(server: FastMCP, name: str, tool_def: dict):
|
|
209
|
+
"""Register a single tool as a FastMCP tool that forwards to Unity."""
|
|
210
|
+
import inspect
|
|
211
|
+
from typing import Optional
|
|
212
|
+
|
|
213
|
+
description = tool_def.get("description", f"Unity tool: {name}")
|
|
214
|
+
schema = tool_def.get("inputSchema", {})
|
|
215
|
+
properties = schema.get("properties", {})
|
|
216
|
+
required_set = set(schema.get("required", []))
|
|
217
|
+
|
|
218
|
+
type_map = {
|
|
219
|
+
"string": str, "integer": int, "number": float,
|
|
220
|
+
"boolean": bool, "object": dict, "array": list,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Build inspect.Parameter list so FastMCP sees a proper signature.
|
|
224
|
+
# We also build a param_descriptions dict to preserve Unity's original
|
|
225
|
+
# parameter descriptions, which inspect.Signature cannot carry.
|
|
226
|
+
params = []
|
|
227
|
+
param_descriptions = {}
|
|
228
|
+
for param_name, param_def in properties.items():
|
|
229
|
+
py_type = type_map.get(param_def.get("type", "string"), str)
|
|
230
|
+
if param_name in required_set:
|
|
231
|
+
params.append(inspect.Parameter(
|
|
232
|
+
param_name,
|
|
233
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
234
|
+
annotation=py_type,
|
|
235
|
+
))
|
|
236
|
+
else:
|
|
237
|
+
params.append(inspect.Parameter(
|
|
238
|
+
param_name,
|
|
239
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
240
|
+
default=None,
|
|
241
|
+
annotation=Optional[py_type],
|
|
242
|
+
))
|
|
243
|
+
# Preserve original description from Unity's schema
|
|
244
|
+
if "description" in param_def:
|
|
245
|
+
param_descriptions[param_name] = param_def["description"]
|
|
246
|
+
|
|
247
|
+
# Create forwarding function with captured tool name
|
|
248
|
+
async def tool_handler(**kwargs) -> str:
|
|
249
|
+
return await _forward_tool(name, **kwargs)
|
|
250
|
+
|
|
251
|
+
tool_handler.__name__ = name
|
|
252
|
+
tool_handler.__qualname__ = name
|
|
253
|
+
tool_handler.__doc__ = description
|
|
254
|
+
tool_handler.__signature__ = inspect.Signature(params, return_annotation=str)
|
|
255
|
+
|
|
256
|
+
# Register with FastMCP
|
|
257
|
+
server.tool(name=name, description=description)(tool_handler)
|
|
258
|
+
|
|
259
|
+
# After registration, patch the tool's inputSchema to restore Unity's
|
|
260
|
+
# original parameter descriptions, enums, and constraints that
|
|
261
|
+
# inspect.Signature cannot carry.
|
|
262
|
+
try:
|
|
263
|
+
tool_manager = server._tool_manager
|
|
264
|
+
if hasattr(tool_manager, '_tools') and name in tool_manager._tools:
|
|
265
|
+
tool_obj = tool_manager._tools[name]
|
|
266
|
+
if hasattr(tool_obj, 'parameters') and tool_obj.parameters:
|
|
267
|
+
tool_schema = tool_obj.parameters
|
|
268
|
+
schema_props = tool_schema.get("properties", {})
|
|
269
|
+
for pname, pdef in properties.items():
|
|
270
|
+
if pname in schema_props:
|
|
271
|
+
# Restore description
|
|
272
|
+
if "description" in pdef:
|
|
273
|
+
schema_props[pname]["description"] = pdef["description"]
|
|
274
|
+
# Restore enum values
|
|
275
|
+
if "enum" in pdef:
|
|
276
|
+
schema_props[pname]["enum"] = pdef["enum"]
|
|
277
|
+
# Restore numeric constraints
|
|
278
|
+
for constraint in ("minimum", "maximum", "default"):
|
|
279
|
+
if constraint in pdef:
|
|
280
|
+
schema_props[pname][constraint] = pdef[constraint]
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.warning(f"Could not patch schema for tool '{name}': {e}. "
|
|
283
|
+
f"Parameter descriptions may be missing.")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _register_dynamic_resource(server: FastMCP, uri: str, res_def: dict):
|
|
287
|
+
"""Register a single resource as a FastMCP resource that forwards to Unity."""
|
|
288
|
+
description = res_def.get("description", f"Unity resource: {uri}")
|
|
289
|
+
res_name = res_def.get("name", uri)
|
|
290
|
+
|
|
291
|
+
# Handle parameterized URI templates (e.g. "unity://gameobject/{id}")
|
|
292
|
+
# FastMCP extracts template params and passes them as kwargs
|
|
293
|
+
async def resource_handler(_uri_template=uri, **kwargs) -> str:
|
|
294
|
+
actual_uri = _uri_template
|
|
295
|
+
for k, v in kwargs.items():
|
|
296
|
+
actual_uri = actual_uri.replace(f"{{{k}}}", str(v))
|
|
297
|
+
return await _forward_resource(actual_uri)
|
|
298
|
+
|
|
299
|
+
resource_handler.__name__ = res_name.replace("/", "_").replace(":", "_")
|
|
300
|
+
resource_handler.__doc__ = description
|
|
301
|
+
|
|
302
|
+
server.resource(uri)(resource_handler)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def _forward_prompt(name: str, arguments: dict | None = None) -> str:
|
|
306
|
+
"""Forward a prompt get to Unity and return the result."""
|
|
307
|
+
try:
|
|
308
|
+
await _ensure_connected()
|
|
309
|
+
result = await unity.send_request("prompts/get", {
|
|
310
|
+
"name": name,
|
|
311
|
+
"arguments": arguments or {}
|
|
312
|
+
})
|
|
313
|
+
if "error" in result:
|
|
314
|
+
error = result["error"]
|
|
315
|
+
msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
316
|
+
raise UnityToolError(f"Prompt error: {msg}")
|
|
317
|
+
# Unity returns: {"description":"...","messages":[{"role":"user","content":{"type":"text","text":"..."}}]}
|
|
318
|
+
mcp_result = result.get("result", {})
|
|
319
|
+
messages = mcp_result.get("messages", [])
|
|
320
|
+
if messages and isinstance(messages, list):
|
|
321
|
+
first = messages[0]
|
|
322
|
+
content = first.get("content", {})
|
|
323
|
+
if isinstance(content, dict) and content.get("type") == "text":
|
|
324
|
+
return content.get("text", "")
|
|
325
|
+
return json.dumps(mcp_result, indent=2)
|
|
326
|
+
except UnityToolError:
|
|
327
|
+
raise
|
|
328
|
+
except asyncio.TimeoutError:
|
|
329
|
+
logger.error(f"Prompt get timeout: {name}")
|
|
330
|
+
raise UnityToolError(f"Prompt execution timeout: {name}")
|
|
331
|
+
except ConnectionError as e:
|
|
332
|
+
logger.error(f"Prompt connection error [{name}]: {e}")
|
|
333
|
+
raise UnityToolError(f"Unity connection error: {e}")
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Prompt forwarding error [{name}]: {e}")
|
|
336
|
+
raise UnityToolError(f"Prompt forwarding error: {e}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _register_dynamic_prompt(server: FastMCP, name: str, prompt_def: dict):
|
|
340
|
+
"""Register a single prompt as a FastMCP prompt that forwards to Unity."""
|
|
341
|
+
import inspect
|
|
342
|
+
from typing import Optional
|
|
343
|
+
|
|
344
|
+
description = prompt_def.get("description", f"Unity prompt: {name}")
|
|
345
|
+
arguments = prompt_def.get("arguments", [])
|
|
346
|
+
|
|
347
|
+
# Build inspect.Parameter list from prompt arguments
|
|
348
|
+
params = []
|
|
349
|
+
for arg_def in arguments:
|
|
350
|
+
arg_name = arg_def.get("name", "")
|
|
351
|
+
if not arg_name:
|
|
352
|
+
continue
|
|
353
|
+
required = arg_def.get("required", False)
|
|
354
|
+
if required:
|
|
355
|
+
params.append(inspect.Parameter(
|
|
356
|
+
arg_name,
|
|
357
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
358
|
+
annotation=str,
|
|
359
|
+
))
|
|
360
|
+
else:
|
|
361
|
+
params.append(inspect.Parameter(
|
|
362
|
+
arg_name,
|
|
363
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
364
|
+
default=None,
|
|
365
|
+
annotation=Optional[str],
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
async def prompt_handler(**kwargs) -> str:
|
|
369
|
+
filtered = {k: v for k, v in kwargs.items() if v is not None}
|
|
370
|
+
return await _forward_prompt(name, filtered)
|
|
371
|
+
|
|
372
|
+
prompt_handler.__name__ = name
|
|
373
|
+
prompt_handler.__qualname__ = name
|
|
374
|
+
prompt_handler.__doc__ = description
|
|
375
|
+
prompt_handler.__signature__ = inspect.Signature(params, return_annotation=str)
|
|
376
|
+
|
|
377
|
+
server.prompt(name=name, description=description)(prompt_handler)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --- Python-side tools (no Unity connection needed) ---
|
|
381
|
+
|
|
382
|
+
@mcp.tool(name="analyze_script", description="Analyze a C# script for common issues and patterns (runs locally, no Unity needed)")
|
|
383
|
+
def analyze_script(file_path: str) -> str:
|
|
384
|
+
from .tools.script_analyzer import analyze_script as _analyze
|
|
385
|
+
return json.dumps(_analyze(file_path), indent=2)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@mcp.tool(name="validate_assets", description="Validate asset naming conventions and folder structure (runs locally, no Unity needed)")
|
|
389
|
+
def validate_assets(project_path: str) -> str:
|
|
390
|
+
from .tools.asset_validator import validate_assets as _validate
|
|
391
|
+
return json.dumps(_validate(project_path), indent=2)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def main():
|
|
395
|
+
"""Entry point for the Unity MCP Python server."""
|
|
396
|
+
mcp.run()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python-side enhanced tools for Unity MCP."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Asset naming and structure validation tool (Python-side)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Default naming conventions
|
|
8
|
+
NAMING_RULES = {
|
|
9
|
+
".cs": r"^[A-Z][a-zA-Z0-9]+\.cs$", # PascalCase
|
|
10
|
+
".prefab": r"^[A-Z][a-zA-Z0-9_]+\.prefab$", # PascalCase with underscores
|
|
11
|
+
".mat": r"^[A-Z][a-zA-Z0-9_]+\.mat$", # PascalCase with underscores
|
|
12
|
+
".asset": r"^[A-Z][a-zA-Z0-9_]+\.asset$",
|
|
13
|
+
".png": r"^[a-z][a-z0-9_]+\.png$", # snake_case for textures
|
|
14
|
+
".jpg": r"^[a-z][a-z0-9_]+\.jpg$",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Expected folder structure under Assets/
|
|
18
|
+
EXPECTED_FOLDERS = [
|
|
19
|
+
"Scripts", "Prefabs", "Materials", "Textures",
|
|
20
|
+
"Scenes", "Audio", "Animations", "Fonts",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_assets(project_path: str, custom_rules: Optional[dict] = None) -> dict:
|
|
25
|
+
"""Validate asset naming conventions and folder structure.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
project_path: Path to the Unity project root (contains Assets/)
|
|
29
|
+
custom_rules: Optional dict of {extension: regex_pattern} to override defaults
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Validation report with violations and suggestions.
|
|
33
|
+
"""
|
|
34
|
+
assets_path = os.path.join(project_path, "Assets")
|
|
35
|
+
if not os.path.isdir(assets_path):
|
|
36
|
+
return {"error": f"Assets folder not found at {assets_path}"}
|
|
37
|
+
|
|
38
|
+
rules = {**NAMING_RULES, **(custom_rules or {})}
|
|
39
|
+
violations = []
|
|
40
|
+
file_count = 0
|
|
41
|
+
folder_count = 0
|
|
42
|
+
|
|
43
|
+
for root, dirs, files in os.walk(assets_path):
|
|
44
|
+
folder_count += len(dirs)
|
|
45
|
+
for filename in files:
|
|
46
|
+
if filename.startswith("."):
|
|
47
|
+
continue
|
|
48
|
+
file_count += 1
|
|
49
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
50
|
+
if ext in rules:
|
|
51
|
+
pattern = rules[ext]
|
|
52
|
+
if not re.match(pattern, filename):
|
|
53
|
+
rel_path = os.path.relpath(
|
|
54
|
+
os.path.join(root, filename), assets_path
|
|
55
|
+
)
|
|
56
|
+
violations.append({
|
|
57
|
+
"file": rel_path,
|
|
58
|
+
"rule": f"Expected pattern: {pattern}",
|
|
59
|
+
"suggestion": f"Rename to match convention for {ext} files"
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
# Check expected folders
|
|
63
|
+
missing_folders = []
|
|
64
|
+
for folder in EXPECTED_FOLDERS:
|
|
65
|
+
if not os.path.isdir(os.path.join(assets_path, folder)):
|
|
66
|
+
missing_folders.append(folder)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"projectPath": project_path,
|
|
70
|
+
"stats": {
|
|
71
|
+
"totalFiles": file_count,
|
|
72
|
+
"totalFolders": folder_count,
|
|
73
|
+
},
|
|
74
|
+
"namingViolations": violations,
|
|
75
|
+
"violationCount": len(violations),
|
|
76
|
+
"missingFolders": missing_folders,
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""C# script analysis tool (Python-side, no Unity needed)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def analyze_script(file_path: str) -> dict:
|
|
7
|
+
"""Analyze a C# script for common issues and patterns.
|
|
8
|
+
|
|
9
|
+
Returns analysis results including:
|
|
10
|
+
- Class/method counts
|
|
11
|
+
- Potential issues (empty catch, magic numbers, etc.)
|
|
12
|
+
- Suggestions for improvement
|
|
13
|
+
"""
|
|
14
|
+
# Validate file extension
|
|
15
|
+
if not file_path.endswith(".cs"):
|
|
16
|
+
return {"error": "Only C# (.cs) files are supported"}
|
|
17
|
+
|
|
18
|
+
# Resolve to absolute path and check for path traversal
|
|
19
|
+
resolved = os.path.realpath(file_path)
|
|
20
|
+
if ".." in os.path.relpath(resolved, os.path.dirname(resolved)):
|
|
21
|
+
return {"error": "Invalid file path"}
|
|
22
|
+
|
|
23
|
+
if not os.path.isfile(resolved):
|
|
24
|
+
return {"error": f"File not found: {file_path}"}
|
|
25
|
+
|
|
26
|
+
with open(resolved, "r", encoding="utf-8") as f:
|
|
27
|
+
content = f.read()
|
|
28
|
+
|
|
29
|
+
lines = content.split("\n")
|
|
30
|
+
issues = []
|
|
31
|
+
|
|
32
|
+
# Check for empty catch blocks
|
|
33
|
+
for i, line in enumerate(lines):
|
|
34
|
+
if re.search(r"catch\s*\([^)]*\)\s*\{\s*\}", line):
|
|
35
|
+
issues.append({
|
|
36
|
+
"line": i + 1,
|
|
37
|
+
"type": "empty_catch",
|
|
38
|
+
"message": "Empty catch block - consider logging the exception"
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
# Check for Update() without null checks on referenced objects
|
|
42
|
+
class_count = len(re.findall(r"\bclass\s+\w+", content))
|
|
43
|
+
method_count = len(re.findall(r"(public|private|protected|internal)\s+\w+\s+\w+\s*\(", content))
|
|
44
|
+
using_count = len(re.findall(r"^using\s+", content, re.MULTILINE))
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"file": file_path,
|
|
48
|
+
"stats": {
|
|
49
|
+
"lines": len(lines),
|
|
50
|
+
"classes": class_count,
|
|
51
|
+
"methods": method_count,
|
|
52
|
+
"usings": using_count,
|
|
53
|
+
},
|
|
54
|
+
"issues": issues,
|
|
55
|
+
"issueCount": len(issues),
|
|
56
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Unity TCP connection manager using the custom frame protocol."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import struct
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from .config import UNITY_HOST, UNITY_PORT, REQUEST_TIMEOUT
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Frame protocol constants (must match C# TcpTransport)
|
|
11
|
+
MSG_TYPE_REQUEST = 0x01
|
|
12
|
+
MSG_TYPE_RESPONSE = 0x02
|
|
13
|
+
MSG_TYPE_NOTIFICATION = 0x03
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnityConnection:
|
|
17
|
+
"""Manages TCP connection to Unity Editor's MCP server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, host: str = None, port: int = None):
|
|
20
|
+
self.host = host or UNITY_HOST
|
|
21
|
+
self.port = port or UNITY_PORT
|
|
22
|
+
self._reader: asyncio.StreamReader | None = None
|
|
23
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
24
|
+
self._request_id = 0
|
|
25
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
26
|
+
self._lock = asyncio.Lock()
|
|
27
|
+
self._connected = False
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def connected(self) -> bool:
|
|
31
|
+
return self._connected
|
|
32
|
+
|
|
33
|
+
async def connect(self):
|
|
34
|
+
"""Connect to Unity Editor TCP server."""
|
|
35
|
+
try:
|
|
36
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
37
|
+
self.host, self.port
|
|
38
|
+
)
|
|
39
|
+
self._connected = True
|
|
40
|
+
asyncio.create_task(self._read_loop())
|
|
41
|
+
logger.info(f"Connected to Unity at {self.host}:{self.port}")
|
|
42
|
+
except ConnectionRefusedError:
|
|
43
|
+
logger.error(
|
|
44
|
+
f"Cannot connect to Unity at {self.host}:{self.port}. "
|
|
45
|
+
"Is Unity running with MCP enabled?"
|
|
46
|
+
)
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
async def disconnect(self):
|
|
50
|
+
"""Close the connection."""
|
|
51
|
+
self._connected = False
|
|
52
|
+
if self._writer:
|
|
53
|
+
self._writer.close()
|
|
54
|
+
await self._writer.wait_closed()
|
|
55
|
+
self._writer = None
|
|
56
|
+
self._reader = None
|
|
57
|
+
|
|
58
|
+
async def send_request(self, method: str, params: dict = None) -> dict:
|
|
59
|
+
"""Send a JSON-RPC request and wait for response."""
|
|
60
|
+
if not self._connected:
|
|
61
|
+
raise ConnectionError("Not connected to Unity")
|
|
62
|
+
|
|
63
|
+
async with self._lock:
|
|
64
|
+
self._request_id += 1
|
|
65
|
+
req_id = self._request_id
|
|
66
|
+
|
|
67
|
+
msg = json.dumps({
|
|
68
|
+
"jsonrpc": "2.0",
|
|
69
|
+
"id": req_id,
|
|
70
|
+
"method": method,
|
|
71
|
+
"params": params or {}
|
|
72
|
+
}).encode("utf-8")
|
|
73
|
+
|
|
74
|
+
# Frame: 4-byte length (BE) + 1-byte type (0x01=Request) + JSON payload
|
|
75
|
+
frame_len = 1 + len(msg)
|
|
76
|
+
self._writer.write(struct.pack(">IB", frame_len, MSG_TYPE_REQUEST) + msg)
|
|
77
|
+
await self._writer.drain()
|
|
78
|
+
|
|
79
|
+
future = asyncio.get_running_loop().create_future()
|
|
80
|
+
self._pending[req_id] = future
|
|
81
|
+
return await asyncio.wait_for(future, timeout=REQUEST_TIMEOUT)
|
|
82
|
+
|
|
83
|
+
async def _read_loop(self):
|
|
84
|
+
"""Read frames from Unity and resolve pending futures."""
|
|
85
|
+
try:
|
|
86
|
+
while self._connected:
|
|
87
|
+
# Read 4-byte length + 1-byte type
|
|
88
|
+
header = await self._reader.readexactly(5)
|
|
89
|
+
frame_len = struct.unpack(">I", header[:4])[0]
|
|
90
|
+
msg_type = header[4]
|
|
91
|
+
payload_len = frame_len - 1
|
|
92
|
+
|
|
93
|
+
if payload_len <= 0 or payload_len > 10 * 1024 * 1024:
|
|
94
|
+
logger.error(f"Invalid payload length: {payload_len}")
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
data = await self._reader.readexactly(payload_len)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
msg = json.loads(data.decode("utf-8"))
|
|
101
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
102
|
+
logger.error(f"Failed to decode message: {e}")
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
req_id = msg.get("id")
|
|
106
|
+
if req_id and req_id in self._pending:
|
|
107
|
+
self._pending.pop(req_id).set_result(msg)
|
|
108
|
+
else:
|
|
109
|
+
logger.debug(f"Received unmatched message: {msg}")
|
|
110
|
+
except asyncio.IncompleteReadError:
|
|
111
|
+
logger.info("Unity connection closed")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Read loop error: {e}")
|
|
114
|
+
finally:
|
|
115
|
+
self._connected = False
|
|
116
|
+
self._fail_pending("Connection closed")
|
|
117
|
+
|
|
118
|
+
def _fail_pending(self, reason: str):
|
|
119
|
+
"""Fail all pending requests when connection is lost."""
|
|
120
|
+
for future in self._pending.values():
|
|
121
|
+
if not future.done():
|
|
122
|
+
future.set_exception(ConnectionError(reason))
|
|
123
|
+
self._pending.clear()
|
|
124
|
+
|
|
125
|
+
async def ensure_connected(self):
|
|
126
|
+
"""Reconnect to Unity if disconnected, with exponential backoff."""
|
|
127
|
+
if self._connected:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
delays = [0, 500, 1000, 2000, 3000, 5000, 8000, 10000]
|
|
131
|
+
for attempt, delay in enumerate(delays):
|
|
132
|
+
if delay > 0:
|
|
133
|
+
logger.info(f"Reconnecting to Unity in {delay}ms (attempt {attempt + 1})")
|
|
134
|
+
await asyncio.sleep(delay / 1000)
|
|
135
|
+
try:
|
|
136
|
+
await self.connect()
|
|
137
|
+
return
|
|
138
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
139
|
+
logger.warning(f"Reconnect attempt {attempt + 1} failed: {e}")
|
|
140
|
+
|
|
141
|
+
raise ConnectionError(
|
|
142
|
+
f"Cannot reconnect to Unity at {self.host}:{self.port} after {len(delays)} attempts"
|
|
143
|
+
)
|