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.
- 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 +254 -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 +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -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 +48 -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 +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -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 +209 -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 +120 -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 +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Annotated, Literal, Optional
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
from fastmcp import Context
|
|
5
|
+
|
|
6
|
+
from models import MCPResponse
|
|
7
|
+
from models.unity_response import parse_resource_response
|
|
8
|
+
from services.registry import mcp_for_unity_resource
|
|
9
|
+
from services.tools import get_unity_instance_from_context
|
|
10
|
+
from transport.unity_transport import send_with_unity_instance
|
|
11
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestItem(BaseModel):
|
|
15
|
+
name: Annotated[str, Field(description="The name of the test.")]
|
|
16
|
+
full_name: Annotated[str, Field(description="The full name of the test.")]
|
|
17
|
+
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
18
|
+
Field(description="The mode the test is for.")]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaginatedTestsData(BaseModel):
|
|
22
|
+
"""Paginated test results."""
|
|
23
|
+
items: list[TestItem] = Field(description="Tests on current page")
|
|
24
|
+
cursor: int = Field(description="Current page cursor (0-based)")
|
|
25
|
+
nextCursor: Optional[int] = Field(None, description="Next page cursor, null if last page")
|
|
26
|
+
totalCount: int = Field(description="Total number of tests across all pages")
|
|
27
|
+
pageSize: int = Field(description="Number of items per page")
|
|
28
|
+
hasMore: bool = Field(description="Whether there are more items after this page")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GetTestsResponse(MCPResponse):
|
|
32
|
+
"""Response containing paginated test data."""
|
|
33
|
+
data: PaginatedTestsData = Field(description="Paginated test data")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp_for_unity_resource(
|
|
37
|
+
uri="mcpforunity://tests",
|
|
38
|
+
name="get_tests",
|
|
39
|
+
description="Provides the first page of Unity tests (default 50 items). "
|
|
40
|
+
"For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests"
|
|
41
|
+
)
|
|
42
|
+
async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
|
|
43
|
+
"""Provides a paginated list of all Unity tests.
|
|
44
|
+
|
|
45
|
+
Returns the first page of tests using Unity's default pagination (50 items).
|
|
46
|
+
For advanced filtering or pagination control, use the run_tests tool which
|
|
47
|
+
accepts mode, filter, page_size, and cursor parameters.
|
|
48
|
+
"""
|
|
49
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
50
|
+
|
|
51
|
+
response = await send_with_unity_instance(
|
|
52
|
+
async_send_command_with_retry,
|
|
53
|
+
unity_instance,
|
|
54
|
+
"get_tests",
|
|
55
|
+
{},
|
|
56
|
+
)
|
|
57
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp_for_unity_resource(
|
|
61
|
+
uri="mcpforunity://tests/{mode}",
|
|
62
|
+
name="get_tests_for_mode",
|
|
63
|
+
description="Provides the first page of tests for a specific mode (EditMode or PlayMode). "
|
|
64
|
+
"For filtering or pagination, use the run_tests tool instead.\n\nURI: mcpforunity://tests/{mode}"
|
|
65
|
+
)
|
|
66
|
+
async def get_tests_for_mode(
|
|
67
|
+
ctx: Context,
|
|
68
|
+
mode: Annotated[Literal["EditMode", "PlayMode"], Field(
|
|
69
|
+
description="The mode to filter tests by (EditMode or PlayMode)."
|
|
70
|
+
)],
|
|
71
|
+
) -> GetTestsResponse | MCPResponse:
|
|
72
|
+
"""Provides the first page of tests for a specific mode.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
mode: The test mode to filter by (EditMode or PlayMode)
|
|
76
|
+
|
|
77
|
+
Returns the first page of tests using Unity's default pagination (50 items).
|
|
78
|
+
For advanced filtering or pagination control, use the run_tests tool.
|
|
79
|
+
"""
|
|
80
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
81
|
+
|
|
82
|
+
response = await send_with_unity_instance(
|
|
83
|
+
async_send_command_with_retry,
|
|
84
|
+
unity_instance,
|
|
85
|
+
"get_tests_for_mode",
|
|
86
|
+
{"mode": mode},
|
|
87
|
+
)
|
|
88
|
+
return parse_resource_response(response, GetTestsResponse)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resource to list all available Unity Editor instances.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context
|
|
7
|
+
from services.registry import mcp_for_unity_resource
|
|
8
|
+
from transport.legacy.unity_connection import get_unity_connection_pool
|
|
9
|
+
from transport.plugin_hub import PluginHub
|
|
10
|
+
from core.config import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp_for_unity_resource(
|
|
14
|
+
uri="mcpforunity://instances",
|
|
15
|
+
name="unity_instances",
|
|
16
|
+
description="Lists all running Unity Editor instances with their details.\n\nURI: mcpforunity://instances"
|
|
17
|
+
)
|
|
18
|
+
async def unity_instances(ctx: Context) -> dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
List all available Unity Editor instances.
|
|
21
|
+
|
|
22
|
+
Returns information about each instance including:
|
|
23
|
+
- id: Unique identifier (ProjectName@hash)
|
|
24
|
+
- name: Project name
|
|
25
|
+
- path: Full project path (stdio only)
|
|
26
|
+
- hash: 8-character hash of project path
|
|
27
|
+
- port: TCP port number (stdio only)
|
|
28
|
+
- status: Current status (running, reloading, etc.) (stdio only)
|
|
29
|
+
- last_heartbeat: Last heartbeat timestamp (stdio only)
|
|
30
|
+
- unity_version: Unity version (if available)
|
|
31
|
+
- connected_at: Connection timestamp (HTTP only)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Dictionary containing list of instances and metadata
|
|
35
|
+
"""
|
|
36
|
+
await ctx.info("Listing Unity instances")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
transport = (config.transport_mode or "stdio").lower()
|
|
40
|
+
if transport == "http":
|
|
41
|
+
# HTTP/WebSocket transport: query PluginHub
|
|
42
|
+
# In remote-hosted mode, filter sessions by user_id
|
|
43
|
+
user_id = ctx.get_state(
|
|
44
|
+
"user_id") if config.http_remote_hosted else None
|
|
45
|
+
sessions_data = await PluginHub.get_sessions(user_id=user_id)
|
|
46
|
+
sessions = sessions_data.sessions
|
|
47
|
+
|
|
48
|
+
instances = []
|
|
49
|
+
for session_id, session_info in sessions.items():
|
|
50
|
+
project = session_info.project
|
|
51
|
+
project_hash = session_info.hash
|
|
52
|
+
|
|
53
|
+
if not project or not project_hash:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"PluginHub session missing required 'project' or 'hash' fields."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
instances.append({
|
|
59
|
+
"id": f"{project}@{project_hash}",
|
|
60
|
+
"name": project,
|
|
61
|
+
"hash": project_hash,
|
|
62
|
+
"unity_version": session_info.unity_version,
|
|
63
|
+
"connected_at": session_info.connected_at,
|
|
64
|
+
"session_id": session_id,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
# Check for duplicate project names
|
|
68
|
+
name_counts = {}
|
|
69
|
+
for inst in instances:
|
|
70
|
+
name_counts[inst["name"]] = name_counts.get(
|
|
71
|
+
inst["name"], 0) + 1
|
|
72
|
+
|
|
73
|
+
duplicates = [name for name,
|
|
74
|
+
count in name_counts.items() if count > 1]
|
|
75
|
+
|
|
76
|
+
result = {
|
|
77
|
+
"success": True,
|
|
78
|
+
"transport": transport,
|
|
79
|
+
"instance_count": len(instances),
|
|
80
|
+
"instances": instances,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if duplicates:
|
|
84
|
+
result["warning"] = (
|
|
85
|
+
f"Multiple instances found with duplicate project names: {duplicates}. "
|
|
86
|
+
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return result
|
|
90
|
+
else:
|
|
91
|
+
# Stdio/TCP transport: query connection pool
|
|
92
|
+
pool = get_unity_connection_pool()
|
|
93
|
+
instances = pool.discover_all_instances(force_refresh=False)
|
|
94
|
+
|
|
95
|
+
# Check for duplicate project names
|
|
96
|
+
name_counts = {}
|
|
97
|
+
for inst in instances:
|
|
98
|
+
name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
|
|
99
|
+
|
|
100
|
+
duplicates = [name for name,
|
|
101
|
+
count in name_counts.items() if count > 1]
|
|
102
|
+
|
|
103
|
+
result = {
|
|
104
|
+
"success": True,
|
|
105
|
+
"transport": transport,
|
|
106
|
+
"instance_count": len(instances),
|
|
107
|
+
"instances": [inst.to_dict() for inst in instances],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if duplicates:
|
|
111
|
+
result["warning"] = (
|
|
112
|
+
f"Multiple instances found with duplicate project names: {duplicates}. "
|
|
113
|
+
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
await ctx.error(f"Error listing Unity instances: {e}")
|
|
120
|
+
return {
|
|
121
|
+
"success": False,
|
|
122
|
+
"error": f"Failed to list Unity instances: {str(e)}",
|
|
123
|
+
"instance_count": 0,
|
|
124
|
+
"instances": []
|
|
125
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from models.unity_response import parse_resource_response
|
|
6
|
+
from services.registry import mcp_for_unity_resource
|
|
7
|
+
from services.tools import get_unity_instance_from_context
|
|
8
|
+
from transport.unity_transport import send_with_unity_instance
|
|
9
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WindowPosition(BaseModel):
|
|
13
|
+
"""Window position and size."""
|
|
14
|
+
x: float = 0.0
|
|
15
|
+
y: float = 0.0
|
|
16
|
+
width: float = 0.0
|
|
17
|
+
height: float = 0.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WindowInfo(BaseModel):
|
|
21
|
+
"""Information about an editor window."""
|
|
22
|
+
title: str = ""
|
|
23
|
+
typeName: str = ""
|
|
24
|
+
isFocused: bool = False
|
|
25
|
+
position: WindowPosition = WindowPosition()
|
|
26
|
+
instanceID: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WindowsResponse(MCPResponse):
|
|
30
|
+
"""List of all open editor windows."""
|
|
31
|
+
data: list[WindowInfo] = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp_for_unity_resource(
|
|
35
|
+
uri="mcpforunity://editor/windows",
|
|
36
|
+
name="editor_windows",
|
|
37
|
+
description="All currently open editor windows with their titles, types, positions, and focus state.\n\nURI: mcpforunity://editor/windows"
|
|
38
|
+
)
|
|
39
|
+
async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
|
|
40
|
+
"""Get all open editor windows."""
|
|
41
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
42
|
+
response = await send_with_unity_instance(
|
|
43
|
+
async_send_command_with_retry,
|
|
44
|
+
unity_instance,
|
|
45
|
+
"get_windows",
|
|
46
|
+
{}
|
|
47
|
+
)
|
|
48
|
+
return parse_resource_response(response, WindowsResponse)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _now_unix_ms() -> int:
|
|
12
|
+
return int(time.time() * 1000)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _in_pytest() -> bool:
|
|
16
|
+
# Keep scanner inert during the Python integration suite unless explicitly invoked.
|
|
17
|
+
return bool(os.environ.get("PYTEST_CURRENT_TEST"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExternalChangesState:
|
|
22
|
+
project_root: str | None = None
|
|
23
|
+
last_scan_unix_ms: int | None = None
|
|
24
|
+
last_seen_mtime_ns: int | None = None
|
|
25
|
+
dirty: bool = False
|
|
26
|
+
dirty_since_unix_ms: int | None = None
|
|
27
|
+
external_changes_last_seen_unix_ms: int | None = None
|
|
28
|
+
last_cleared_unix_ms: int | None = None
|
|
29
|
+
# Cached package roots referenced by Packages/manifest.json "file:" dependencies
|
|
30
|
+
extra_roots: list[str] | None = None
|
|
31
|
+
manifest_last_mtime_ns: int | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExternalChangesScanner:
|
|
35
|
+
"""
|
|
36
|
+
Lightweight external-changes detector using recursive max-mtime scan.
|
|
37
|
+
|
|
38
|
+
This is intentionally conservative:
|
|
39
|
+
- It only marks dirty when it sees a strictly newer mtime than the baseline.
|
|
40
|
+
- It scans at most once per scan_interval_ms per instance to keep overhead bounded.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, scan_interval_ms: int = 1500, max_entries: int = 20000):
|
|
44
|
+
self._states: dict[str, ExternalChangesState] = {}
|
|
45
|
+
self._scan_interval_ms = int(scan_interval_ms)
|
|
46
|
+
self._max_entries = int(max_entries)
|
|
47
|
+
|
|
48
|
+
def _get_state(self, instance_id: str) -> ExternalChangesState:
|
|
49
|
+
return self._states.setdefault(instance_id, ExternalChangesState())
|
|
50
|
+
|
|
51
|
+
def set_project_root(self, instance_id: str, project_root: str | None) -> None:
|
|
52
|
+
st = self._get_state(instance_id)
|
|
53
|
+
if project_root:
|
|
54
|
+
st.project_root = project_root
|
|
55
|
+
|
|
56
|
+
def clear_dirty(self, instance_id: str) -> None:
|
|
57
|
+
st = self._get_state(instance_id)
|
|
58
|
+
st.dirty = False
|
|
59
|
+
st.dirty_since_unix_ms = None
|
|
60
|
+
st.last_cleared_unix_ms = _now_unix_ms()
|
|
61
|
+
# Reset baseline to “now” on next scan.
|
|
62
|
+
st.last_seen_mtime_ns = None
|
|
63
|
+
|
|
64
|
+
def _scan_paths_max_mtime_ns(self, roots: Iterable[Path]) -> int | None:
|
|
65
|
+
newest: int | None = None
|
|
66
|
+
entries = 0
|
|
67
|
+
|
|
68
|
+
for root in roots:
|
|
69
|
+
if not root.exists():
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Walk the tree; skip common massive/irrelevant dirs (Library/Temp/Logs).
|
|
73
|
+
for dirpath, dirnames, filenames in os.walk(str(root)):
|
|
74
|
+
entries += 1
|
|
75
|
+
if entries > self._max_entries:
|
|
76
|
+
return newest
|
|
77
|
+
|
|
78
|
+
dp = Path(dirpath)
|
|
79
|
+
name = dp.name.lower()
|
|
80
|
+
if name in {"library", "temp", "logs", "obj", ".git", "node_modules"}:
|
|
81
|
+
dirnames[:] = []
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Allow skipping hidden directories quickly
|
|
85
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
86
|
+
|
|
87
|
+
for fn in filenames:
|
|
88
|
+
if fn.startswith("."):
|
|
89
|
+
continue
|
|
90
|
+
entries += 1
|
|
91
|
+
if entries > self._max_entries:
|
|
92
|
+
return newest
|
|
93
|
+
p = dp / fn
|
|
94
|
+
try:
|
|
95
|
+
stat = p.stat()
|
|
96
|
+
except OSError:
|
|
97
|
+
continue
|
|
98
|
+
m = getattr(stat, "st_mtime_ns", None)
|
|
99
|
+
if m is None:
|
|
100
|
+
# Fallback when st_mtime_ns is unavailable
|
|
101
|
+
m = int(stat.st_mtime * 1_000_000_000)
|
|
102
|
+
newest = m if newest is None else max(newest, int(m))
|
|
103
|
+
|
|
104
|
+
return newest
|
|
105
|
+
|
|
106
|
+
def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesState) -> list[Path]:
|
|
107
|
+
"""
|
|
108
|
+
Parse Packages/manifest.json for local file: dependencies and resolve them to absolute paths.
|
|
109
|
+
Returns a list of Paths that exist and are directories.
|
|
110
|
+
"""
|
|
111
|
+
manifest_path = project_root / "Packages" / "manifest.json"
|
|
112
|
+
try:
|
|
113
|
+
stat = manifest_path.stat()
|
|
114
|
+
except OSError:
|
|
115
|
+
st.extra_roots = []
|
|
116
|
+
st.manifest_last_mtime_ns = None
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
mtime_ns = getattr(stat, "st_mtime_ns", int(
|
|
120
|
+
stat.st_mtime * 1_000_000_000))
|
|
121
|
+
if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns:
|
|
122
|
+
return [Path(p) for p in st.extra_roots if p]
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
raw = manifest_path.read_text(encoding="utf-8")
|
|
126
|
+
doc = json.loads(raw)
|
|
127
|
+
except Exception:
|
|
128
|
+
st.extra_roots = []
|
|
129
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
deps = doc.get("dependencies") if isinstance(doc, dict) else None
|
|
133
|
+
if not isinstance(deps, dict):
|
|
134
|
+
st.extra_roots = []
|
|
135
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
roots: list[str] = []
|
|
139
|
+
base_dir = manifest_path.parent
|
|
140
|
+
|
|
141
|
+
for _, ver in deps.items():
|
|
142
|
+
if not isinstance(ver, str):
|
|
143
|
+
continue
|
|
144
|
+
v = ver.strip()
|
|
145
|
+
if not v.startswith("file:"):
|
|
146
|
+
continue
|
|
147
|
+
suffix = v[len("file:"):].strip()
|
|
148
|
+
# Handle file:///abs/path or file:/abs/path
|
|
149
|
+
if suffix.startswith("///"):
|
|
150
|
+
candidate = Path("/" + suffix.lstrip("/"))
|
|
151
|
+
elif suffix.startswith("/"):
|
|
152
|
+
candidate = Path(suffix)
|
|
153
|
+
else:
|
|
154
|
+
candidate = (base_dir / suffix).resolve()
|
|
155
|
+
try:
|
|
156
|
+
if candidate.exists() and candidate.is_dir():
|
|
157
|
+
roots.append(str(candidate))
|
|
158
|
+
except OSError:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
# De-dupe, preserve order
|
|
162
|
+
deduped: list[str] = []
|
|
163
|
+
seen = set()
|
|
164
|
+
for r in roots:
|
|
165
|
+
if r not in seen:
|
|
166
|
+
seen.add(r)
|
|
167
|
+
deduped.append(r)
|
|
168
|
+
|
|
169
|
+
st.extra_roots = deduped
|
|
170
|
+
st.manifest_last_mtime_ns = mtime_ns
|
|
171
|
+
return [Path(p) for p in deduped if p]
|
|
172
|
+
|
|
173
|
+
def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]:
|
|
174
|
+
"""
|
|
175
|
+
Returns a small dict suitable for embedding in editor_state_v2.assets:
|
|
176
|
+
- external_changes_dirty
|
|
177
|
+
- external_changes_last_seen_unix_ms
|
|
178
|
+
- dirty_since_unix_ms
|
|
179
|
+
- last_cleared_unix_ms
|
|
180
|
+
"""
|
|
181
|
+
st = self._get_state(instance_id)
|
|
182
|
+
|
|
183
|
+
if _in_pytest():
|
|
184
|
+
return {
|
|
185
|
+
"external_changes_dirty": st.dirty,
|
|
186
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
187
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
188
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
now = _now_unix_ms()
|
|
192
|
+
if st.last_scan_unix_ms is not None and (now - st.last_scan_unix_ms) < self._scan_interval_ms:
|
|
193
|
+
return {
|
|
194
|
+
"external_changes_dirty": st.dirty,
|
|
195
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
196
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
197
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
st.last_scan_unix_ms = now
|
|
201
|
+
|
|
202
|
+
project_root = st.project_root
|
|
203
|
+
if not project_root:
|
|
204
|
+
return {
|
|
205
|
+
"external_changes_dirty": st.dirty,
|
|
206
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
207
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
208
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
root = Path(project_root)
|
|
212
|
+
paths = [root / "Assets", root / "ProjectSettings", root / "Packages"]
|
|
213
|
+
# Include any local package roots referenced by file: deps in Packages/manifest.json
|
|
214
|
+
try:
|
|
215
|
+
paths.extend(self._resolve_manifest_extra_roots(root, st))
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
newest = self._scan_paths_max_mtime_ns(paths)
|
|
219
|
+
if newest is None:
|
|
220
|
+
return {
|
|
221
|
+
"external_changes_dirty": st.dirty,
|
|
222
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
223
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
224
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if st.last_seen_mtime_ns is None:
|
|
228
|
+
st.last_seen_mtime_ns = newest
|
|
229
|
+
elif newest > st.last_seen_mtime_ns:
|
|
230
|
+
st.last_seen_mtime_ns = newest
|
|
231
|
+
st.external_changes_last_seen_unix_ms = now
|
|
232
|
+
if not st.dirty:
|
|
233
|
+
st.dirty = True
|
|
234
|
+
st.dirty_since_unix_ms = now
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"external_changes_dirty": st.dirty,
|
|
238
|
+
"external_changes_last_seen_unix_ms": st.external_changes_last_seen_unix_ms,
|
|
239
|
+
"dirty_since_unix_ms": st.dirty_since_unix_ms,
|
|
240
|
+
"last_cleared_unix_ms": st.last_cleared_unix_ms,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Global singleton (simple, process-local)
|
|
245
|
+
external_changes_scanner = ExternalChangesScanner()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""MCP tools package - auto-discovery and Unity routing helpers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context, FastMCP
|
|
9
|
+
from core.telemetry_decorator import telemetry_tool
|
|
10
|
+
from core.logging_decorator import log_execution
|
|
11
|
+
from utils.module_discovery import discover_modules
|
|
12
|
+
from services.registry import get_registered_tools
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
15
|
+
|
|
16
|
+
# Export decorator and helpers for easy imports within tools
|
|
17
|
+
__all__ = [
|
|
18
|
+
"register_all_tools",
|
|
19
|
+
"get_unity_instance_from_context",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
|
|
24
|
+
"""
|
|
25
|
+
Auto-discover and register all tools in the tools/ directory.
|
|
26
|
+
|
|
27
|
+
Any .py file in this directory or subdirectories with @mcp_for_unity_tool decorated
|
|
28
|
+
functions will be automatically registered.
|
|
29
|
+
"""
|
|
30
|
+
logger.info("Auto-discovering MCP for Unity Server tools...")
|
|
31
|
+
# Dynamic import of all modules in this directory
|
|
32
|
+
tools_dir = Path(__file__).parent
|
|
33
|
+
|
|
34
|
+
# Discover and import all modules
|
|
35
|
+
list(discover_modules(tools_dir, __package__))
|
|
36
|
+
|
|
37
|
+
tools = get_registered_tools()
|
|
38
|
+
|
|
39
|
+
if not tools:
|
|
40
|
+
logger.warning("No MCP tools registered!")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
for tool_info in tools:
|
|
44
|
+
func = tool_info['func']
|
|
45
|
+
tool_name = tool_info['name']
|
|
46
|
+
description = tool_info['description']
|
|
47
|
+
kwargs = tool_info['kwargs']
|
|
48
|
+
|
|
49
|
+
if not project_scoped_tools and tool_name == "execute_custom_tool":
|
|
50
|
+
logger.info(
|
|
51
|
+
"Skipping execute_custom_tool registration (project-scoped tools disabled)")
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Apply decorators: logging -> telemetry -> mcp.tool
|
|
55
|
+
# Note: Parameter normalization (camelCase -> snake_case) is handled by
|
|
56
|
+
# ParamNormalizerMiddleware before FastMCP validation
|
|
57
|
+
wrapped = log_execution(tool_name, "Tool")(func)
|
|
58
|
+
wrapped = telemetry_tool(tool_name)(wrapped)
|
|
59
|
+
wrapped = mcp.tool(
|
|
60
|
+
name=tool_name, description=description, **kwargs)(wrapped)
|
|
61
|
+
tool_info['func'] = wrapped
|
|
62
|
+
logger.debug(f"Registered tool: {tool_name} - {description}")
|
|
63
|
+
|
|
64
|
+
logger.info(f"Registered {len(tools)} MCP tools")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_unity_instance_from_context(
|
|
68
|
+
ctx: Context,
|
|
69
|
+
key: str = "unity_instance",
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""Extract the unity_instance value from middleware state.
|
|
72
|
+
|
|
73
|
+
The instance is set via the set_active_instance tool and injected into
|
|
74
|
+
request state by UnityInstanceMiddleware.
|
|
75
|
+
"""
|
|
76
|
+
get_state_fn = getattr(ctx, "get_state", None)
|
|
77
|
+
if callable(get_state_fn):
|
|
78
|
+
try:
|
|
79
|
+
return get_state_fn(key)
|
|
80
|
+
except Exception: # pragma: no cover - defensive
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
return None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Defines the batch_execute tool for orchestrating multiple Unity MCP commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context
|
|
7
|
+
from mcp.types import ToolAnnotations
|
|
8
|
+
|
|
9
|
+
from services.registry import mcp_for_unity_tool
|
|
10
|
+
from services.tools import get_unity_instance_from_context
|
|
11
|
+
from transport.unity_transport import send_with_unity_instance
|
|
12
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
13
|
+
|
|
14
|
+
MAX_COMMANDS_PER_BATCH = 25
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@mcp_for_unity_tool(
|
|
18
|
+
name="batch_execute",
|
|
19
|
+
description=(
|
|
20
|
+
"Executes multiple MCP commands in a single batch for dramatically better performance. "
|
|
21
|
+
"STRONGLY RECOMMENDED when creating/modifying multiple objects, adding components to multiple targets, "
|
|
22
|
+
"or performing any repetitive operations. Reduces latency and token costs by 10-100x compared to "
|
|
23
|
+
"sequential tool calls. Supports up to 25 commands per batch. "
|
|
24
|
+
"Example: creating 5 cubes → use 1 batch_execute with 5 create commands instead of 5 separate calls."
|
|
25
|
+
),
|
|
26
|
+
annotations=ToolAnnotations(
|
|
27
|
+
title="Batch Execute",
|
|
28
|
+
destructiveHint=True,
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
async def batch_execute(
|
|
32
|
+
ctx: Context,
|
|
33
|
+
commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."],
|
|
34
|
+
parallel: Annotated[bool | None,
|
|
35
|
+
"Attempt to run read-only commands in parallel"] = None,
|
|
36
|
+
fail_fast: Annotated[bool | None,
|
|
37
|
+
"Stop processing after the first failure"] = None,
|
|
38
|
+
max_parallelism: Annotated[int | None,
|
|
39
|
+
"Hint for the maximum number of parallel workers"] = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Proxy the batch_execute tool to the Unity Editor transporter."""
|
|
42
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
43
|
+
|
|
44
|
+
if not isinstance(commands, list) or not commands:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"'commands' must be a non-empty list of command specifications")
|
|
47
|
+
|
|
48
|
+
if len(commands) > MAX_COMMANDS_PER_BATCH:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"batch_execute currently supports up to {MAX_COMMANDS_PER_BATCH} commands; received {len(commands)}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
normalized_commands: list[dict[str, Any]] = []
|
|
54
|
+
for index, command in enumerate(commands):
|
|
55
|
+
if not isinstance(command, dict):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Command at index {index} must be an object with 'tool' and 'params' keys")
|
|
58
|
+
|
|
59
|
+
tool_name = command.get("tool")
|
|
60
|
+
params = command.get("params", {})
|
|
61
|
+
|
|
62
|
+
if not tool_name or not isinstance(tool_name, str):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Command at index {index} is missing a valid 'tool' name")
|
|
65
|
+
|
|
66
|
+
if params is None:
|
|
67
|
+
params = {}
|
|
68
|
+
if not isinstance(params, dict):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Command '{tool_name}' must specify parameters as an object/dict")
|
|
71
|
+
|
|
72
|
+
normalized_commands.append({
|
|
73
|
+
"tool": tool_name,
|
|
74
|
+
"params": params,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
payload: dict[str, Any] = {
|
|
78
|
+
"commands": normalized_commands,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if parallel is not None:
|
|
82
|
+
payload["parallel"] = bool(parallel)
|
|
83
|
+
if fail_fast is not None:
|
|
84
|
+
payload["failFast"] = bool(fail_fast)
|
|
85
|
+
if max_parallelism is not None:
|
|
86
|
+
payload["maxParallelism"] = int(max_parallelism)
|
|
87
|
+
|
|
88
|
+
return await send_with_unity_instance(
|
|
89
|
+
async_send_command_with_retry,
|
|
90
|
+
unity_instance,
|
|
91
|
+
"batch_execute",
|
|
92
|
+
payload,
|
|
93
|
+
)
|