coplay-mcp-server 1.4.1__py3-none-any.whl

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,342 @@
1
+ """Unity RPC client for file-based communication with Unity Editor."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+ import concurrent.futures
10
+
11
+ import aiofiles
12
+ from watchdog.events import FileSystemEventHandler
13
+ from watchdog.observers import Observer
14
+
15
+ from mcp.server.fastmcp.utilities.types import Image
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class UnityProjectRootError(Exception):
21
+ """Base exception for Unity project root related errors."""
22
+ pass
23
+
24
+
25
+ class NoUnityInstancesError(UnityProjectRootError):
26
+ """Raised when no Unity Editor instances are found."""
27
+ pass
28
+
29
+
30
+ class MultipleUnityInstancesError(UnityProjectRootError):
31
+ """Raised when multiple Unity Editor instances are found and auto-detection cannot proceed."""
32
+
33
+ def __init__(self, message: str, project_roots: list[str]):
34
+ super().__init__(message)
35
+ self.project_roots = project_roots
36
+
37
+
38
+ def discover_unity_project_roots():
39
+ """Import and call the discover_unity_project_roots function."""
40
+ from coplay_mcp_server.process_discovery import discover_unity_project_roots as _discover
41
+ return _discover()
42
+
43
+
44
+ class UnityRpcClient:
45
+ """Client for communicating with Unity Editor via file-based RPC."""
46
+
47
+ def __init__(self) -> None:
48
+ self._unity_project_root: Optional[str] = None
49
+ self._pending_requests: Dict[str, concurrent.futures.Future[Any]] = {}
50
+ self._observer: Optional[Observer] = None
51
+ self._response_handler: Optional[ResponseFileHandler] = None
52
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
53
+
54
+ def set_unity_project_root(self, project_root: str) -> None:
55
+ """Set the Unity project root and start watching for responses."""
56
+ if self._unity_project_root == project_root:
57
+ return
58
+
59
+ # Stop existing watcher if any
60
+ self._stop_file_watcher()
61
+
62
+ self._unity_project_root = project_root
63
+ self._start_file_watcher()
64
+ logger.info(f"Unity project root set to: {project_root}")
65
+
66
+ def auto_detect_unity_project_root(self) -> bool:
67
+ """
68
+ Automatically detect and set Unity project root if exactly one Unity instance is running.
69
+
70
+ Returns:
71
+ bool: True if successfully auto-detected and set, False otherwise.
72
+ """
73
+ try:
74
+ project_roots = discover_unity_project_roots()
75
+
76
+ if len(project_roots) == 1:
77
+ # Exactly one Unity instance found - auto-set it
78
+ project_root = project_roots[0]
79
+ self.set_unity_project_root(project_root)
80
+ logger.info(f"Auto-detected Unity project root: {project_root}")
81
+ return True
82
+ elif len(project_roots) == 0:
83
+ logger.warning("No Unity instances found for auto-detection")
84
+ return False
85
+ else:
86
+ logger.warning(f"Multiple Unity instances found ({len(project_roots)}), cannot auto-detect. Available projects: {project_roots}")
87
+ return False
88
+
89
+ except Exception as e:
90
+ logger.error(f"Failed to auto-detect Unity project root: {e}")
91
+ return False
92
+
93
+ def _start_file_watcher(self) -> None:
94
+ """Start watching for response files."""
95
+ if not self._unity_project_root:
96
+ return
97
+
98
+ requests_dir = (
99
+ Path(self._unity_project_root) / "Temp" / "Coplay" / "MCPRequests"
100
+ )
101
+ if not requests_dir.exists():
102
+ logger.warning(f"Unity requests directory does not exist: {requests_dir}")
103
+ return
104
+
105
+ # Store the current event loop for thread-safe communication
106
+ try:
107
+ self._loop = asyncio.get_running_loop()
108
+ except RuntimeError:
109
+ logger.warning(
110
+ "No running event loop found, file watching may not work properly"
111
+ )
112
+ return
113
+
114
+ self._response_handler = ResponseFileHandler(
115
+ self._handle_response_file_sync, self._loop
116
+ )
117
+ self._observer = Observer()
118
+ self._observer.schedule(
119
+ self._response_handler, str(requests_dir), recursive=False
120
+ )
121
+ self._observer.start()
122
+ logger.info(f"Started watching for responses in: {requests_dir}")
123
+
124
+ def _stop_file_watcher(self) -> None:
125
+ """Stop watching for response files."""
126
+ if self._observer:
127
+ self._observer.stop()
128
+ self._observer.join()
129
+ self._observer = None
130
+ self._response_handler = None
131
+
132
+ def _handle_response_file_sync(self, file_path: Path) -> None:
133
+ """Handle a response file from Unity (synchronous version for thread safety)."""
134
+ try:
135
+ logger.info(f"Handling file change: {file_path}")
136
+
137
+ if not file_path.name.startswith(
138
+ "response_"
139
+ ) or not file_path.name.endswith(".json"):
140
+ return
141
+
142
+ # Read response file synchronously
143
+ try:
144
+ with open(file_path, "r", encoding="utf-8") as f:
145
+ response_json = f.read()
146
+ except Exception as e:
147
+ logger.error(f"Failed to read response file {file_path}: {e}")
148
+ return
149
+
150
+ response_data = json.loads(response_json)
151
+ request_id = response_data.get("id")
152
+ if request_id not in self._pending_requests:
153
+ logger.warning(f"No pending request found for ID: {request_id}")
154
+ return
155
+
156
+ future = self._pending_requests.pop(request_id)
157
+
158
+ if "error" in response_data and response_data["error"]:
159
+ error_msg = response_data["error"]
160
+ if isinstance(error_msg, dict):
161
+ error_msg = error_msg.get("message", str(error_msg))
162
+ future.set_exception(Exception(str(error_msg)))
163
+ else:
164
+ future.set_result(response_data.get("result"))
165
+
166
+ # Clean up response file
167
+ try:
168
+ file_path.unlink()
169
+ logger.debug(f"Deleted response file: {file_path}")
170
+ except Exception as e:
171
+ logger.warning(f"Failed to delete response file {file_path}: {e}")
172
+
173
+ except Exception as e:
174
+ logger.error(f"Error handling response file {file_path}: {e}")
175
+
176
+ def _is_image_function(self, method: str) -> bool:
177
+ """Check if this is a function that returns images."""
178
+ image_functions = {
179
+ "get_scene_view_screenshot",
180
+ "capture_ui_canvas",
181
+ }
182
+ return method in image_functions
183
+
184
+ def _process_image_response(self, response: Any) -> Any:
185
+ """Convert ImagePath to FastMCP Image object for proper MCP compatibility."""
186
+ if isinstance(response, dict) and "ImagePath" in response:
187
+ image_path = response["ImagePath"]
188
+ if image_path and isinstance(image_path, str):
189
+ logger.debug(f"Processing image response with path: {image_path}")
190
+ try:
191
+ # Create FastMCP Image object from the file path
192
+ return Image(path=image_path)
193
+ except Exception as e:
194
+ logger.warning(
195
+ f"Failed to create FastMCP Image from path {image_path}: {e}"
196
+ )
197
+ return response
198
+
199
+ async def execute_request(
200
+ self,
201
+ method: str,
202
+ params: Optional[Dict[str, Any]] = None,
203
+ timeout: float = 60.0,
204
+ ) -> Any:
205
+ """Execute an RPC request to Unity Editor."""
206
+ if not self._unity_project_root:
207
+ # Try to auto-detect Unity project root
208
+ if not self.auto_detect_unity_project_root():
209
+ # Auto-detection failed, provide helpful error message
210
+ try:
211
+ project_roots = discover_unity_project_roots()
212
+ if len(project_roots) == 0:
213
+ raise NoUnityInstancesError(
214
+ "No Unity Editor instances found. Please start Unity Editor and call set_unity_project_root with the project path."
215
+ )
216
+ else:
217
+ project_list = "\n".join([f" - {root}" for root in project_roots])
218
+ raise MultipleUnityInstancesError(
219
+ f"Multiple Unity Editor instances found. Please call set_unity_project_root with one of these project paths:\n{project_list}",
220
+ project_roots
221
+ )
222
+ except UnityProjectRootError:
223
+ # Re-raise our custom exceptions as-is
224
+ raise
225
+ except Exception as e:
226
+ raise RuntimeError(
227
+ "Unity project root is not set and auto-detection failed. Call set_unity_project_root first."
228
+ ) from e
229
+
230
+ requests_dir = (
231
+ Path(self._unity_project_root) / "Temp" / "Coplay" / "MCPRequests"
232
+ )
233
+ if not requests_dir.exists():
234
+ raise RuntimeError(
235
+ "Unity Editor is not running at the specified project root"
236
+ )
237
+
238
+ request_id = str(uuid.uuid4())
239
+ request_file = requests_dir / f"req_{request_id}.json"
240
+
241
+ # Create request data
242
+ request_data = {
243
+ "jsonrpc": "2.0",
244
+ "id": request_id,
245
+ "method": method,
246
+ "params": params or {},
247
+ }
248
+
249
+ # Create concurrent.futures.Future for thread-safe completion
250
+ future: concurrent.futures.Future[Any] = concurrent.futures.Future()
251
+ self._pending_requests[request_id] = future
252
+
253
+ try:
254
+ # Write request file
255
+ async with aiofiles.open(request_file, "w", encoding="utf-8") as f:
256
+ await f.write(json.dumps(request_data, indent=2))
257
+
258
+ logger.debug(f"Created request file: {request_file}")
259
+
260
+ # Wait for response with timeout using asyncio.wrap_future
261
+ try:
262
+ wrapped_future = asyncio.wrap_future(future)
263
+ result = await asyncio.wait_for(wrapped_future, timeout=timeout)
264
+
265
+ # Post-process response for image functions
266
+ if self._is_image_function(method):
267
+ result = self._process_image_response(result)
268
+
269
+ return result
270
+ except asyncio.TimeoutError:
271
+ raise TimeoutError(
272
+ f"Request {method} timed out after {timeout} seconds"
273
+ )
274
+
275
+ except Exception as e:
276
+ # Clean up on error
277
+ self._pending_requests.pop(request_id, None)
278
+ try:
279
+ if request_file.exists():
280
+ request_file.unlink()
281
+ except Exception:
282
+ pass
283
+ raise e
284
+
285
+ def close(self) -> None:
286
+ """Close the Unity RPC client and clean up resources."""
287
+ self._stop_file_watcher()
288
+
289
+ # Cancel any pending requests
290
+ for future in self._pending_requests.values():
291
+ if not future.done():
292
+ future.cancel()
293
+ self._pending_requests.clear()
294
+
295
+
296
+ class ResponseFileHandler(FileSystemEventHandler):
297
+ """File system event handler for Unity response files."""
298
+
299
+ def __init__(
300
+ self, callback, loop: Optional[asyncio.AbstractEventLoop] = None
301
+ ) -> None:
302
+ super().__init__()
303
+ self._callback = callback
304
+ self._loop = loop
305
+ self._processed_files: set[str] = set()
306
+ self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
307
+
308
+ def on_created(self, event) -> None:
309
+ """Handle file creation events."""
310
+ if event.is_directory:
311
+ return
312
+
313
+ file_path = Path(event.src_path)
314
+ if file_path.name.startswith("response_") and file_path.name.endswith(".json"):
315
+ self._process_file_threadsafe(file_path)
316
+
317
+ def on_modified(self, event) -> None:
318
+ """Handle file modification events."""
319
+ if event.is_directory:
320
+ return
321
+
322
+ file_path = Path(event.src_path)
323
+ if file_path.name.startswith("response_") and file_path.name.endswith(".json"):
324
+ # Avoid processing the same file multiple times
325
+ if str(file_path) not in self._processed_files:
326
+ self._processed_files.add(str(file_path))
327
+ self._process_file_threadsafe(file_path)
328
+
329
+ def _process_file_threadsafe(self, file_path: Path) -> None:
330
+ """Process a response file in a thread-safe manner."""
331
+
332
+ def process_with_delay():
333
+ # Small delay to ensure file is fully written
334
+ import time
335
+
336
+ time.sleep(0.1)
337
+
338
+ if file_path.exists():
339
+ self._callback(file_path)
340
+
341
+ # Submit to thread pool to avoid blocking the file watcher
342
+ self._executor.submit(process_with_delay)
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: coplay-mcp-server
3
+ Version: 1.4.1
4
+ Summary: A Model Context Protocol (MCP) server for Coplay, providing Unity integration capabilities
5
+ Project-URL: Homepage, https://coplay.dev/
6
+ Project-URL: Repository, https://github.com/CoplayDev/coplay-unity-plugin
7
+ Project-URL: Documentation, https://docs.coplay.dev/
8
+ Project-URL: Issues, https://github.com/CoplayDev/coplay-unity-plugin/issues
9
+ Author-email: Coplay Dev <dev@coplay.com>
10
+ Maintainer-email: Coplay Dev <dev@coplay.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: coplay,mcp,model-context-protocol,unity,unity-editor
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Topic :: Games/Entertainment
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: aiofiles>=24.1.0
24
+ Requires-Dist: anyio>=4.10.0
25
+ Requires-Dist: fastmcp>=2.0.0
26
+ Requires-Dist: psutil>=7.0.0
27
+ Requires-Dist: pydantic>=2.11.7
28
+ Requires-Dist: watchdog>=6.0.0
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Coplay MCP Server
32
+
33
+ A Model Context Protocol (MCP) server for Coplay, providing Unity Editor integration capabilities through MCP tools.
34
+
35
+ ## Features
36
+
37
+ - **Schema-Based Tool Registration**: Dynamically registers tools from JSON schema files, ensuring compatibility with Backend's AssistantMode.NORMAL
38
+ - **Unity Project Discovery**: Automatically discover running Unity Editor instances and their project roots
39
+ - **Unity Editor State**: Retrieve current Unity Editor state and scene hierarchy information
40
+ - **Script Execution**: Execute arbitrary C# scripts within the Unity Editor
41
+ - **Log Management**: Access and filter Unity console logs
42
+ - **GameObject Hierarchy**: List and filter GameObjects in the scene hierarchy
43
+ - **Task Creation**: Create new Coplay tasks directly from MCP clients
44
+ - **Parameter Validation**: Automatic parameter validation and type conversion based on tool schemas
45
+ - **Version Compatibility**: Tools are locked to specific schema versions, ensuring compatibility with Unity plugin versions
46
+
47
+ ## Usage
48
+
49
+ ### As an MCP server
50
+
51
+ Add to your MCP client configuration:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "coplay-mcp": {
57
+ "autoApprove": [],
58
+ "disabled": false,
59
+ "timeout": 60,
60
+ "type": "stdio",
61
+ "command": "uvx",
62
+ "args": [
63
+ "coplay-mcp-server@latest"
64
+ ]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+
@@ -0,0 +1,24 @@
1
+ coplay_mcp_server/__init__.py,sha256=S7pbUyOcTUgBTjPhI-Nll37HXJaqgLcjf0rhlb1BrnE,92
2
+ coplay_mcp_server/code_generator.py,sha256=eZhnehQFsE-kMkPVESyzKDLOuTmdnTyX_ZTSljmIj9M,13070
3
+ coplay_mcp_server/image_utils.py,sha256=0IGwazKwkhS95S1LsFweuFOgXH0uHlZtlfqt0547Xbo,2800
4
+ coplay_mcp_server/process_discovery.py,sha256=RnmVPNfYZsJoKGpaZYpxJLbco24_LZlr-oM-zQ3lXkg,5983
5
+ coplay_mcp_server/server.py,sha256=lHG85J55bPu8Cdx_BojHvCdpi2udTB_UflOOpdFWz3M,7626
6
+ coplay_mcp_server/unity_client.py,sha256=apjP7-icQFuMP8vNzAZdMFu-HNJbSNT0vXTthwX-vus,12959
7
+ coplay_mcp_server/generated_tools/.gitignore,sha256=TlBJtSSzMbAK1WY8wWpckwcbQjWH1JpsI6-5dJ8ve_g,103
8
+ coplay_mcp_server/generated_tools/__init__.py,sha256=WzV0HQqurRGPjGJi1bUboLIYafTd_QFqegcM-iL557k,159
9
+ coplay_mcp_server/generated_tools/agent_tool_tools.py,sha256=coWVRszqrk2BrmjXhWsnYmwCMBvAEACUjhBGM6S7GP8,13161
10
+ coplay_mcp_server/generated_tools/coplay_tool_tools.py,sha256=REyx3cLHJ1MzElhmM5JEsFZZRD0nV8dH1D7vDE9ADNM,2151
11
+ coplay_mcp_server/generated_tools/image_tool_tools.py,sha256=WGbKcTqejJxPSJvKa3GzlGPsFnjrrBHF0HifmfFnDQU,6079
12
+ coplay_mcp_server/generated_tools/input_action_tool_tools.py,sha256=zuDJjnVGqYSOLErZUMj0d3QM1-nQXLEomk7s3iOF4eU,22979
13
+ coplay_mcp_server/generated_tools/package_tool_tools.py,sha256=7-rbM3yLgjbomHhSwLapPqWWhJJG62N7E3_Oc7n8PHA,7865
14
+ coplay_mcp_server/generated_tools/profiler_functions_tools.py,sha256=lXW8-ms8VKcsRlkOmRfjmFouJvGAT9b57D8Y4eOhW48,2250
15
+ coplay_mcp_server/generated_tools/scene_view_functions_tools.py,sha256=QOU0-BuU_Ey-rvuT-OspmDZywFnKA0P51LYAp4hFJVI,2103
16
+ coplay_mcp_server/generated_tools/screenshot_tool_tools.py,sha256=IOc1pPTJheG40JqK-0AmGVdYTsMUNnoRSRSc_Fn9qA8,3582
17
+ coplay_mcp_server/generated_tools/snapping_functions_tools.py,sha256=7iAz5C_CEQb5VON9TlbKK8NaKPNxgDA8LDndUrE4RhM,14956
18
+ coplay_mcp_server/generated_tools/ui_functions_tools.py,sha256=wTEvi0xc2chk9Ta3hyWCwRyalksDpXaWIFuYzpigVPo,14454
19
+ coplay_mcp_server/generated_tools/unity_functions_tools.py,sha256=QkQswrZQxDrnyLO-d4QMmGY6i8bY08C_P3Zd1Lipef8,59290
20
+ coplay_mcp_server-1.4.1.dist-info/METADATA,sha256=6PtqXCH-wEaADDpmV9eu9CUTOZi7BZ4KqEyOTcerWT0,2571
21
+ coplay_mcp_server-1.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
22
+ coplay_mcp_server-1.4.1.dist-info/entry_points.txt,sha256=SKRD69ycL3CtXfaBghc8QIicPFCvO5XzqDyUSn25Eqs,130
23
+ coplay_mcp_server-1.4.1.dist-info/licenses/LICENSE,sha256=JOvqcPTq0pJwgFmq0GSYUZOvZu4rPfmiRG20bGTlx6o,1067
24
+ coplay_mcp_server-1.4.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ coplay-generate-tools = coplay_mcp_server.code_generator:main
3
+ coplay-mcp-server = coplay_mcp_server.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Coplay Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.