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
@@ -0,0 +1,120 @@
1
+ from typing import Annotated, Any
2
+ from types import SimpleNamespace
3
+
4
+ from fastmcp import Context
5
+ from mcp.types import ToolAnnotations
6
+
7
+ from services.registry import mcp_for_unity_tool
8
+ from transport.legacy.unity_connection import get_unity_connection_pool
9
+ from transport.unity_instance_middleware import get_unity_instance_middleware
10
+ from transport.plugin_hub import PluginHub
11
+ from core.config import config
12
+
13
+
14
+ @mcp_for_unity_tool(
15
+ description="Set the active Unity instance for this client/session. Accepts Name@hash or hash.",
16
+ annotations=ToolAnnotations(
17
+ title="Set Active Instance",
18
+ ),
19
+ )
20
+ async def set_active_instance(
21
+ ctx: Context,
22
+ instance: Annotated[str, "Target instance (Name@hash or hash prefix)"]
23
+ ) -> dict[str, Any]:
24
+ transport = (config.transport_mode or "stdio").lower()
25
+
26
+ # Discover running instances based on transport
27
+ if transport == "http":
28
+ # In remote-hosted mode, filter sessions by user_id
29
+ user_id = ctx.get_state(
30
+ "user_id") if config.http_remote_hosted else None
31
+ sessions_data = await PluginHub.get_sessions(user_id=user_id)
32
+ sessions = sessions_data.sessions
33
+ instances = []
34
+ for session_id, session in sessions.items():
35
+ project = session.project or "Unknown"
36
+ hash_value = session.hash
37
+ if not hash_value:
38
+ continue
39
+ inst_id = f"{project}@{hash_value}"
40
+ instances.append(SimpleNamespace(
41
+ id=inst_id,
42
+ hash=hash_value,
43
+ name=project,
44
+ session_id=session_id,
45
+ ))
46
+ else:
47
+ pool = get_unity_connection_pool()
48
+ instances = pool.discover_all_instances(force_refresh=True)
49
+
50
+ if not instances:
51
+ return {
52
+ "success": False,
53
+ "error": "No Unity instances are currently connected. Start Unity and press 'Start Session'."
54
+ }
55
+ ids = {inst.id: inst for inst in instances if getattr(inst, "id", None)}
56
+
57
+ value = (instance or "").strip()
58
+ if not value:
59
+ return {
60
+ "success": False,
61
+ "error": "Instance identifier is required. "
62
+ "Use mcpforunity://instances to copy a Name@hash or provide a hash prefix."
63
+ }
64
+ resolved = None
65
+ if "@" in value:
66
+ resolved = ids.get(value)
67
+ if resolved is None:
68
+ return {
69
+ "success": False,
70
+ "error": f"Instance '{value}' not found. "
71
+ "Use mcpforunity://instances to copy an exact Name@hash."
72
+ }
73
+ else:
74
+ lookup = value.lower()
75
+ matches = []
76
+ for inst in instances:
77
+ if not getattr(inst, "id", None):
78
+ continue
79
+ inst_hash = getattr(inst, "hash", "")
80
+ if inst_hash and inst_hash.lower().startswith(lookup):
81
+ matches.append(inst)
82
+ if not matches:
83
+ return {
84
+ "success": False,
85
+ "error": f"Instance hash '{value}' does not match any running Unity editors. "
86
+ "Use mcpforunity://instances to confirm the available hashes."
87
+ }
88
+ if len(matches) > 1:
89
+ matching_ids = ", ".join(
90
+ inst.id for inst in matches if getattr(inst, "id", None)
91
+ ) or "multiple instances"
92
+ return {
93
+ "success": False,
94
+ "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). "
95
+ "Provide the full Name@hash from mcpforunity://instances."
96
+ }
97
+ resolved = matches[0]
98
+
99
+ if resolved is None:
100
+ # Should be unreachable due to logic above, but satisfies static analysis
101
+ return {
102
+ "success": False,
103
+ "error": "Internal error: Instance resolution failed."
104
+ }
105
+
106
+ # Store selection in middleware (session-scoped)
107
+ middleware = get_unity_instance_middleware()
108
+ # We use middleware.set_active_instance to persist the selection.
109
+ # The session key is an internal detail but useful for debugging response.
110
+ middleware.set_active_instance(ctx, resolved.id)
111
+ session_key = middleware.get_session_key(ctx)
112
+
113
+ return {
114
+ "success": True,
115
+ "message": f"Active instance set to {resolved.id}",
116
+ "data": {
117
+ "instance": resolved.id,
118
+ "session_key": session_key,
119
+ },
120
+ }
@@ -0,0 +1,348 @@
1
+ """Shared helper utilities for MCP server tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from typing import Any
8
+
9
+ _TRUTHY = {"true", "1", "yes", "on"}
10
+ _FALSY = {"false", "0", "no", "off"}
11
+
12
+
13
+ def coerce_bool(value: Any, default: bool | None = None) -> bool | None:
14
+ """Attempt to coerce a loosely-typed value to a boolean."""
15
+ if value is None:
16
+ return default
17
+ if isinstance(value, bool):
18
+ return value
19
+ if isinstance(value, str):
20
+ lowered = value.strip().lower()
21
+ if lowered in _TRUTHY:
22
+ return True
23
+ if lowered in _FALSY:
24
+ return False
25
+ return default
26
+ return bool(value)
27
+
28
+
29
+ def parse_json_payload(value: Any) -> Any:
30
+ """
31
+ Attempt to parse a value that might be a JSON string into its native object.
32
+
33
+ This is a tolerant parser used to handle cases where MCP clients or LLMs
34
+ serialize complex objects (lists, dicts) into strings. It also handles
35
+ scalar values like numbers, booleans, and null.
36
+
37
+ Args:
38
+ value: The input value (can be str, list, dict, etc.)
39
+
40
+ Returns:
41
+ The parsed JSON object/list if the input was a valid JSON string,
42
+ or the original value if parsing failed or wasn't necessary.
43
+ """
44
+ if not isinstance(value, str):
45
+ return value
46
+
47
+ val_trimmed = value.strip()
48
+
49
+ # Fast path: if it doesn't look like JSON structure, return as is
50
+ if not (
51
+ (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or
52
+ (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or
53
+ val_trimmed in ("true", "false", "null") or
54
+ (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit())
55
+ ):
56
+ return value
57
+
58
+ try:
59
+ return json.loads(value)
60
+ except (json.JSONDecodeError, ValueError):
61
+ # If parsing fails, assume it was meant to be a literal string
62
+ return value
63
+
64
+
65
+ def coerce_int(value: Any, default: int | None = None) -> int | None:
66
+ """Attempt to coerce a loosely-typed value to an integer."""
67
+ if value is None:
68
+ return default
69
+ try:
70
+ if isinstance(value, bool):
71
+ return default
72
+ if isinstance(value, int):
73
+ return value
74
+ s = str(value).strip()
75
+ if s.lower() in ("", "none", "null"):
76
+ return default
77
+ return int(float(s))
78
+ except Exception:
79
+ return default
80
+
81
+
82
+ def coerce_float(value: Any, default: float | None = None) -> float | None:
83
+ """Attempt to coerce a loosely-typed value to a float-like number."""
84
+ if value is None:
85
+ return default
86
+ try:
87
+ # Treat booleans as invalid numeric input instead of coercing to 0/1.
88
+ if isinstance(value, bool):
89
+ return default
90
+ if isinstance(value, (int, float)):
91
+ return float(value)
92
+ s = str(value).strip()
93
+ if s.lower() in ("", "none", "null"):
94
+ return default
95
+ return float(s)
96
+ except (TypeError, ValueError):
97
+ return default
98
+
99
+
100
+ def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]:
101
+ """
102
+ Robustly normalize a properties parameter to a dict.
103
+
104
+ Handles various input formats from MCP clients/LLMs:
105
+ - None -> (None, None)
106
+ - dict -> (dict, None)
107
+ - JSON string -> (parsed_dict, None) or (None, error_message)
108
+ - Invalid values -> (None, error_message)
109
+
110
+ Returns:
111
+ Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None.
112
+ """
113
+ if value is None:
114
+ return None, None
115
+
116
+ # Already a dict - return as-is
117
+ if isinstance(value, dict):
118
+ return value, None
119
+
120
+ # Try parsing as string
121
+ if isinstance(value, str):
122
+ # Check for obviously invalid values from serialization bugs
123
+ if value in ("[object Object]", "undefined", "null", ""):
124
+ return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}"
125
+
126
+ parsed = parse_json_payload(value)
127
+ if isinstance(parsed, dict):
128
+ return parsed, None
129
+
130
+ return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}"
131
+
132
+ return None, f"properties must be a dict or JSON string, got {type(value).__name__}"
133
+
134
+
135
+ def normalize_vector3(value: Any, param_name: str = "vector") -> tuple[list[float] | None, str | None]:
136
+ """
137
+ Normalize a vector parameter to [x, y, z] format.
138
+
139
+ Handles various input formats from MCP clients/LLMs:
140
+ - None -> (None, None)
141
+ - list/tuple [x, y, z] -> ([x, y, z], None)
142
+ - dict {x, y, z} -> ([x, y, z], None)
143
+ - JSON string "[x, y, z]" or "{x, y, z}" -> parsed and normalized
144
+ - comma-separated string "x, y, z" -> ([x, y, z], None)
145
+
146
+ Returns:
147
+ Tuple of (parsed_vector, error_message). If error_message is set, parsed_vector is None.
148
+ """
149
+ if value is None:
150
+ return None, None
151
+
152
+ # Handle dict with x/y/z keys (e.g., {"x": 0, "y": 1, "z": 2})
153
+ if isinstance(value, dict):
154
+ if all(k in value for k in ("x", "y", "z")):
155
+ try:
156
+ vec = [float(value["x"]), float(value["y"]), float(value["z"])]
157
+ if all(math.isfinite(n) for n in vec):
158
+ return vec, None
159
+ return None, f"{param_name} values must be finite numbers, got {value}"
160
+ except (ValueError, TypeError, KeyError):
161
+ return None, f"{param_name} dict values must be numbers, got {value}"
162
+ return None, f"{param_name} dict must have 'x', 'y', 'z' keys, got {list(value.keys())}"
163
+
164
+ # If already a list/tuple with 3 elements, convert to floats
165
+ if isinstance(value, (list, tuple)) and len(value) == 3:
166
+ try:
167
+ vec = [float(value[0]), float(value[1]), float(value[2])]
168
+ if all(math.isfinite(n) for n in vec):
169
+ return vec, None
170
+ return None, f"{param_name} values must be finite numbers, got {value}"
171
+ except (ValueError, TypeError):
172
+ return None, f"{param_name} values must be numbers, got {value}"
173
+
174
+ # Try parsing as string
175
+ if isinstance(value, str):
176
+ # Check for obviously invalid values
177
+ if value in ("[object Object]", "undefined", "null", ""):
178
+ return None, f"{param_name} received invalid value: '{value}'. Expected [x, y, z] array or {{x, y, z}} object"
179
+
180
+ parsed = parse_json_payload(value)
181
+
182
+ # Handle parsed dict
183
+ if isinstance(parsed, dict):
184
+ return normalize_vector3(parsed, param_name)
185
+
186
+ # Handle parsed list
187
+ if isinstance(parsed, list) and len(parsed) == 3:
188
+ try:
189
+ vec = [float(parsed[0]), float(parsed[1]), float(parsed[2])]
190
+ if all(math.isfinite(n) for n in vec):
191
+ return vec, None
192
+ return None, f"{param_name} values must be finite numbers, got {parsed}"
193
+ except (ValueError, TypeError):
194
+ return None, f"{param_name} values must be numbers, got {parsed}"
195
+
196
+ # Handle comma-separated strings "1,2,3", "[1,2,3]", or "(1,2,3)"
197
+ s = value.strip()
198
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
199
+ s = s[1:-1]
200
+ parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
201
+ if len(parts) == 3:
202
+ try:
203
+ vec = [float(parts[0]), float(parts[1]), float(parts[2])]
204
+ if all(math.isfinite(n) for n in vec):
205
+ return vec, None
206
+ return None, f"{param_name} values must be finite numbers, got {value}"
207
+ except (ValueError, TypeError):
208
+ return None, f"{param_name} values must be numbers, got {value}"
209
+
210
+ return None, f"{param_name} must be a [x, y, z] array or {{x, y, z}} object, got: {value}"
211
+
212
+ return None, f"{param_name} must be a list, dict, or string, got {type(value).__name__}"
213
+
214
+
215
+ def normalize_color(value: Any, output_range: str = "float") -> tuple[list[float] | None, str | None]:
216
+ """
217
+ Normalize a color parameter to [r, g, b, a] format.
218
+
219
+ Handles various input formats from MCP clients/LLMs:
220
+ - None -> (None, None)
221
+ - list/tuple [r, g, b] or [r, g, b, a] -> normalized with optional alpha
222
+ - dict {r, g, b} or {r, g, b, a} -> converted to list
223
+ - hex string "#RGB", "#RRGGBB", "#RRGGBBAA" -> parsed to [r, g, b, a]
224
+ - JSON string -> parsed and normalized
225
+
226
+ Args:
227
+ value: The color value to normalize
228
+ output_range: "float" for 0.0-1.0 range, "int" for 0-255 range
229
+
230
+ Returns:
231
+ Tuple of (parsed_color, error_message). If error_message is set, parsed_color is None.
232
+ """
233
+ if value is None:
234
+ return None, None
235
+
236
+ def _to_output_range(components: list[float], from_hex: bool = False) -> list:
237
+ """Convert color components to the requested output range."""
238
+ if output_range == "int":
239
+ if from_hex:
240
+ # Already 0-255 from hex parsing
241
+ return [int(c) for c in components]
242
+ # Check if input is normalized (0-1) or already 0-255
243
+ if all(0 <= c <= 1 for c in components):
244
+ return [int(round(c * 255)) for c in components]
245
+ return [int(c) for c in components]
246
+ else: # float
247
+ if from_hex:
248
+ # Convert 0-255 to 0-1
249
+ return [c / 255.0 for c in components]
250
+ if any(c > 1 for c in components):
251
+ return [c / 255.0 for c in components]
252
+ return [float(c) for c in components]
253
+
254
+ # Handle dict with r/g/b keys
255
+ if isinstance(value, dict):
256
+ if all(k in value for k in ("r", "g", "b")):
257
+ try:
258
+ color = [float(value["r"]), float(value["g"]), float(value["b"])]
259
+ if "a" in value:
260
+ color.append(float(value["a"]))
261
+ else:
262
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
263
+ color.append(1.0)
264
+ else:
265
+ color.append(1.0 if output_range == "float" else 255)
266
+ return _to_output_range(color), None
267
+ except (ValueError, TypeError, KeyError):
268
+ return None, f"color dict values must be numbers, got {value}"
269
+ return None, f"color dict must have 'r', 'g', 'b' keys, got {list(value.keys())}"
270
+
271
+ # Already a list/tuple - validate
272
+ if isinstance(value, (list, tuple)):
273
+ if len(value) in (3, 4):
274
+ try:
275
+ color = [float(c) for c in value]
276
+ if len(color) == 3:
277
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
278
+ color.append(1.0)
279
+ else:
280
+ color.append(1.0 if output_range == "float" else 255)
281
+ return _to_output_range(color), None
282
+ except (ValueError, TypeError):
283
+ return None, f"color values must be numbers, got {value}"
284
+ return None, f"color must have 3 or 4 components, got {len(value)}"
285
+
286
+ # Try parsing as string
287
+ if isinstance(value, str):
288
+ if value in ("[object Object]", "undefined", "null", ""):
289
+ return None, f"color received invalid value: '{value}'. Expected [r, g, b, a] or {{r, g, b, a}}"
290
+
291
+ # Handle hex colors
292
+ if value.startswith("#"):
293
+ h = value.lstrip("#")
294
+ try:
295
+ if len(h) == 3:
296
+ # Short form #RGB -> expand to #RRGGBB
297
+ components = [int(c + c, 16) for c in h] + [255]
298
+ return _to_output_range(components, from_hex=True), None
299
+ elif len(h) == 6:
300
+ components = [int(h[i:i+2], 16) for i in (0, 2, 4)] + [255]
301
+ return _to_output_range(components, from_hex=True), None
302
+ elif len(h) == 8:
303
+ components = [int(h[i:i+2], 16) for i in (0, 2, 4, 6)]
304
+ return _to_output_range(components, from_hex=True), None
305
+ except ValueError:
306
+ return None, f"Invalid hex color: {value}"
307
+ return None, f"Invalid hex color length: {value}"
308
+
309
+ # Try parsing as JSON
310
+ parsed = parse_json_payload(value)
311
+
312
+ # Handle parsed dict
313
+ if isinstance(parsed, dict):
314
+ return normalize_color(parsed, output_range)
315
+
316
+ # Handle parsed list
317
+ if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4):
318
+ try:
319
+ color = [float(c) for c in parsed]
320
+ if len(color) == 3:
321
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
322
+ color.append(1.0)
323
+ else:
324
+ color.append(1.0 if output_range == "float" else 255)
325
+ return _to_output_range(color), None
326
+ except (ValueError, TypeError):
327
+ return None, f"color values must be numbers, got {parsed}"
328
+
329
+ # Handle tuple-style strings "(r, g, b)" or "(r, g, b, a)"
330
+ s = value.strip()
331
+ if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
332
+ s = s[1:-1]
333
+ parts = [p.strip() for p in s.split(",")]
334
+ if len(parts) in (3, 4):
335
+ try:
336
+ color = [float(p) for p in parts]
337
+ if len(color) == 3:
338
+ if output_range == "int" and all(0 <= c <= 1 for c in color):
339
+ color.append(1.0)
340
+ else:
341
+ color.append(1.0 if output_range == "float" else 255)
342
+ return _to_output_range(color), None
343
+ except (ValueError, TypeError):
344
+ pass # Fall through to error message
345
+
346
+ return None, f"Failed to parse color string: {value}"
347
+
348
+ return None, f"color must be a list, dict, hex string, or JSON string, got {type(value).__name__}"
transport/__init__.py ADDED
File without changes