mcpforunityserver 8.5.0__py3-none-any.whl → 9.1.0__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 (79) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +87 -0
  4. cli/commands/asset.py +310 -0
  5. cli/commands/audio.py +133 -0
  6. cli/commands/batch.py +184 -0
  7. cli/commands/code.py +189 -0
  8. cli/commands/component.py +212 -0
  9. cli/commands/editor.py +487 -0
  10. cli/commands/gameobject.py +510 -0
  11. cli/commands/instance.py +101 -0
  12. cli/commands/lighting.py +128 -0
  13. cli/commands/material.py +268 -0
  14. cli/commands/prefab.py +144 -0
  15. cli/commands/scene.py +255 -0
  16. cli/commands/script.py +240 -0
  17. cli/commands/shader.py +238 -0
  18. cli/commands/ui.py +263 -0
  19. cli/commands/vfx.py +439 -0
  20. cli/main.py +248 -0
  21. cli/utils/__init__.py +31 -0
  22. cli/utils/config.py +58 -0
  23. cli/utils/connection.py +191 -0
  24. cli/utils/output.py +195 -0
  25. main.py +207 -62
  26. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/METADATA +4 -2
  27. mcpforunityserver-9.1.0.dist-info/RECORD +96 -0
  28. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/WHEEL +1 -1
  29. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/entry_points.txt +1 -0
  30. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/top_level.txt +1 -2
  31. services/custom_tool_service.py +179 -19
  32. services/resources/__init__.py +6 -1
  33. services/resources/active_tool.py +1 -1
  34. services/resources/custom_tools.py +2 -2
  35. services/resources/editor_state.py +283 -21
  36. services/resources/gameobject.py +243 -0
  37. services/resources/layers.py +1 -1
  38. services/resources/prefab_stage.py +1 -1
  39. services/resources/project_info.py +1 -1
  40. services/resources/selection.py +1 -1
  41. services/resources/tags.py +1 -1
  42. services/resources/unity_instances.py +1 -1
  43. services/resources/windows.py +1 -1
  44. services/state/external_changes_scanner.py +245 -0
  45. services/tools/__init__.py +6 -1
  46. services/tools/batch_execute.py +24 -9
  47. services/tools/debug_request_context.py +8 -2
  48. services/tools/execute_custom_tool.py +6 -1
  49. services/tools/execute_menu_item.py +6 -3
  50. services/tools/find_gameobjects.py +89 -0
  51. services/tools/find_in_file.py +26 -19
  52. services/tools/manage_asset.py +19 -43
  53. services/tools/manage_components.py +131 -0
  54. services/tools/manage_editor.py +9 -8
  55. services/tools/manage_gameobject.py +120 -79
  56. services/tools/manage_material.py +80 -31
  57. services/tools/manage_prefabs.py +7 -1
  58. services/tools/manage_scene.py +34 -13
  59. services/tools/manage_script.py +62 -19
  60. services/tools/manage_scriptable_object.py +22 -10
  61. services/tools/manage_shader.py +8 -1
  62. services/tools/manage_vfx.py +738 -0
  63. services/tools/preflight.py +110 -0
  64. services/tools/read_console.py +81 -18
  65. services/tools/refresh_unity.py +153 -0
  66. services/tools/run_tests.py +202 -41
  67. services/tools/script_apply_edits.py +15 -7
  68. services/tools/set_active_instance.py +12 -7
  69. services/tools/utils.py +60 -6
  70. transport/legacy/port_discovery.py +2 -2
  71. transport/legacy/unity_connection.py +129 -26
  72. transport/plugin_hub.py +191 -19
  73. transport/unity_instance_middleware.py +93 -2
  74. transport/unity_transport.py +17 -6
  75. utils/focus_nudge.py +321 -0
  76. __init__.py +0 -0
  77. mcpforunityserver-8.5.0.dist-info/RECORD +0 -66
  78. routes/__init__.py +0 -0
  79. {mcpforunityserver-8.5.0.dist-info → mcpforunityserver-9.1.0.dist-info}/licenses/LICENSE +0 -0
cli/utils/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """CLI utility modules."""
2
+
3
+ from cli.utils.config import CLIConfig, get_config, set_config
4
+ from cli.utils.connection import (
5
+ run_command,
6
+ run_check_connection,
7
+ run_list_instances,
8
+ UnityConnectionError,
9
+ )
10
+ from cli.utils.output import (
11
+ format_output,
12
+ print_success,
13
+ print_error,
14
+ print_warning,
15
+ print_info,
16
+ )
17
+
18
+ __all__ = [
19
+ "CLIConfig",
20
+ "UnityConnectionError",
21
+ "format_output",
22
+ "get_config",
23
+ "print_error",
24
+ "print_info",
25
+ "print_success",
26
+ "print_warning",
27
+ "run_check_connection",
28
+ "run_command",
29
+ "run_list_instances",
30
+ "set_config",
31
+ ]
cli/utils/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """CLI Configuration utilities."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class CLIConfig:
10
+ """Configuration for CLI connection to Unity."""
11
+
12
+ host: str = "127.0.0.1"
13
+ port: int = 8080
14
+ timeout: int = 30
15
+ format: str = "text" # text, json, table
16
+ unity_instance: Optional[str] = None
17
+
18
+ @classmethod
19
+ def from_env(cls) -> "CLIConfig":
20
+ port_raw = os.environ.get("UNITY_MCP_HTTP_PORT", "8080")
21
+ try:
22
+ port = int(port_raw)
23
+ except (ValueError, TypeError):
24
+ raise ValueError(
25
+ f"Invalid UNITY_MCP_HTTP_PORT value: {port_raw!r}")
26
+
27
+ timeout_raw = os.environ.get("UNITY_MCP_TIMEOUT", "30")
28
+ try:
29
+ timeout = int(timeout_raw)
30
+ except (ValueError, TypeError):
31
+ raise ValueError(
32
+ f"Invalid UNITY_MCP_TIMEOUT value: {timeout_raw!r}")
33
+
34
+ return cls(
35
+ host=os.environ.get("UNITY_MCP_HOST", "127.0.0.1"),
36
+ port=port,
37
+ timeout=timeout,
38
+ format=os.environ.get("UNITY_MCP_FORMAT", "text"),
39
+ unity_instance=os.environ.get("UNITY_MCP_INSTANCE"),
40
+ )
41
+
42
+
43
+ # Global config instance
44
+ _config: Optional[CLIConfig] = None
45
+
46
+
47
+ def get_config() -> CLIConfig:
48
+ """Get the current CLI configuration."""
49
+ global _config
50
+ if _config is None:
51
+ _config = CLIConfig.from_env()
52
+ return _config
53
+
54
+
55
+ def set_config(config: CLIConfig) -> None:
56
+ """Set the CLI configuration."""
57
+ global _config
58
+ _config = config
@@ -0,0 +1,191 @@
1
+ """Connection utilities for CLI to communicate with Unity via MCP server."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from typing import Any, Dict, Optional
7
+
8
+ import httpx
9
+
10
+ from cli.utils.config import get_config, CLIConfig
11
+
12
+
13
+ class UnityConnectionError(Exception):
14
+ """Raised when connection to Unity fails."""
15
+ pass
16
+
17
+
18
+ def warn_if_remote_host(config: CLIConfig) -> None:
19
+ """Warn user if connecting to a non-localhost server.
20
+
21
+ This is a security measure to alert users that connecting to remote
22
+ servers exposes Unity control to potential network attacks.
23
+
24
+ Args:
25
+ config: CLI configuration with host setting
26
+ """
27
+ import click
28
+
29
+ local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0")
30
+ if config.host.lower() not in local_hosts:
31
+ click.echo(
32
+ "⚠️ Security Warning: Connecting to non-localhost server.\n"
33
+ " The MCP CLI has no authentication. Anyone on the network could\n"
34
+ " intercept commands or send unauthorized commands to Unity.\n"
35
+ " Only proceed if you trust this network.\n",
36
+ err=True
37
+ )
38
+
39
+
40
+ async def send_command(
41
+ command_type: str,
42
+ params: Dict[str, Any],
43
+ config: Optional[CLIConfig] = None,
44
+ timeout: Optional[int] = None,
45
+ ) -> Dict[str, Any]:
46
+ """Send a command to Unity via the MCP HTTP server.
47
+
48
+ Args:
49
+ command_type: The command type (e.g., 'manage_gameobject', 'manage_scene')
50
+ params: Command parameters
51
+ config: Optional CLI configuration
52
+ timeout: Optional timeout override
53
+
54
+ Returns:
55
+ Response dict from Unity
56
+
57
+ Raises:
58
+ UnityConnectionError: If connection fails
59
+ """
60
+ cfg = config or get_config()
61
+ url = f"http://{cfg.host}:{cfg.port}/api/command"
62
+
63
+ payload = {
64
+ "type": command_type,
65
+ "params": params,
66
+ }
67
+
68
+ if cfg.unity_instance:
69
+ payload["unity_instance"] = cfg.unity_instance
70
+
71
+ try:
72
+ async with httpx.AsyncClient() as client:
73
+ response = await client.post(
74
+ url,
75
+ json=payload,
76
+ timeout=timeout or cfg.timeout,
77
+ )
78
+ response.raise_for_status()
79
+ return response.json()
80
+ except httpx.ConnectError as e:
81
+ raise UnityConnectionError(
82
+ f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
83
+ f"Make sure the server is running and Unity is connected.\n"
84
+ f"Error: {e}"
85
+ )
86
+ except httpx.TimeoutException:
87
+ raise UnityConnectionError(
88
+ f"Connection to Unity timed out after {timeout or cfg.timeout}s. "
89
+ f"Unity may be busy or unresponsive."
90
+ )
91
+ except httpx.HTTPStatusError as e:
92
+ raise UnityConnectionError(
93
+ f"HTTP error from server: {e.response.status_code} - {e.response.text}"
94
+ )
95
+ except Exception as e:
96
+ raise UnityConnectionError(f"Unexpected error: {e}")
97
+
98
+
99
+ def run_command(
100
+ command_type: str,
101
+ params: Dict[str, Any],
102
+ config: Optional[CLIConfig] = None,
103
+ timeout: Optional[int] = None,
104
+ ) -> Dict[str, Any]:
105
+ """Synchronous wrapper for send_command.
106
+
107
+ Args:
108
+ command_type: The command type
109
+ params: Command parameters
110
+ config: Optional CLI configuration
111
+ timeout: Optional timeout override
112
+
113
+ Returns:
114
+ Response dict from Unity
115
+ """
116
+ return asyncio.run(send_command(command_type, params, config, timeout))
117
+
118
+
119
+ async def check_connection(config: Optional[CLIConfig] = None) -> bool:
120
+ """Check if we can connect to the Unity MCP server.
121
+
122
+ Args:
123
+ config: Optional CLI configuration
124
+
125
+ Returns:
126
+ True if connection successful, False otherwise
127
+ """
128
+ cfg = config or get_config()
129
+ url = f"http://{cfg.host}:{cfg.port}/health"
130
+
131
+ try:
132
+ async with httpx.AsyncClient() as client:
133
+ response = await client.get(url, timeout=5)
134
+ return response.status_code == 200
135
+ except Exception:
136
+ return False
137
+
138
+
139
+ def run_check_connection(config: Optional[CLIConfig] = None) -> bool:
140
+ """Synchronous wrapper for check_connection."""
141
+ return asyncio.run(check_connection(config))
142
+
143
+
144
+ async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
145
+ """List available Unity instances.
146
+
147
+ Args:
148
+ config: Optional CLI configuration
149
+
150
+ Returns:
151
+ Dict with list of Unity instances
152
+ """
153
+ cfg = config or get_config()
154
+
155
+ # Try the new /api/instances endpoint first, fall back to /plugin/sessions
156
+ urls_to_try = [
157
+ f"http://{cfg.host}:{cfg.port}/api/instances",
158
+ f"http://{cfg.host}:{cfg.port}/plugin/sessions",
159
+ ]
160
+
161
+ async with httpx.AsyncClient() as client:
162
+ for url in urls_to_try:
163
+ try:
164
+ response = await client.get(url, timeout=10)
165
+ if response.status_code == 200:
166
+ data = response.json()
167
+ # Normalize response format
168
+ if "instances" in data:
169
+ return data
170
+ elif "sessions" in data:
171
+ # Convert sessions format to instances format
172
+ instances = []
173
+ for session_id, details in data["sessions"].items():
174
+ instances.append({
175
+ "session_id": session_id,
176
+ "project": details.get("project", "Unknown"),
177
+ "hash": details.get("hash", ""),
178
+ "unity_version": details.get("unity_version", "Unknown"),
179
+ "connected_at": details.get("connected_at", ""),
180
+ })
181
+ return {"success": True, "instances": instances}
182
+ except Exception:
183
+ continue
184
+
185
+ raise UnityConnectionError(
186
+ "Failed to list Unity instances: No working endpoint found")
187
+
188
+
189
+ def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
190
+ """Synchronous wrapper for list_unity_instances."""
191
+ return asyncio.run(list_unity_instances(config))
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}")