mcpforunityserver 9.4.0b20260203025228__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.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. utils/module_discovery.py +55 -0
cli/utils/output.py ADDED
@@ -0,0 +1,195 @@
1
+ """Output formatting utilities for CLI."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+
9
+ def format_output(data: Any, format_type: str = "text") -> str:
10
+ """Format output based on requested format type.
11
+
12
+ Args:
13
+ data: Data to format
14
+ format_type: One of 'text', 'json', 'table'
15
+
16
+ Returns:
17
+ Formatted string
18
+ """
19
+ if format_type == "json":
20
+ return format_as_json(data)
21
+ elif format_type == "table":
22
+ return format_as_table(data)
23
+ else:
24
+ return format_as_text(data)
25
+
26
+
27
+ def format_as_json(data: Any) -> str:
28
+ """Format data as pretty-printed JSON."""
29
+ try:
30
+ return json.dumps(data, indent=2, default=str)
31
+ except (TypeError, ValueError) as e:
32
+ return json.dumps({"error": f"JSON serialization failed: {e}", "raw": str(data)})
33
+
34
+
35
+ def format_as_text(data: Any, indent: int = 0) -> str:
36
+ """Format data as human-readable text."""
37
+ prefix = " " * indent
38
+
39
+ if data is None:
40
+ return f"{prefix}(none)"
41
+
42
+ if isinstance(data, dict):
43
+ # Check for error response
44
+ if "success" in data and not data.get("success"):
45
+ error = data.get("error") or data.get("message") or "Unknown error"
46
+ return f"{prefix}❌ Error: {error}"
47
+
48
+ # Check for success response with data
49
+ if "success" in data and data.get("success"):
50
+ result = data.get("data") or data.get("result") or data
51
+ if result != data:
52
+ return format_as_text(result, indent)
53
+
54
+ lines = []
55
+ for key, value in data.items():
56
+ if key in ("success", "error", "message") and "success" in data:
57
+ continue # Skip meta fields
58
+ if isinstance(value, dict):
59
+ lines.append(f"{prefix}{key}:")
60
+ lines.append(format_as_text(value, indent + 1))
61
+ elif isinstance(value, list):
62
+ lines.append(f"{prefix}{key}: [{len(value)} items]")
63
+ if len(value) <= 10:
64
+ for i, item in enumerate(value):
65
+ lines.append(
66
+ f"{prefix} [{i}] {_format_list_item(item)}")
67
+ else:
68
+ for i, item in enumerate(value[:5]):
69
+ lines.append(
70
+ f"{prefix} [{i}] {_format_list_item(item)}")
71
+ lines.append(f"{prefix} ... ({len(value) - 10} more)")
72
+ for i, item in enumerate(value[-5:], len(value) - 5):
73
+ lines.append(
74
+ f"{prefix} [{i}] {_format_list_item(item)}")
75
+ else:
76
+ lines.append(f"{prefix}{key}: {value}")
77
+ return "\n".join(lines)
78
+
79
+ if isinstance(data, list):
80
+ if not data:
81
+ return f"{prefix}(empty list)"
82
+ lines = [f"{prefix}[{len(data)} items]"]
83
+ for i, item in enumerate(data[:20]):
84
+ lines.append(f"{prefix} [{i}] {_format_list_item(item)}")
85
+ if len(data) > 20:
86
+ lines.append(f"{prefix} ... ({len(data) - 20} more)")
87
+ return "\n".join(lines)
88
+
89
+ return f"{prefix}{data}"
90
+
91
+
92
+ def _format_list_item(item: Any) -> str:
93
+ """Format a single list item."""
94
+ if isinstance(item, dict):
95
+ # Try to find a name/id field for display
96
+ name = item.get("name") or item.get(
97
+ "Name") or item.get("id") or item.get("Id")
98
+ if name:
99
+ extra = ""
100
+ if "instanceID" in item:
101
+ extra = f" (ID: {item['instanceID']})"
102
+ elif "path" in item:
103
+ extra = f" ({item['path']})"
104
+ return f"{name}{extra}"
105
+ # Fallback to compact representation
106
+ return json.dumps(item, default=str)[:80]
107
+ return str(item)[:80]
108
+
109
+
110
+ def format_as_table(data: Any) -> str:
111
+ """Format data as an ASCII table."""
112
+ if isinstance(data, dict):
113
+ # Check for success response with data
114
+ if "success" in data and data.get("success"):
115
+ result = data.get("data") or data.get(
116
+ "result") or data.get("items")
117
+ if isinstance(result, list):
118
+ return _build_table(result)
119
+
120
+ # Single dict as key-value table
121
+ rows = [[str(k), str(v)[:60]] for k, v in data.items()]
122
+ return _build_table(rows, headers=["Key", "Value"])
123
+
124
+ if isinstance(data, list):
125
+ return _build_table(data)
126
+
127
+ return str(data)
128
+
129
+
130
+ def _build_table(data: list[Any], headers: list[str] | None = None) -> str:
131
+ """Build an ASCII table from list data."""
132
+ if not data:
133
+ return "(no data)"
134
+
135
+ # Convert list of dicts to rows
136
+ if isinstance(data[0], dict):
137
+ if headers is None:
138
+ headers = list(data[0].keys())
139
+ rows = [[str(item.get(h, ""))[:40] for h in headers] for item in data]
140
+ elif isinstance(data[0], (list, tuple)):
141
+ rows = [[str(cell)[:40] for cell in row] for row in data]
142
+ if headers is None:
143
+ headers = [f"Col{i}" for i in range(len(data[0]))]
144
+ else:
145
+ rows = [[str(item)[:60]] for item in data]
146
+ headers = headers or ["Value"]
147
+
148
+ # Calculate column widths
149
+ col_widths = [len(h) for h in headers]
150
+ for row in rows:
151
+ for i, cell in enumerate(row):
152
+ if i < len(col_widths):
153
+ col_widths[i] = max(col_widths[i], len(cell))
154
+
155
+ # Build table
156
+ lines = []
157
+
158
+ # Header
159
+ header_line = " | ".join(
160
+ h.ljust(col_widths[i]) for i, h in enumerate(headers))
161
+ lines.append(header_line)
162
+ lines.append("-+-".join("-" * w for w in col_widths))
163
+
164
+ # Rows
165
+ for row in rows[:50]: # Limit rows
166
+ row_line = " | ".join(
167
+ (row[i] if i < len(row) else "").ljust(col_widths[i])
168
+ for i in range(len(headers))
169
+ )
170
+ lines.append(row_line)
171
+
172
+ if len(rows) > 50:
173
+ lines.append(f"... ({len(rows) - 50} more rows)")
174
+
175
+ return "\n".join(lines)
176
+
177
+
178
+ def print_success(message: str) -> None:
179
+ """Print a success message."""
180
+ click.echo(f"✅ {message}")
181
+
182
+
183
+ def print_error(message: str) -> None:
184
+ """Print an error message to stderr."""
185
+ click.echo(f"❌ {message}", err=True)
186
+
187
+
188
+ def print_warning(message: str) -> None:
189
+ """Print a warning message."""
190
+ click.echo(f"⚠️ {message}")
191
+
192
+
193
+ def print_info(message: str) -> None:
194
+ """Print an info message."""
195
+ click.echo(f"ℹ️ {message}")
cli/utils/parsers.py ADDED
@@ -0,0 +1,112 @@
1
+ """JSON and value parsing utilities for CLI commands."""
2
+ import json
3
+ import sys
4
+ from typing import Any
5
+
6
+ from cli.utils.output import print_error, print_info
7
+
8
+
9
+ def parse_value_safe(value: str) -> Any:
10
+ """Parse a value, trying JSON → float → string fallback.
11
+
12
+ This is used for property values that could be JSON objects/arrays,
13
+ numbers, or strings. Never raises an exception.
14
+
15
+ Args:
16
+ value: The string value to parse
17
+
18
+ Returns:
19
+ Parsed JSON object/array, float, or original string
20
+
21
+ Examples:
22
+ >>> parse_value_safe('{"x": 1}')
23
+ {'x': 1}
24
+ >>> parse_value_safe('3.14')
25
+ 3.14
26
+ >>> parse_value_safe('hello')
27
+ 'hello'
28
+ """
29
+ try:
30
+ return json.loads(value)
31
+ except json.JSONDecodeError:
32
+ # Try to parse as number
33
+ try:
34
+ return float(value)
35
+ except ValueError:
36
+ # Keep as string
37
+ return value
38
+
39
+
40
+ def parse_json_or_exit(value: str, context: str = "parameter") -> Any:
41
+ """Parse JSON string, trying to fix common issues, or exit with error.
42
+
43
+ Attempts to parse JSON with automatic fixes for:
44
+ - Single quotes instead of double quotes
45
+ - Python-style True/False instead of true/false
46
+
47
+ Args:
48
+ value: The JSON string to parse
49
+ context: Description of what's being parsed (for error messages)
50
+
51
+ Returns:
52
+ Parsed JSON object
53
+
54
+ Exits:
55
+ Calls sys.exit(1) if JSON is invalid after attempting fixes
56
+ """
57
+ try:
58
+ return json.loads(value)
59
+ except json.JSONDecodeError:
60
+ # Try to fix common shell quoting issues (single quotes, Python bools)
61
+ try:
62
+ fixed = value.replace("'", '"').replace("True", "true").replace("False", "false")
63
+ return json.loads(fixed)
64
+ except json.JSONDecodeError as e:
65
+ print_error(f"Invalid JSON for {context}: {e}")
66
+ print_info("Example: --params '{\"key\":\"value\"}'")
67
+ print_info("Tip: wrap JSON in single quotes to avoid shell escaping issues.")
68
+ sys.exit(1)
69
+
70
+
71
+ def parse_json_dict_or_exit(value: str, context: str = "parameter") -> dict[str, Any]:
72
+ """Parse JSON object (dict), or exit with error.
73
+
74
+ Like parse_json_or_exit, but ensures result is a dictionary.
75
+
76
+ Args:
77
+ value: The JSON string to parse
78
+ context: Description of what's being parsed (for error messages)
79
+
80
+ Returns:
81
+ Parsed JSON object as dictionary
82
+
83
+ Exits:
84
+ Calls sys.exit(1) if JSON is invalid or not an object
85
+ """
86
+ result = parse_json_or_exit(value, context)
87
+ if not isinstance(result, dict):
88
+ print_error(f"Invalid JSON for {context}: expected an object, got {type(result).__name__}")
89
+ sys.exit(1)
90
+ return result
91
+
92
+
93
+ def parse_json_list_or_exit(value: str, context: str = "parameter") -> list[Any]:
94
+ """Parse JSON array (list), or exit with error.
95
+
96
+ Like parse_json_or_exit, but ensures result is a list.
97
+
98
+ Args:
99
+ value: The JSON string to parse
100
+ context: Description of what's being parsed (for error messages)
101
+
102
+ Returns:
103
+ Parsed JSON array as list
104
+
105
+ Exits:
106
+ Calls sys.exit(1) if JSON is invalid or not an array
107
+ """
108
+ result = parse_json_or_exit(value, context)
109
+ if not isinstance(result, list):
110
+ print_error(f"Invalid JSON for {context}: expected an array, got {type(result).__name__}")
111
+ sys.exit(1)
112
+ return result
@@ -0,0 +1,34 @@
1
+ """Helpers for CLI suggestion messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from typing import Iterable, List
7
+
8
+
9
+ def suggest_matches(
10
+ value: str,
11
+ choices: Iterable[str],
12
+ *,
13
+ limit: int = 3,
14
+ cutoff: float = 0.6,
15
+ ) -> List[str]:
16
+ """Return close matches for a value from a list of choices."""
17
+ try:
18
+ normalized = [c for c in choices if isinstance(c, str)]
19
+ except Exception:
20
+ normalized = []
21
+ if not value or not normalized:
22
+ return []
23
+ return difflib.get_close_matches(value, normalized, n=limit, cutoff=cutoff)
24
+
25
+
26
+ def format_suggestions(matches: Iterable[str]) -> str | None:
27
+ """Format matches into a CLI-friendly suggestion string."""
28
+ items = [m for m in matches if m]
29
+ if not items:
30
+ return None
31
+ if len(items) == 1:
32
+ return f"Did you mean: {items[0]}"
33
+ joined = ", ".join(items)
34
+ return f"Did you mean one of: {joined}"
core/__init__.py ADDED
File without changes
core/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Configuration settings for the MCP for Unity Server.
3
+ This file contains all configurable parameters for the server.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class ServerConfig:
11
+ """Main configuration class for the MCP server."""
12
+
13
+ # Network settings
14
+ unity_host: str = "localhost"
15
+ unity_port: int = 6400
16
+ mcp_port: int = 6500
17
+
18
+ # Transport settings
19
+ transport_mode: str = "stdio"
20
+
21
+ # HTTP transport behaviour
22
+ http_remote_hosted: bool = False
23
+
24
+ # API key authentication (required when http_remote_hosted=True)
25
+ api_key_validation_url: str | None = None # POST endpoint to validate keys
26
+ api_key_login_url: str | None = None # URL for users to get/manage keys
27
+ # Cache TTL in seconds (5 min default)
28
+ api_key_cache_ttl: float = 300.0
29
+ # Optional service token for authenticating to the validation endpoint
30
+ api_key_service_token_header: str | None = None # e.g. "X-Service-Token"
31
+ api_key_service_token: str | None = None # The token value
32
+
33
+ # Connection settings
34
+ connection_timeout: float = 30.0
35
+ buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
36
+
37
+ # STDIO framing behaviour
38
+ require_framing: bool = True
39
+ handshake_timeout: float = 1.0
40
+ framed_receive_timeout: float = 2.0
41
+ max_heartbeat_frames: int = 16
42
+ heartbeat_timeout: float = 2.0
43
+
44
+ # Logging settings
45
+ log_level: str = "INFO"
46
+ log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
47
+
48
+ # Server settings
49
+ max_retries: int = 5
50
+ retry_delay: float = 0.25
51
+ # Backoff hint returned to clients when Unity is reloading (milliseconds)
52
+ reload_retry_ms: int = 250
53
+ # Number of polite retries when Unity reports reloading
54
+ # 40 × 250ms ≈ 10s default window
55
+ reload_max_retries: int = 40
56
+
57
+ # Port discovery cache
58
+ port_registry_ttl: float = 5.0
59
+
60
+ # Telemetry settings
61
+ telemetry_enabled: bool = True
62
+ # Align with telemetry.py default Cloud Run endpoint
63
+ telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events"
64
+
65
+
66
+ # Create a global config instance
67
+ config = ServerConfig()
core/constants.py ADDED
@@ -0,0 +1,4 @@
1
+ """Server-wide protocol constants."""
2
+
3
+ # HTTP header name for API key authentication
4
+ API_KEY_HEADER = "X-API-Key"
@@ -0,0 +1,37 @@
1
+ import functools
2
+ import inspect
3
+ import logging
4
+ from typing import Callable, Any
5
+
6
+ logger = logging.getLogger("mcp-for-unity-server")
7
+
8
+
9
+ def log_execution(name: str, type_label: str):
10
+ """Decorator to log input arguments and return value of a function."""
11
+ def decorator(func: Callable) -> Callable:
12
+ @functools.wraps(func)
13
+ def _sync_wrapper(*args, **kwargs) -> Any:
14
+ logger.info(
15
+ f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
16
+ try:
17
+ result = func(*args, **kwargs)
18
+ logger.info(f"{type_label} '{name}' returned: {result}")
19
+ return result
20
+ except Exception as e:
21
+ logger.info(f"{type_label} '{name}' failed: {e}")
22
+ raise
23
+
24
+ @functools.wraps(func)
25
+ async def _async_wrapper(*args, **kwargs) -> Any:
26
+ logger.info(
27
+ f"{type_label} '{name}' called with args={args} kwargs={kwargs}")
28
+ try:
29
+ result = await func(*args, **kwargs)
30
+ logger.info(f"{type_label} '{name}' returned: {result}")
31
+ return result
32
+ except Exception as e:
33
+ logger.info(f"{type_label} '{name}' failed: {e}")
34
+ raise
35
+
36
+ return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper
37
+ return decorator