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.
@@ -0,0 +1,5 @@
1
+ __pycache__
2
+ *.pyc
3
+ .venv
4
+ .git
5
+ *.egg-info
@@ -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,2 @@
1
+ """Unity MCP Server - Python FastMCP bridge to Unity Editor."""
2
+ __version__ = "1.0.0"
@@ -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
+ )