coplay-mcp-server 1.2.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.
Potentially problematic release.
This version of coplay-mcp-server might be problematic. Click here for more details.
- coplay_mcp_server/__init__.py +3 -0
- coplay_mcp_server/process_discovery.py +168 -0
- coplay_mcp_server/server.py +228 -0
- coplay_mcp_server/unity_client.py +224 -0
- coplay_mcp_server-1.2.1.dist-info/METADATA +100 -0
- coplay_mcp_server-1.2.1.dist-info/RECORD +9 -0
- coplay_mcp_server-1.2.1.dist-info/WHEEL +4 -0
- coplay_mcp_server-1.2.1.dist-info/entry_points.txt +2 -0
- coplay_mcp_server-1.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Unity process discovery utilities."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import platform
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_unity_processes() -> List[psutil.Process]:
|
|
14
|
+
"""Find all running Unity Editor processes."""
|
|
15
|
+
unity_processes = []
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
19
|
+
try:
|
|
20
|
+
proc_info = proc.info
|
|
21
|
+
if not proc_info['name']:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
name = proc_info['name'].lower()
|
|
25
|
+
|
|
26
|
+
# Check for Unity process names across platforms
|
|
27
|
+
if any(unity_name in name for unity_name in [
|
|
28
|
+
'unity', 'unity.exe', 'unity editor', 'unity hub'
|
|
29
|
+
]):
|
|
30
|
+
# Skip Unity Hub, we only want Editor instances
|
|
31
|
+
if 'hub' not in name:
|
|
32
|
+
unity_processes.append(proc)
|
|
33
|
+
|
|
34
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Error finding Unity processes: {e}")
|
|
39
|
+
|
|
40
|
+
return unity_processes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_project_path_from_cmdline(cmdline: List[str]) -> Optional[str]:
|
|
44
|
+
"""Extract Unity project path from command line arguments."""
|
|
45
|
+
if not cmdline:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Look for -projectPath argument
|
|
50
|
+
for i, arg in enumerate(cmdline):
|
|
51
|
+
if arg.lower() == "-projectpath" and i + 1 < len(cmdline):
|
|
52
|
+
project_path = cmdline[i + 1]
|
|
53
|
+
# Remove quotes if present
|
|
54
|
+
project_path = project_path.strip('"\'')
|
|
55
|
+
return project_path
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.debug(f"Error extracting project path from cmdline: {e}")
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def verify_unity_project(project_path: str) -> bool:
|
|
64
|
+
"""Verify that a path is a valid Unity project."""
|
|
65
|
+
try:
|
|
66
|
+
project_dir = Path(project_path)
|
|
67
|
+
if not project_dir.exists() or not project_dir.is_dir():
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Check for Assets folder (required for Unity projects)
|
|
71
|
+
assets_dir = project_dir / "Assets"
|
|
72
|
+
if not assets_dir.exists() or not assets_dir.is_dir():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.debug(f"Error verifying Unity project at {project_path}: {e}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _discover_from_temp_dirs() -> List[str]:
|
|
83
|
+
"""Discover Unity projects from MCP request directories in temp locations."""
|
|
84
|
+
project_roots = []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Common temp directory locations
|
|
88
|
+
temp_paths = []
|
|
89
|
+
|
|
90
|
+
if platform.system() == "Windows":
|
|
91
|
+
import tempfile
|
|
92
|
+
temp_paths.append(Path(tempfile.gettempdir()))
|
|
93
|
+
# Also check AppData/Local
|
|
94
|
+
try:
|
|
95
|
+
appdata_local = Path.home() / "AppData" / "Local"
|
|
96
|
+
if appdata_local.exists():
|
|
97
|
+
temp_paths.append(appdata_local)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
else:
|
|
101
|
+
# Unix-like systems
|
|
102
|
+
temp_paths.extend([
|
|
103
|
+
Path("/tmp"),
|
|
104
|
+
Path.home() / ".cache",
|
|
105
|
+
Path("/var/tmp")
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
for temp_path in temp_paths:
|
|
109
|
+
if not temp_path.exists():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# Look for Coplay MCP request directories
|
|
114
|
+
for path in temp_path.rglob("**/Temp/Coplay/MCPRequests"):
|
|
115
|
+
if path.is_dir():
|
|
116
|
+
# Try to infer project root from temp path structure
|
|
117
|
+
temp_index = None
|
|
118
|
+
parts = path.parts
|
|
119
|
+
|
|
120
|
+
for i, part in enumerate(parts):
|
|
121
|
+
if part.lower() == "temp":
|
|
122
|
+
temp_index = i
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if temp_index and temp_index > 0:
|
|
126
|
+
project_path = str(Path(*parts[:temp_index]))
|
|
127
|
+
if verify_unity_project(project_path):
|
|
128
|
+
project_roots.append(project_path)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.debug(f"Error searching temp path {temp_path}: {e}")
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.debug(f"Error discovering from temp directories: {e}")
|
|
135
|
+
|
|
136
|
+
return project_roots
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def discover_unity_project_roots() -> List[str]:
|
|
140
|
+
"""Discover all Unity project roots from running processes."""
|
|
141
|
+
project_roots = []
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
unity_processes = find_unity_processes()
|
|
145
|
+
logger.info(f"Found {len(unity_processes)} Unity processes")
|
|
146
|
+
|
|
147
|
+
for proc in unity_processes:
|
|
148
|
+
try:
|
|
149
|
+
cmdline = proc.cmdline()
|
|
150
|
+
project_path = extract_project_path_from_cmdline(cmdline)
|
|
151
|
+
|
|
152
|
+
if project_path and verify_unity_project(project_path):
|
|
153
|
+
if project_path not in project_roots:
|
|
154
|
+
project_roots.append(project_path)
|
|
155
|
+
logger.info(f"Found Unity project: {project_path}")
|
|
156
|
+
|
|
157
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
158
|
+
continue
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.debug(f"Error processing Unity process {proc.pid}: {e}")
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Error discovering Unity project roots: {e}")
|
|
164
|
+
|
|
165
|
+
# Also check for MCP request directories in temp locations
|
|
166
|
+
project_roots.extend(_discover_from_temp_dirs())
|
|
167
|
+
|
|
168
|
+
return list(set(project_roots)) # Remove duplicates
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Main entry point for Coplay MCP Server using FastMCP."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from .process_discovery import discover_unity_project_roots
|
|
9
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
10
|
+
from mcp import ServerSession
|
|
11
|
+
|
|
12
|
+
from .unity_client import UnityRpcClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_logging() -> None:
|
|
16
|
+
"""Set up logging configuration."""
|
|
17
|
+
|
|
18
|
+
# Configure logging
|
|
19
|
+
logging.basicConfig(
|
|
20
|
+
level=logging.INFO,
|
|
21
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
22
|
+
handlers=[
|
|
23
|
+
# Log errors to stderr (visible to MCP client)
|
|
24
|
+
logging.StreamHandler(sys.stderr),
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Set specific log levels for noisy libraries
|
|
29
|
+
logging.getLogger("watchdog").setLevel(logging.WARNING)
|
|
30
|
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
setup_logging()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Initialize FastMCP server
|
|
37
|
+
mcp = FastMCP(name="coplay-mcp-server")
|
|
38
|
+
|
|
39
|
+
# Global Unity client instance
|
|
40
|
+
unity_client = UnityRpcClient()
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
async def set_unity_project_root(
|
|
47
|
+
unity_project_root: str,
|
|
48
|
+
ctx: Context[ServerSession, None]
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Set the Unity project root path for the MCP server instance. This tool should be called before using any other Unity tools."""
|
|
51
|
+
try:
|
|
52
|
+
logger.info(f"Setting Unity project root to: {unity_project_root}")
|
|
53
|
+
|
|
54
|
+
if not unity_project_root or not unity_project_root.strip():
|
|
55
|
+
raise ValueError("Unity project root cannot be empty")
|
|
56
|
+
|
|
57
|
+
# Set the Unity project root in the RPC client
|
|
58
|
+
unity_client.set_unity_project_root(unity_project_root)
|
|
59
|
+
|
|
60
|
+
result = f"Unity project root set to: {unity_project_root}"
|
|
61
|
+
logger.info("Unity project root set successfully")
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Failed to set Unity project root: {e}")
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
async def list_unity_project_roots(ctx: Context[ServerSession, None]) -> Any:
|
|
71
|
+
"""List all project roots of currently open Unity instances. This tool discovers all running Unity Editor instances and returns their project root directories."""
|
|
72
|
+
try:
|
|
73
|
+
logger.info("Discovering Unity project roots...")
|
|
74
|
+
|
|
75
|
+
project_roots = discover_unity_project_roots()
|
|
76
|
+
return {
|
|
77
|
+
"count": len(project_roots),
|
|
78
|
+
"projectRoots": [
|
|
79
|
+
{
|
|
80
|
+
"projectRoot": root,
|
|
81
|
+
"projectName": root.split("/")[-1] if "/" in root else root.split("\\")[-1]
|
|
82
|
+
}
|
|
83
|
+
for root in project_roots
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Failed to list Unity project roots: {e}")
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
async def execute_script(
|
|
93
|
+
file_path: str,
|
|
94
|
+
method_name: str = "Execute",
|
|
95
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
96
|
+
ctx: Context[ServerSession, None] = None
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""Execute arbitrary C# code inside of the Unity Editor. The supplied code file must contain a class with a public static method that can be specified via method_name parameter (defaults to 'Execute'). This static method can return an object which will be serialized to JSON."""
|
|
99
|
+
try:
|
|
100
|
+
logger.info(f"Executing script: {file_path}, method: {method_name}")
|
|
101
|
+
|
|
102
|
+
params = {
|
|
103
|
+
"filePath": file_path,
|
|
104
|
+
"methodName": method_name
|
|
105
|
+
}
|
|
106
|
+
if arguments:
|
|
107
|
+
params["arguments"] = arguments
|
|
108
|
+
|
|
109
|
+
return await unity_client.execute_request("executeScript", params)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error(f"Failed to execute script: {e}")
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool()
|
|
116
|
+
async def get_unity_logs(
|
|
117
|
+
skip: int,
|
|
118
|
+
limit: int,
|
|
119
|
+
show_logs: bool = True,
|
|
120
|
+
show_warnings: bool = True,
|
|
121
|
+
show_errors: bool = True,
|
|
122
|
+
search_term: Optional[str] = None,
|
|
123
|
+
ctx: Context[ServerSession, None] = None
|
|
124
|
+
) -> Any:
|
|
125
|
+
"""Gets logs sorted from newest to oldest, applying filtering and pagination."""
|
|
126
|
+
try:
|
|
127
|
+
logger.debug(f"Getting Unity logs: skip={skip}, limit={limit}")
|
|
128
|
+
|
|
129
|
+
params = {
|
|
130
|
+
"skip": skip,
|
|
131
|
+
"limit": limit,
|
|
132
|
+
"showLogs": show_logs,
|
|
133
|
+
"showWarnings": show_warnings,
|
|
134
|
+
"showErrors": show_errors
|
|
135
|
+
}
|
|
136
|
+
if search_term:
|
|
137
|
+
params["searchTerm"] = search_term
|
|
138
|
+
|
|
139
|
+
return await unity_client.execute_request("getUnityLogs", params)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Failed to get Unity logs: {e}")
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool()
|
|
146
|
+
async def get_unity_editor_state(ctx: Context[ServerSession, None]) -> Any:
|
|
147
|
+
"""Retrieve the current state of the Unity Editor, excluding scene hierarchy."""
|
|
148
|
+
try:
|
|
149
|
+
logger.info("Getting Unity Editor state...")
|
|
150
|
+
|
|
151
|
+
result = await unity_client.execute_request("getUnityEditorState", {})
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"Failed to get Unity Editor state: {e}")
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@mcp.tool()
|
|
160
|
+
async def list_game_objects_in_hierarchy(
|
|
161
|
+
reference_object_path: Optional[str] = None,
|
|
162
|
+
name_filter: Optional[str] = None,
|
|
163
|
+
tag_filter: Optional[str] = None,
|
|
164
|
+
component_filter: Optional[str] = None,
|
|
165
|
+
include_inactive: Optional[bool] = None,
|
|
166
|
+
limit: Optional[int] = None,
|
|
167
|
+
skip: Optional[int] = None,
|
|
168
|
+
only_paths: Optional[bool] = None,
|
|
169
|
+
ctx: Context[ServerSession, None] = None
|
|
170
|
+
) -> Any:
|
|
171
|
+
"""List game objects in the hierarchy with optional filtering capabilities. Uses breadth-first traversal to prioritize objects closer to the root. Results are truncated if they exceed the limit, with a message indicating the truncation."""
|
|
172
|
+
try:
|
|
173
|
+
logger.debug("Listing game objects in hierarchy...")
|
|
174
|
+
|
|
175
|
+
params = {}
|
|
176
|
+
if reference_object_path is not None:
|
|
177
|
+
params["referenceObjectPath"] = reference_object_path
|
|
178
|
+
if name_filter is not None:
|
|
179
|
+
params["nameFilter"] = name_filter
|
|
180
|
+
if tag_filter is not None:
|
|
181
|
+
params["tagFilter"] = tag_filter
|
|
182
|
+
if component_filter is not None:
|
|
183
|
+
params["componentFilter"] = component_filter
|
|
184
|
+
if include_inactive is not None:
|
|
185
|
+
params["includeInactive"] = include_inactive
|
|
186
|
+
if limit is not None:
|
|
187
|
+
params["limit"] = limit
|
|
188
|
+
if skip is not None:
|
|
189
|
+
params["skip"] = skip
|
|
190
|
+
if only_paths is not None:
|
|
191
|
+
params["onlyPaths"] = only_paths
|
|
192
|
+
|
|
193
|
+
return await unity_client.execute_request("listGameObjectsInHierarchy", params)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"Failed to list game objects: {e}")
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@mcp.tool()
|
|
200
|
+
async def create_coplay_task(
|
|
201
|
+
prompt: str,
|
|
202
|
+
file_paths: Optional[str] = None,
|
|
203
|
+
model: Optional[str] = None,
|
|
204
|
+
ctx: Context[ServerSession, None] = None
|
|
205
|
+
) -> Any:
|
|
206
|
+
"""Creates a new task in the Unity Editor with the specified prompt and optional file attachments. This will start a new chat thread and submit the prompt for processing."""
|
|
207
|
+
try:
|
|
208
|
+
logger.info(f"Creating task with prompt: {prompt[:100]}...")
|
|
209
|
+
|
|
210
|
+
params = {"prompt": prompt}
|
|
211
|
+
if file_paths:
|
|
212
|
+
params["file_paths"] = file_paths
|
|
213
|
+
if model:
|
|
214
|
+
params["model"] = model
|
|
215
|
+
|
|
216
|
+
result = await unity_client.execute_request("createTask", params)
|
|
217
|
+
return result
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"Failed to create task: {e}")
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
mcp.run()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
main()
|
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnityRpcClient:
|
|
19
|
+
"""Client for communicating with Unity Editor via file-based RPC."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._unity_project_root: Optional[str] = None
|
|
23
|
+
self._pending_requests: Dict[str, concurrent.futures.Future[Any]] = {}
|
|
24
|
+
self._observer: Optional[Observer] = None
|
|
25
|
+
self._response_handler: Optional[ResponseFileHandler] = None
|
|
26
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
27
|
+
|
|
28
|
+
def set_unity_project_root(self, project_root: str) -> None:
|
|
29
|
+
"""Set the Unity project root and start watching for responses."""
|
|
30
|
+
if self._unity_project_root == project_root:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Stop existing watcher if any
|
|
34
|
+
self._stop_file_watcher()
|
|
35
|
+
|
|
36
|
+
self._unity_project_root = project_root
|
|
37
|
+
self._start_file_watcher()
|
|
38
|
+
logger.info(f"Unity project root set to: {project_root}")
|
|
39
|
+
|
|
40
|
+
def _start_file_watcher(self) -> None:
|
|
41
|
+
"""Start watching for response files."""
|
|
42
|
+
if not self._unity_project_root:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
requests_dir = Path(self._unity_project_root) / "Temp" / "Coplay" / "MCPRequests"
|
|
46
|
+
if not requests_dir.exists():
|
|
47
|
+
logger.warning(f"Unity requests directory does not exist: {requests_dir}")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Store the current event loop for thread-safe communication
|
|
51
|
+
try:
|
|
52
|
+
self._loop = asyncio.get_running_loop()
|
|
53
|
+
except RuntimeError:
|
|
54
|
+
logger.warning("No running event loop found, file watching may not work properly")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
self._response_handler = ResponseFileHandler(self._handle_response_file_sync, self._loop)
|
|
58
|
+
self._observer = Observer()
|
|
59
|
+
self._observer.schedule(
|
|
60
|
+
self._response_handler,
|
|
61
|
+
str(requests_dir),
|
|
62
|
+
recursive=False
|
|
63
|
+
)
|
|
64
|
+
self._observer.start()
|
|
65
|
+
logger.info(f"Started watching for responses in: {requests_dir}")
|
|
66
|
+
|
|
67
|
+
def _stop_file_watcher(self) -> None:
|
|
68
|
+
"""Stop watching for response files."""
|
|
69
|
+
if self._observer:
|
|
70
|
+
self._observer.stop()
|
|
71
|
+
self._observer.join()
|
|
72
|
+
self._observer = None
|
|
73
|
+
self._response_handler = None
|
|
74
|
+
|
|
75
|
+
def _handle_response_file_sync(self, file_path: Path) -> None:
|
|
76
|
+
"""Handle a response file from Unity (synchronous version for thread safety)."""
|
|
77
|
+
try:
|
|
78
|
+
logger.info(f"Handling file change: {file_path}")
|
|
79
|
+
|
|
80
|
+
if not file_path.name.startswith("response_") or not file_path.name.endswith(".json"):
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Read response file synchronously
|
|
84
|
+
try:
|
|
85
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
86
|
+
response_json = f.read()
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Failed to read response file {file_path}: {e}")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
response_data = json.loads(response_json)
|
|
92
|
+
request_id = response_data.get('id')
|
|
93
|
+
if request_id not in self._pending_requests:
|
|
94
|
+
logger.warning(f"No pending request found for ID: {request_id}")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
future = self._pending_requests.pop(request_id)
|
|
98
|
+
|
|
99
|
+
if "error" in response_data and response_data["error"]:
|
|
100
|
+
error_msg = response_data["error"]
|
|
101
|
+
if isinstance(error_msg, dict):
|
|
102
|
+
error_msg = error_msg.get("message", str(error_msg))
|
|
103
|
+
future.set_exception(Exception(str(error_msg)))
|
|
104
|
+
else:
|
|
105
|
+
future.set_result(response_data.get("result"))
|
|
106
|
+
|
|
107
|
+
# Clean up response file
|
|
108
|
+
try:
|
|
109
|
+
file_path.unlink()
|
|
110
|
+
logger.debug(f"Deleted response file: {file_path}")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.warning(f"Failed to delete response file {file_path}: {e}")
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.error(f"Error handling response file {file_path}: {e}")
|
|
116
|
+
|
|
117
|
+
async def execute_request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
params: Optional[Dict[str, Any]] = None,
|
|
121
|
+
timeout: float = 60.0
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Execute an RPC request to Unity Editor."""
|
|
124
|
+
if not self._unity_project_root:
|
|
125
|
+
raise RuntimeError("Unity project root is not set. Call set_unity_project_root first.")
|
|
126
|
+
|
|
127
|
+
requests_dir = Path(self._unity_project_root) / "Temp" / "Coplay" / "MCPRequests"
|
|
128
|
+
if not requests_dir.exists():
|
|
129
|
+
raise RuntimeError("Unity Editor is not running at the specified project root")
|
|
130
|
+
|
|
131
|
+
request_id = str(uuid.uuid4())
|
|
132
|
+
request_file = requests_dir / f"req_{request_id}.json"
|
|
133
|
+
|
|
134
|
+
# Create request data
|
|
135
|
+
request_data = {
|
|
136
|
+
"jsonrpc": "2.0",
|
|
137
|
+
"id": request_id,
|
|
138
|
+
"method": method,
|
|
139
|
+
"params": params or {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Create concurrent.futures.Future for thread-safe completion
|
|
143
|
+
future: concurrent.futures.Future[Any] = concurrent.futures.Future()
|
|
144
|
+
self._pending_requests[request_id] = future
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Write request file
|
|
148
|
+
async with aiofiles.open(request_file, 'w', encoding='utf-8') as f:
|
|
149
|
+
await f.write(json.dumps(request_data, indent=2))
|
|
150
|
+
|
|
151
|
+
logger.debug(f"Created request file: {request_file}")
|
|
152
|
+
|
|
153
|
+
# Wait for response with timeout using asyncio.wrap_future
|
|
154
|
+
try:
|
|
155
|
+
wrapped_future = asyncio.wrap_future(future)
|
|
156
|
+
result = await asyncio.wait_for(wrapped_future, timeout=timeout)
|
|
157
|
+
return result
|
|
158
|
+
except asyncio.TimeoutError:
|
|
159
|
+
raise TimeoutError(f"Request {method} timed out after {timeout} seconds")
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
# Clean up on error
|
|
163
|
+
self._pending_requests.pop(request_id, None)
|
|
164
|
+
try:
|
|
165
|
+
if request_file.exists():
|
|
166
|
+
request_file.unlink()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
raise e
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
"""Close the Unity RPC client and clean up resources."""
|
|
173
|
+
self._stop_file_watcher()
|
|
174
|
+
|
|
175
|
+
# Cancel any pending requests
|
|
176
|
+
for future in self._pending_requests.values():
|
|
177
|
+
if not future.done():
|
|
178
|
+
future.cancel()
|
|
179
|
+
self._pending_requests.clear()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ResponseFileHandler(FileSystemEventHandler):
|
|
183
|
+
"""File system event handler for Unity response files."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, callback, loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
|
|
186
|
+
super().__init__()
|
|
187
|
+
self._callback = callback
|
|
188
|
+
self._loop = loop
|
|
189
|
+
self._processed_files: set[str] = set()
|
|
190
|
+
self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
|
|
191
|
+
|
|
192
|
+
def on_created(self, event) -> None:
|
|
193
|
+
"""Handle file creation events."""
|
|
194
|
+
if event.is_directory:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
file_path = Path(event.src_path)
|
|
198
|
+
if file_path.name.startswith("response_") and file_path.name.endswith(".json"):
|
|
199
|
+
self._process_file_threadsafe(file_path)
|
|
200
|
+
|
|
201
|
+
def on_modified(self, event) -> None:
|
|
202
|
+
"""Handle file modification events."""
|
|
203
|
+
if event.is_directory:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
file_path = Path(event.src_path)
|
|
207
|
+
if file_path.name.startswith("response_") and file_path.name.endswith(".json"):
|
|
208
|
+
# Avoid processing the same file multiple times
|
|
209
|
+
if str(file_path) not in self._processed_files:
|
|
210
|
+
self._processed_files.add(str(file_path))
|
|
211
|
+
self._process_file_threadsafe(file_path)
|
|
212
|
+
|
|
213
|
+
def _process_file_threadsafe(self, file_path: Path) -> None:
|
|
214
|
+
"""Process a response file in a thread-safe manner."""
|
|
215
|
+
def process_with_delay():
|
|
216
|
+
# Small delay to ensure file is fully written
|
|
217
|
+
import time
|
|
218
|
+
time.sleep(0.1)
|
|
219
|
+
|
|
220
|
+
if file_path.exists():
|
|
221
|
+
self._callback(file_path)
|
|
222
|
+
|
|
223
|
+
# Submit to thread pool to avoid blocking the file watcher
|
|
224
|
+
self._executor.submit(process_with_delay)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coplay-mcp-server
|
|
3
|
+
Version: 1.2.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: mcp[cli]>=1.12.4
|
|
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
|
+
- **Unity Project Discovery**: Automatically discover running Unity Editor instances and their project roots
|
|
38
|
+
- **Unity Editor State**: Retrieve current Unity Editor state and scene hierarchy information
|
|
39
|
+
- **Script Execution**: Execute arbitrary C# scripts within the Unity Editor
|
|
40
|
+
- **Log Management**: Access and filter Unity console logs
|
|
41
|
+
- **GameObject Hierarchy**: List and filter GameObjects in the scene hierarchy
|
|
42
|
+
- **Task Creation**: Create new Coplay tasks directly from MCP clients
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### As an MCP server
|
|
47
|
+
|
|
48
|
+
Add to your MCP client configuration:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"coplay-testpypi": {
|
|
54
|
+
"autoApprove": [],
|
|
55
|
+
"disabled": false,
|
|
56
|
+
"timeout": 60,
|
|
57
|
+
"type": "stdio",
|
|
58
|
+
"command": "uvx",
|
|
59
|
+
"args": [
|
|
60
|
+
"coplay-mcp-server@latest"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Available Tools
|
|
68
|
+
|
|
69
|
+
- `set_unity_project_root` - Set the Unity project root path
|
|
70
|
+
- `list_unity_project_roots` - Discover all running Unity Editor instances
|
|
71
|
+
- `execute_script` - Execute C# scripts in Unity Editor
|
|
72
|
+
- `get_unity_logs` - Retrieve Unity console logs with filtering
|
|
73
|
+
- `get_unity_editor_state` - Get current Unity Editor state
|
|
74
|
+
- `list_game_objects_in_hierarchy` - List GameObjects with filtering options
|
|
75
|
+
- `create_coplay_task` - Create new Coplay tasks
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
To launch the server in development mode:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv run mcp dev coplay_mcp_server/server.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Prerequisites
|
|
86
|
+
|
|
87
|
+
- Python 3.11+
|
|
88
|
+
- [uv](https://docs.astral.sh/uv/) package manager
|
|
89
|
+
- Unity Editor with Coplay plugin installed
|
|
90
|
+
|
|
91
|
+
### Project Structure
|
|
92
|
+
|
|
93
|
+
- `coplay_mcp_server/server.py` - Main MCP server implementation
|
|
94
|
+
- `coplay_mcp_server/unity_client.py` - Unity RPC client for communication
|
|
95
|
+
- `coplay_mcp_server/process_discovery.py` - Process discovery utilities
|
|
96
|
+
- `pyproject.toml` - Project configuration and dependencies
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
coplay_mcp_server/__init__.py,sha256=h8oQ4XIoeJ86DSsfvAq_1luDAHU2nHr4L6er3wCPdN8,95
|
|
2
|
+
coplay_mcp_server/process_discovery.py,sha256=kcY7dlyNJUG3EjdDJKAzf_8RqqM2zgqpjK6ZJTqc4aU,6151
|
|
3
|
+
coplay_mcp_server/server.py,sha256=g4EcyUvO6C-ZPT1cblixbJ1sBHndt34oihRbQxuIFuk,7792
|
|
4
|
+
coplay_mcp_server/unity_client.py,sha256=bPzDv9-dsS0tXha-Q9BBedoW63L5GadFHJXQOrOXEZA,8622
|
|
5
|
+
coplay_mcp_server-1.2.1.dist-info/METADATA,sha256=_KJ98ZbPtNQ55naSVXxyx-VR-d13VlQwEMeMwORU1xw,3263
|
|
6
|
+
coplay_mcp_server-1.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
coplay_mcp_server-1.2.1.dist-info/entry_points.txt,sha256=FYOh7Ksh98Q-cp6pgvof0dLlgR8vyMl8VFeKhGVxgjI,68
|
|
8
|
+
coplay_mcp_server-1.2.1.dist-info/licenses/LICENSE,sha256=K3xG9ipglyxcG95EQnM3rAnOAbRQ7wsB3GsQAaIo6Ho,1088
|
|
9
|
+
coplay_mcp_server-1.2.1.dist-info/RECORD,,
|
|
@@ -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.
|