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,96 @@
1
+ """Utility functions for image processing in MCP server."""
2
+
3
+ import base64
4
+ import logging
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def convert_image_to_base64_url(image_path: str) -> Optional[str]:
13
+ """Convert local image path to base64 data URL for MCP compatibility.
14
+
15
+ Args:
16
+ image_path: Local file path to the image
17
+
18
+ Returns:
19
+ Base64 data URL string (e.g., 'data:image/jpeg;base64,/9j/4AAQ...') or None if conversion fails
20
+ """
21
+ try:
22
+ path = Path(image_path)
23
+ if not path.exists():
24
+ logger.warning(f"Image file not found: {image_path}")
25
+ return None
26
+
27
+ if not path.is_file():
28
+ logger.warning(f"Path is not a file: {image_path}")
29
+ return None
30
+
31
+ # Determine MIME type from file extension
32
+ mime_type, _ = mimetypes.guess_type(str(path))
33
+ if not mime_type or not mime_type.startswith("image/"):
34
+ # Default to JPEG for unknown image types
35
+ mime_type = "image/jpeg"
36
+ logger.debug(
37
+ f"Unknown MIME type for {image_path}, defaulting to {mime_type}"
38
+ )
39
+
40
+ # Read and encode image to base64
41
+ with open(path, "rb") as f:
42
+ image_data = base64.b64encode(f.read()).decode("utf-8")
43
+
44
+ data_url = f"data:{mime_type};base64,{image_data}"
45
+ logger.debug(
46
+ f"Successfully converted {image_path} to base64 data URL ({len(data_url)} chars)"
47
+ )
48
+ return data_url
49
+
50
+ except Exception as e:
51
+ logger.error(f"Failed to convert image to base64: {e}")
52
+ return None
53
+
54
+
55
+ def is_image_file(file_path: str) -> bool:
56
+ """Check if a file path points to an image file.
57
+
58
+ Args:
59
+ file_path: Path to check
60
+
61
+ Returns:
62
+ True if the file appears to be an image based on extension
63
+ """
64
+ try:
65
+ mime_type, _ = mimetypes.guess_type(file_path)
66
+ return mime_type is not None and mime_type.startswith("image/")
67
+ except Exception:
68
+ return False
69
+
70
+
71
+ def get_image_info(image_path: str) -> Optional[dict]:
72
+ """Get basic information about an image file.
73
+
74
+ Args:
75
+ image_path: Path to the image file
76
+
77
+ Returns:
78
+ Dictionary with image info or None if file cannot be read
79
+ """
80
+ try:
81
+ path = Path(image_path)
82
+ if not path.exists():
83
+ return None
84
+
85
+ stat = path.stat()
86
+ mime_type, _ = mimetypes.guess_type(str(path))
87
+
88
+ return {
89
+ "path": str(path),
90
+ "size_bytes": stat.st_size,
91
+ "mime_type": mime_type,
92
+ "is_image": is_image_file(str(path)),
93
+ }
94
+ except Exception as e:
95
+ logger.error(f"Failed to get image info for {image_path}: {e}")
96
+ return None
@@ -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,236 @@
1
+ """Main entry point for Coplay MCP Server using FastMCP."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ from typing import Any, Optional, Annotated
8
+
9
+ from pydantic import Field
10
+ from coplay_mcp_server.process_discovery import discover_unity_project_roots
11
+ from fastmcp import FastMCP
12
+
13
+ from coplay_mcp_server.unity_client import UnityRpcClient
14
+ from coplay_mcp_server.generated_tools import (
15
+ unity_functions_tools,
16
+ image_tool_tools,
17
+ coplay_tool_tools,
18
+ agent_tool_tools,
19
+ package_tool_tools,
20
+ input_action_tool_tools,
21
+ ui_functions_tools,
22
+ snapping_functions_tools,
23
+ scene_view_functions_tools,
24
+ profiler_functions_tools,
25
+ screenshot_tool_tools,
26
+ )
27
+
28
+ # Set binary mode for stdin/stdout/stderr on Windows
29
+ # This is necessary for proper MCP protocol communication on Windows
30
+ if sys.platform == 'win32':
31
+ import msvcrt
32
+ msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
33
+ msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
34
+ msvcrt.setmode(sys.stderr.fileno(), os.O_BINARY)
35
+
36
+
37
+ def setup_logging() -> None:
38
+ """Set up logging configuration with support for file logging and configurable log level via environment variables.
39
+
40
+ Environment variables:
41
+ - COPLAY_LOG_FILE: Path to log file (if set, logs will be written to this file)
42
+ - COPLAY_LOG_LEVEL: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Defaults to WARNING.
43
+ """
44
+
45
+ # Get log level from environment variable, default to WARNING
46
+ log_level_str = os.getenv("COPLAY_LOG_LEVEL", "WARNING").upper()
47
+ log_level = getattr(logging, log_level_str, logging.WARNING)
48
+
49
+ # Get log file path from environment variable
50
+ log_file = os.getenv("COPLAY_LOG_FILE")
51
+
52
+ # Create handlers list
53
+ handlers = [
54
+ # Log to stderr (visible to MCP client)
55
+ logging.StreamHandler(sys.stderr),
56
+ ]
57
+
58
+ # Add file handler if log file is specified
59
+ if log_file:
60
+ try:
61
+ # Create directory if it doesn't exist
62
+ log_dir = os.path.dirname(log_file)
63
+ if log_dir and not os.path.exists(log_dir):
64
+ os.makedirs(log_dir, exist_ok=True)
65
+
66
+ file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8')
67
+ file_handler.setFormatter(
68
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
69
+ )
70
+ handlers.append(file_handler)
71
+
72
+ # Log to stderr that file logging is enabled
73
+ sys.stderr.write(f"File logging enabled: {log_file}\n")
74
+ except Exception as e:
75
+ sys.stderr.write(f"Failed to set up file logging to {log_file}: {e}\n")
76
+
77
+ # Configure logging
78
+ logging.basicConfig(
79
+ level=log_level,
80
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
81
+ handlers=handlers,
82
+ )
83
+
84
+ # Set specific log levels for noisy libraries
85
+ logging.getLogger("watchdog").setLevel(logging.WARNING)
86
+ logging.getLogger("asyncio").setLevel(logging.WARNING)
87
+
88
+
89
+ setup_logging()
90
+
91
+
92
+ # Initialize FastMCP server
93
+ mcp = FastMCP(name="coplay-mcp-server")
94
+
95
+ # Global Unity client instance
96
+ unity_client = UnityRpcClient()
97
+
98
+ logger = logging.getLogger(__name__)
99
+
100
+
101
+ @mcp.tool()
102
+ async def set_unity_project_root(
103
+ unity_project_root: str
104
+ ) -> str:
105
+ """Set the Unity project root path for the MCP server instance. This tool should be called before using any other Unity tools."""
106
+ try:
107
+ logger.info(f"Setting Unity project root to: {unity_project_root}")
108
+
109
+ if not unity_project_root or not unity_project_root.strip():
110
+ raise ValueError("Unity project root cannot be empty")
111
+
112
+ # Set the Unity project root in the RPC client
113
+ unity_client.set_unity_project_root(unity_project_root)
114
+
115
+ result = f"Unity project root set to: {unity_project_root}"
116
+ logger.info("Unity project root set successfully")
117
+ return result
118
+
119
+ except Exception as e:
120
+ logger.error(f"Failed to set Unity project root: {e}")
121
+ raise
122
+
123
+
124
+ @mcp.tool()
125
+ async def list_unity_project_roots() -> Any:
126
+ """List all project roots of currently open Unity instances. This tool discovers all running Unity Editor instances and returns their project root directories."""
127
+ try:
128
+ logger.info("Discovering Unity project roots...")
129
+
130
+ project_roots = discover_unity_project_roots()
131
+ return {
132
+ "count": len(project_roots),
133
+ "projectRoots": [
134
+ {
135
+ "projectRoot": root,
136
+ "projectName": root.split("/")[-1]
137
+ if "/" in root
138
+ else root.split("\\")[-1],
139
+ }
140
+ for root in project_roots
141
+ ],
142
+ }
143
+ except Exception as e:
144
+ logger.error(f"Failed to list Unity project roots: {e}")
145
+ raise
146
+
147
+
148
+ @mcp.tool()
149
+ async def create_coplay_task(
150
+ prompt: Annotated[
151
+ str,
152
+ Field(description="The task prompt to submit"),
153
+ ],
154
+ file_paths: Annotated[
155
+ Optional[str],
156
+ Field(description="Optional comma-separated file paths to attach as context"),
157
+ ] = None,
158
+ model: Annotated[
159
+ Optional[str],
160
+ Field(description="Optional AI model to use for this task"),
161
+ ] = None,
162
+ ) -> Any:
163
+ """Creates a new task in the Unity Editor with the specified prompt and optional file attachments.
164
+
165
+ Args:
166
+ prompt: The task prompt to submit
167
+ file_paths: Optional comma-separated file paths to attach as context
168
+ model: Optional AI model to use for this task
169
+ """
170
+ try:
171
+ logger.info(f"Creating task with prompt: {prompt[:10000]}...")
172
+
173
+ params = {"prompt": prompt}
174
+ if file_paths:
175
+ params["file_paths"] = file_paths
176
+ if model:
177
+ params["model"] = model
178
+
179
+ # Always wait for completion
180
+ params["wait_for_completion"] = "true"
181
+
182
+ # Use a longer timeout (610 seconds) to accommodate Unity's default 600-second timeout
183
+ result = await unity_client.execute_request(
184
+ "create_task", params, timeout=610.0
185
+ )
186
+ return result
187
+ except Exception as e:
188
+ logger.error(f"Failed to create task: {e}")
189
+ raise
190
+
191
+
192
+ def main():
193
+ """Initialize MCP server with generated tools and start serving."""
194
+ try:
195
+ logger.info("Initializing Coplay MCP Server...")
196
+
197
+ # Register all generated tools
198
+ tool_modules = [
199
+ unity_functions_tools,
200
+ image_tool_tools,
201
+ coplay_tool_tools,
202
+ agent_tool_tools,
203
+ package_tool_tools,
204
+ input_action_tool_tools,
205
+ ui_functions_tools,
206
+ snapping_functions_tools,
207
+ scene_view_functions_tools,
208
+ profiler_functions_tools,
209
+ screenshot_tool_tools,
210
+ ]
211
+
212
+ total_tools = 0
213
+ for module in tool_modules:
214
+ module.register_tools(mcp, unity_client)
215
+ # Count tools by checking for functions with @mcp.tool decorator
216
+ module_tools = [
217
+ name
218
+ for name in dir(module)
219
+ if not name.startswith("_") and name != "register_tools"
220
+ ]
221
+ total_tools += len(module_tools)
222
+ logger.info(f"Registered tools from {module.__name__}")
223
+
224
+ logger.info(f"Total generated tools registered: {total_tools}")
225
+ logger.info("Coplay MCP Server initialized successfully")
226
+
227
+ # Start the MCP server
228
+ mcp.run()
229
+
230
+ except Exception as e:
231
+ logger.error(f"Failed to initialize MCP server: {e}")
232
+ raise
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()