mcpforunityserver 9.3.0b20260129104751__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.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +258 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +52 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +713 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/METADATA +216 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/RECORD +103 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/WHEEL +5 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.3.0b20260129104751.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +47 -0
- services/__init__.py +0 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +47 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +29 -0
- services/resources/menu_items.py +34 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +39 -0
- services/resources/project_info.py +39 -0
- services/resources/selection.py +55 -0
- services/resources/tags.py +30 -0
- services/resources/tests.py +87 -0
- services/resources/unity_instances.py +122 -0
- services/resources/windows.py +47 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +174 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +117 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +888 -0
- transport/models.py +63 -0
- transport/plugin_hub.py +585 -0
- transport/plugin_registry.py +126 -0
- transport/unity_instance_middleware.py +232 -0
- transport/unity_transport.py +63 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
cli/main.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Unity MCP Command Line Interface - Main Entry Point."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from cli import __version__
|
|
10
|
+
from cli.utils.config import CLIConfig, set_config, get_config
|
|
11
|
+
from cli.utils.suggestions import suggest_matches, format_suggestions
|
|
12
|
+
from cli.utils.output import format_output, print_error, print_success, print_info
|
|
13
|
+
from cli.utils.connection import (
|
|
14
|
+
run_command,
|
|
15
|
+
run_check_connection,
|
|
16
|
+
run_list_instances,
|
|
17
|
+
UnityConnectionError,
|
|
18
|
+
warn_if_remote_host,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Context object to pass configuration between commands
|
|
23
|
+
class Context:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.config: Optional[CLIConfig] = None
|
|
26
|
+
self.verbose: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
pass_context = click.make_pass_decorator(Context, ensure=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_ORIGINAL_RESOLVE_COMMAND = click.Group.resolve_command
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_command_with_suggestions(self: click.Group, ctx: click.Context, args: list[str]):
|
|
36
|
+
try:
|
|
37
|
+
return _ORIGINAL_RESOLVE_COMMAND(self, ctx, args)
|
|
38
|
+
except click.exceptions.NoSuchCommand as e:
|
|
39
|
+
if not args or args[0].startswith("-"):
|
|
40
|
+
raise
|
|
41
|
+
matches = suggest_matches(args[0], self.list_commands(ctx))
|
|
42
|
+
suggestion = format_suggestions(matches)
|
|
43
|
+
if suggestion:
|
|
44
|
+
message = f"{e}\n{suggestion}"
|
|
45
|
+
raise click.exceptions.UsageError(message, ctx=ctx)
|
|
46
|
+
raise
|
|
47
|
+
except click.exceptions.UsageError as e:
|
|
48
|
+
if args and not args[0].startswith("-") and "No such command" in str(e):
|
|
49
|
+
matches = suggest_matches(args[0], self.list_commands(ctx))
|
|
50
|
+
suggestion = format_suggestions(matches)
|
|
51
|
+
if suggestion:
|
|
52
|
+
message = f"{e}\n{suggestion}"
|
|
53
|
+
raise click.exceptions.UsageError(message, ctx=ctx)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Install suggestion handling for all CLI command groups.
|
|
58
|
+
click.Group.resolve_command = _resolve_command_with_suggestions # type: ignore[assignment]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@click.group()
|
|
62
|
+
@click.version_option(version=__version__, prog_name="unity-mcp")
|
|
63
|
+
@click.option(
|
|
64
|
+
"--host", "-h",
|
|
65
|
+
default="127.0.0.1",
|
|
66
|
+
envvar="UNITY_MCP_HOST",
|
|
67
|
+
help="MCP server host address."
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--port", "-p",
|
|
71
|
+
default=8080,
|
|
72
|
+
type=int,
|
|
73
|
+
envvar="UNITY_MCP_HTTP_PORT",
|
|
74
|
+
help="MCP server port."
|
|
75
|
+
)
|
|
76
|
+
@click.option(
|
|
77
|
+
"--timeout", "-t",
|
|
78
|
+
default=30,
|
|
79
|
+
type=int,
|
|
80
|
+
envvar="UNITY_MCP_TIMEOUT",
|
|
81
|
+
help="Command timeout in seconds."
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--format", "-f",
|
|
85
|
+
type=click.Choice(["text", "json", "table"]),
|
|
86
|
+
default="text",
|
|
87
|
+
envvar="UNITY_MCP_FORMAT",
|
|
88
|
+
help="Output format."
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--instance", "-i",
|
|
92
|
+
default=None,
|
|
93
|
+
envvar="UNITY_MCP_INSTANCE",
|
|
94
|
+
help="Target Unity instance (hash or Name@hash)."
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"--verbose", "-v",
|
|
98
|
+
is_flag=True,
|
|
99
|
+
help="Enable verbose output."
|
|
100
|
+
)
|
|
101
|
+
@pass_context
|
|
102
|
+
def cli(ctx: Context, host: str, port: int, timeout: int, format: str, instance: Optional[str], verbose: bool):
|
|
103
|
+
"""Unity MCP Command Line Interface.
|
|
104
|
+
|
|
105
|
+
Control Unity Editor directly from the command line using the Model Context Protocol.
|
|
106
|
+
|
|
107
|
+
\b
|
|
108
|
+
Examples:
|
|
109
|
+
unity-mcp status
|
|
110
|
+
unity-mcp gameobject find "Player"
|
|
111
|
+
unity-mcp scene hierarchy --format json
|
|
112
|
+
unity-mcp editor play
|
|
113
|
+
|
|
114
|
+
\b
|
|
115
|
+
Environment Variables:
|
|
116
|
+
UNITY_MCP_HOST Server host (default: 127.0.0.1)
|
|
117
|
+
UNITY_MCP_HTTP_PORT Server port (default: 8080)
|
|
118
|
+
UNITY_MCP_TIMEOUT Timeout in seconds (default: 30)
|
|
119
|
+
UNITY_MCP_FORMAT Output format (default: text)
|
|
120
|
+
UNITY_MCP_INSTANCE Target Unity instance
|
|
121
|
+
"""
|
|
122
|
+
config = CLIConfig(
|
|
123
|
+
host=host,
|
|
124
|
+
port=port,
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
format=format,
|
|
127
|
+
unity_instance=instance,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Security warning for non-localhost connections
|
|
131
|
+
warn_if_remote_host(config)
|
|
132
|
+
|
|
133
|
+
set_config(config)
|
|
134
|
+
ctx.config = config
|
|
135
|
+
ctx.verbose = verbose
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cli.command("status")
|
|
139
|
+
@pass_context
|
|
140
|
+
def status(ctx: Context):
|
|
141
|
+
"""Check connection status to Unity MCP server."""
|
|
142
|
+
config = ctx.config or get_config()
|
|
143
|
+
|
|
144
|
+
click.echo(f"Checking connection to {config.host}:{config.port}...")
|
|
145
|
+
|
|
146
|
+
if run_check_connection(config):
|
|
147
|
+
print_success(
|
|
148
|
+
f"Connected to Unity MCP server at {config.host}:{config.port}")
|
|
149
|
+
|
|
150
|
+
# Try to get Unity instances
|
|
151
|
+
try:
|
|
152
|
+
result = run_list_instances(config)
|
|
153
|
+
instances = result.get("instances", []) if isinstance(
|
|
154
|
+
result, dict) else []
|
|
155
|
+
if instances:
|
|
156
|
+
click.echo("\nConnected Unity instances:")
|
|
157
|
+
for inst in instances:
|
|
158
|
+
project = inst.get("project", "Unknown")
|
|
159
|
+
version = inst.get("unity_version", "Unknown")
|
|
160
|
+
hash_id = inst.get("hash", "")[:8]
|
|
161
|
+
click.echo(f" • {project} (Unity {version}) [{hash_id}]")
|
|
162
|
+
else:
|
|
163
|
+
print_info("No Unity instances currently connected")
|
|
164
|
+
except UnityConnectionError as e:
|
|
165
|
+
print_info(f"Could not retrieve Unity instances: {e}")
|
|
166
|
+
else:
|
|
167
|
+
print_error(
|
|
168
|
+
f"Cannot connect to Unity MCP server at {config.host}:{config.port}")
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@cli.command("instances")
|
|
173
|
+
@pass_context
|
|
174
|
+
def list_instances(ctx: Context):
|
|
175
|
+
"""List available Unity instances."""
|
|
176
|
+
config = ctx.config or get_config()
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
instances = run_list_instances(config)
|
|
180
|
+
click.echo(format_output(instances, config.format))
|
|
181
|
+
except UnityConnectionError as e:
|
|
182
|
+
print_error(str(e))
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@cli.command("raw")
|
|
187
|
+
@click.argument("command_type")
|
|
188
|
+
@click.argument("params", required=False, default="{}")
|
|
189
|
+
@pass_context
|
|
190
|
+
def raw_command(ctx: Context, command_type: str, params: str):
|
|
191
|
+
"""Send a raw command to Unity.
|
|
192
|
+
|
|
193
|
+
\b
|
|
194
|
+
Examples:
|
|
195
|
+
unity-mcp raw manage_scene '{"action": "get_hierarchy"}'
|
|
196
|
+
unity-mcp raw read_console '{"count": 10}'
|
|
197
|
+
"""
|
|
198
|
+
import json
|
|
199
|
+
config = ctx.config or get_config()
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
params_dict = json.loads(params)
|
|
203
|
+
except json.JSONDecodeError as e:
|
|
204
|
+
print_error(f"Invalid JSON params: {e}")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = run_command(command_type, params_dict, config)
|
|
209
|
+
click.echo(format_output(result, config.format))
|
|
210
|
+
except UnityConnectionError as e:
|
|
211
|
+
print_error(str(e))
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Import and register command groups
|
|
216
|
+
# These will be implemented in subsequent TODOs
|
|
217
|
+
def register_commands():
|
|
218
|
+
"""Register all command groups."""
|
|
219
|
+
def register_optional_command(module_name: str, command_name: str) -> None:
|
|
220
|
+
try:
|
|
221
|
+
module = import_module(module_name)
|
|
222
|
+
except ModuleNotFoundError as e:
|
|
223
|
+
if e.name == module_name:
|
|
224
|
+
return
|
|
225
|
+
print_error(
|
|
226
|
+
f"Failed to load command module '{module_name}': {e}"
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
except Exception as e:
|
|
230
|
+
print_error(
|
|
231
|
+
f"Failed to load command module '{module_name}': {e}"
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
command = getattr(module, command_name, None)
|
|
236
|
+
if command is None:
|
|
237
|
+
print_error(
|
|
238
|
+
f"Command '{command_name}' not found in '{module_name}'"
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
cli.add_command(command)
|
|
243
|
+
|
|
244
|
+
optional_commands = [
|
|
245
|
+
("cli.commands.tool", "tool"),
|
|
246
|
+
("cli.commands.tool", "custom_tool"),
|
|
247
|
+
("cli.commands.gameobject", "gameobject"),
|
|
248
|
+
("cli.commands.component", "component"),
|
|
249
|
+
("cli.commands.scene", "scene"),
|
|
250
|
+
("cli.commands.asset", "asset"),
|
|
251
|
+
("cli.commands.script", "script"),
|
|
252
|
+
("cli.commands.code", "code"),
|
|
253
|
+
("cli.commands.editor", "editor"),
|
|
254
|
+
("cli.commands.prefab", "prefab"),
|
|
255
|
+
("cli.commands.material", "material"),
|
|
256
|
+
("cli.commands.lighting", "lighting"),
|
|
257
|
+
("cli.commands.animation", "animation"),
|
|
258
|
+
("cli.commands.audio", "audio"),
|
|
259
|
+
("cli.commands.ui", "ui"),
|
|
260
|
+
("cli.commands.instance", "instance"),
|
|
261
|
+
("cli.commands.shader", "shader"),
|
|
262
|
+
("cli.commands.vfx", "vfx"),
|
|
263
|
+
("cli.commands.batch", "batch"),
|
|
264
|
+
("cli.commands.texture", "texture"),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
for module_name, command_name in optional_commands:
|
|
268
|
+
register_optional_command(module_name, command_name)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Register commands on import
|
|
272
|
+
register_commands()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
"""Main entry point for the CLI."""
|
|
277
|
+
cli()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
main()
|
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,37 @@
|
|
|
1
|
+
"""Confirmation dialog utilities for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def confirm_destructive_action(
|
|
7
|
+
action: str,
|
|
8
|
+
item_type: str,
|
|
9
|
+
item_name: str,
|
|
10
|
+
force: bool,
|
|
11
|
+
extra_context: str = ""
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Prompt user to confirm destructive action unless --force flag is set.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
action: The action being performed (e.g., "Delete", "Remove")
|
|
17
|
+
item_type: The type of item (e.g., "script", "GameObject", "asset")
|
|
18
|
+
item_name: The name/path of the item
|
|
19
|
+
force: If True, skip confirmation prompt
|
|
20
|
+
extra_context: Optional additional context (e.g., "from 'Player'")
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
click.Abort: If user declines confirmation
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
confirm_destructive_action("Delete", "script", "MyScript.cs", force=False)
|
|
27
|
+
# Prompts: "Delete script 'MyScript.cs'?"
|
|
28
|
+
|
|
29
|
+
confirm_destructive_action("Remove", "Rigidbody", "Player", force=False, extra_context="from")
|
|
30
|
+
# Prompts: "Remove Rigidbody from 'Player'?"
|
|
31
|
+
"""
|
|
32
|
+
if not force:
|
|
33
|
+
if extra_context:
|
|
34
|
+
message = f"{action} {item_type} {extra_context} '{item_name}'?"
|
|
35
|
+
else:
|
|
36
|
+
message = f"{action} {item_type} '{item_name}'?"
|
|
37
|
+
click.confirm(message, abort=True)
|
cli/utils/connection.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Connection utilities for CLI to communicate with Unity via MCP server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
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
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_unity_errors(func: F) -> F:
|
|
22
|
+
"""Decorator that handles UnityConnectionError consistently.
|
|
23
|
+
|
|
24
|
+
Wraps a CLI command function and catches UnityConnectionError,
|
|
25
|
+
printing a formatted error message and exiting with code 1.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
@scene.command("active")
|
|
29
|
+
@handle_unity_errors
|
|
30
|
+
def active():
|
|
31
|
+
config = get_config()
|
|
32
|
+
result = run_command("manage_scene", {"action": "get_active"}, config)
|
|
33
|
+
click.echo(format_output(result, config.format))
|
|
34
|
+
"""
|
|
35
|
+
from cli.utils.output import print_error
|
|
36
|
+
|
|
37
|
+
@functools.wraps(func)
|
|
38
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
39
|
+
try:
|
|
40
|
+
return func(*args, **kwargs)
|
|
41
|
+
except UnityConnectionError as e:
|
|
42
|
+
print_error(str(e))
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
return wrapper # type: ignore[return-value]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def warn_if_remote_host(config: CLIConfig) -> None:
|
|
49
|
+
"""Warn user if connecting to a non-localhost server.
|
|
50
|
+
|
|
51
|
+
This is a security measure to alert users that connecting to remote
|
|
52
|
+
servers exposes Unity control to potential network attacks.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
config: CLI configuration with host setting
|
|
56
|
+
"""
|
|
57
|
+
import click
|
|
58
|
+
|
|
59
|
+
local_hosts = ("127.0.0.1", "localhost", "::1", "0.0.0.0")
|
|
60
|
+
if config.host.lower() not in local_hosts:
|
|
61
|
+
click.echo(
|
|
62
|
+
"⚠️ Security Warning: Connecting to non-localhost server.\n"
|
|
63
|
+
" The MCP CLI has no authentication. Anyone on the network could\n"
|
|
64
|
+
" intercept commands or send unauthorized commands to Unity.\n"
|
|
65
|
+
" Only proceed if you trust this network.\n",
|
|
66
|
+
err=True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def send_command(
|
|
71
|
+
command_type: str,
|
|
72
|
+
params: Dict[str, Any],
|
|
73
|
+
config: Optional[CLIConfig] = None,
|
|
74
|
+
timeout: Optional[int] = None,
|
|
75
|
+
) -> Dict[str, Any]:
|
|
76
|
+
"""Send a command to Unity via the MCP HTTP server.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
command_type: The command type (e.g., 'manage_gameobject', 'manage_scene')
|
|
80
|
+
params: Command parameters
|
|
81
|
+
config: Optional CLI configuration
|
|
82
|
+
timeout: Optional timeout override
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Response dict from Unity
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
UnityConnectionError: If connection fails
|
|
89
|
+
"""
|
|
90
|
+
cfg = config or get_config()
|
|
91
|
+
url = f"http://{cfg.host}:{cfg.port}/api/command"
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"type": command_type,
|
|
95
|
+
"params": params,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if cfg.unity_instance:
|
|
99
|
+
payload["unity_instance"] = cfg.unity_instance
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
async with httpx.AsyncClient() as client:
|
|
103
|
+
response = await client.post(
|
|
104
|
+
url,
|
|
105
|
+
json=payload,
|
|
106
|
+
timeout=timeout or cfg.timeout,
|
|
107
|
+
)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
return response.json()
|
|
110
|
+
except httpx.ConnectError as e:
|
|
111
|
+
raise UnityConnectionError(
|
|
112
|
+
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
|
|
113
|
+
f"Make sure the server is running and Unity is connected.\n"
|
|
114
|
+
f"Error: {e}"
|
|
115
|
+
)
|
|
116
|
+
except httpx.TimeoutException:
|
|
117
|
+
raise UnityConnectionError(
|
|
118
|
+
f"Connection to Unity timed out after {timeout or cfg.timeout}s. "
|
|
119
|
+
f"Unity may be busy or unresponsive."
|
|
120
|
+
)
|
|
121
|
+
except httpx.HTTPStatusError as e:
|
|
122
|
+
raise UnityConnectionError(
|
|
123
|
+
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
|
|
124
|
+
)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise UnityConnectionError(f"Unexpected error: {e}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_command(
|
|
130
|
+
command_type: str,
|
|
131
|
+
params: Dict[str, Any],
|
|
132
|
+
config: Optional[CLIConfig] = None,
|
|
133
|
+
timeout: Optional[int] = None,
|
|
134
|
+
) -> Dict[str, Any]:
|
|
135
|
+
"""Synchronous wrapper for send_command.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
command_type: The command type
|
|
139
|
+
params: Command parameters
|
|
140
|
+
config: Optional CLI configuration
|
|
141
|
+
timeout: Optional timeout override
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Response dict from Unity
|
|
145
|
+
"""
|
|
146
|
+
return asyncio.run(send_command(command_type, params, config, timeout))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def check_connection(config: Optional[CLIConfig] = None) -> bool:
|
|
150
|
+
"""Check if we can connect to the Unity MCP server.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
config: Optional CLI configuration
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if connection successful, False otherwise
|
|
157
|
+
"""
|
|
158
|
+
cfg = config or get_config()
|
|
159
|
+
url = f"http://{cfg.host}:{cfg.port}/health"
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
async with httpx.AsyncClient() as client:
|
|
163
|
+
response = await client.get(url, timeout=5)
|
|
164
|
+
return response.status_code == 200
|
|
165
|
+
except Exception:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_check_connection(config: Optional[CLIConfig] = None) -> bool:
|
|
170
|
+
"""Synchronous wrapper for check_connection."""
|
|
171
|
+
return asyncio.run(check_connection(config))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def list_unity_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
175
|
+
"""List available Unity instances.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
config: Optional CLI configuration
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Dict with list of Unity instances
|
|
182
|
+
"""
|
|
183
|
+
cfg = config or get_config()
|
|
184
|
+
|
|
185
|
+
# Try the new /api/instances endpoint first, fall back to /plugin/sessions
|
|
186
|
+
urls_to_try = [
|
|
187
|
+
f"http://{cfg.host}:{cfg.port}/api/instances",
|
|
188
|
+
f"http://{cfg.host}:{cfg.port}/plugin/sessions",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
async with httpx.AsyncClient() as client:
|
|
192
|
+
for url in urls_to_try:
|
|
193
|
+
try:
|
|
194
|
+
response = await client.get(url, timeout=10)
|
|
195
|
+
if response.status_code == 200:
|
|
196
|
+
data = response.json()
|
|
197
|
+
# Normalize response format
|
|
198
|
+
if "instances" in data:
|
|
199
|
+
return data
|
|
200
|
+
elif "sessions" in data:
|
|
201
|
+
# Convert sessions format to instances format
|
|
202
|
+
instances = []
|
|
203
|
+
for session_id, details in data["sessions"].items():
|
|
204
|
+
instances.append({
|
|
205
|
+
"session_id": session_id,
|
|
206
|
+
"project": details.get("project", "Unknown"),
|
|
207
|
+
"hash": details.get("hash", ""),
|
|
208
|
+
"unity_version": details.get("unity_version", "Unknown"),
|
|
209
|
+
"connected_at": details.get("connected_at", ""),
|
|
210
|
+
})
|
|
211
|
+
return {"success": True, "instances": instances}
|
|
212
|
+
except Exception:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
raise UnityConnectionError(
|
|
216
|
+
"Failed to list Unity instances: No working endpoint found")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run_list_instances(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
220
|
+
"""Synchronous wrapper for list_unity_instances."""
|
|
221
|
+
return asyncio.run(list_unity_instances(config))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
225
|
+
"""List custom tools registered for the active Unity project."""
|
|
226
|
+
cfg = config or get_config()
|
|
227
|
+
url = f"http://{cfg.host}:{cfg.port}/api/custom-tools"
|
|
228
|
+
params: Dict[str, Any] = {}
|
|
229
|
+
if cfg.unity_instance:
|
|
230
|
+
params["instance"] = cfg.unity_instance
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
async with httpx.AsyncClient() as client:
|
|
234
|
+
response = await client.get(url, params=params, timeout=cfg.timeout)
|
|
235
|
+
response.raise_for_status()
|
|
236
|
+
return response.json()
|
|
237
|
+
except httpx.ConnectError as e:
|
|
238
|
+
raise UnityConnectionError(
|
|
239
|
+
f"Cannot connect to Unity MCP server at {cfg.host}:{cfg.port}. "
|
|
240
|
+
f"Make sure the server is running and Unity is connected.\n"
|
|
241
|
+
f"Error: {e}"
|
|
242
|
+
)
|
|
243
|
+
except httpx.TimeoutException:
|
|
244
|
+
raise UnityConnectionError(
|
|
245
|
+
f"Connection to Unity timed out after {cfg.timeout}s. "
|
|
246
|
+
f"Unity may be busy or unresponsive."
|
|
247
|
+
)
|
|
248
|
+
except httpx.HTTPStatusError as e:
|
|
249
|
+
raise UnityConnectionError(
|
|
250
|
+
f"HTTP error from server: {e.response.status_code} - {e.response.text}"
|
|
251
|
+
)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise UnityConnectionError(f"Unexpected error: {e}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def run_list_custom_tools(config: Optional[CLIConfig] = None) -> Dict[str, Any]:
|
|
257
|
+
"""Synchronous wrapper for list_custom_tools."""
|
|
258
|
+
return asyncio.run(list_custom_tools(config))
|
cli/utils/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Common constants for CLI commands."""
|
|
2
|
+
import click
|
|
3
|
+
|
|
4
|
+
# Search method constants used across various CLI commands
|
|
5
|
+
# These define how GameObjects and other Unity objects can be located
|
|
6
|
+
|
|
7
|
+
# Full set of search methods (used by gameobject commands)
|
|
8
|
+
SEARCH_METHODS_FULL = ["by_name", "by_path", "by_id", "by_tag", "by_layer", "by_component"]
|
|
9
|
+
|
|
10
|
+
# Basic search methods (used by component, animation, audio commands)
|
|
11
|
+
SEARCH_METHODS_BASIC = ["by_id", "by_name", "by_path"]
|
|
12
|
+
|
|
13
|
+
# Extended search methods for renderer-based commands (material commands)
|
|
14
|
+
SEARCH_METHODS_RENDERER = ["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"]
|
|
15
|
+
|
|
16
|
+
# Tagged search methods (used by VFX commands)
|
|
17
|
+
SEARCH_METHODS_TAGGED = ["by_name", "by_path", "by_id", "by_tag"]
|
|
18
|
+
|
|
19
|
+
# Click choice options for each set
|
|
20
|
+
SEARCH_METHOD_CHOICE_FULL = click.Choice(SEARCH_METHODS_FULL)
|
|
21
|
+
SEARCH_METHOD_CHOICE_BASIC = click.Choice(SEARCH_METHODS_BASIC)
|
|
22
|
+
SEARCH_METHOD_CHOICE_RENDERER = click.Choice(SEARCH_METHODS_RENDERER)
|
|
23
|
+
SEARCH_METHOD_CHOICE_TAGGED = click.Choice(SEARCH_METHODS_TAGGED)
|