mcpforunityserver 8.2.3__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.
- __init__.py +0 -0
- core/__init__.py +0 -0
- core/config.py +56 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +533 -0
- core/telemetry_decorator.py +164 -0
- main.py +411 -0
- mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
- mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
- mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
- mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
- mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +47 -0
- routes/__init__.py +0 -0
- services/__init__.py +0 -0
- services/custom_tool_service.py +339 -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 +81 -0
- services/resources/active_tool.py +47 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +42 -0
- services/resources/layers.py +29 -0
- services/resources/menu_items.py +34 -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 +55 -0
- services/resources/unity_instances.py +122 -0
- services/resources/windows.py +47 -0
- services/tools/__init__.py +76 -0
- services/tools/batch_execute.py +78 -0
- services/tools/debug_request_context.py +71 -0
- services/tools/execute_custom_tool.py +38 -0
- services/tools/execute_menu_item.py +29 -0
- services/tools/find_in_file.py +174 -0
- services/tools/manage_asset.py +129 -0
- services/tools/manage_editor.py +63 -0
- services/tools/manage_gameobject.py +240 -0
- services/tools/manage_material.py +95 -0
- services/tools/manage_prefabs.py +62 -0
- services/tools/manage_scene.py +75 -0
- services/tools/manage_script.py +602 -0
- services/tools/manage_shader.py +64 -0
- services/tools/read_console.py +115 -0
- services/tools/run_tests.py +108 -0
- services/tools/script_apply_edits.py +998 -0
- services/tools/set_active_instance.py +112 -0
- services/tools/utils.py +60 -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 +785 -0
- transport/models.py +62 -0
- transport/plugin_hub.py +412 -0
- transport/plugin_registry.py +123 -0
- transport/unity_instance_middleware.py +141 -0
- transport/unity_transport.py +103 -0
- utils/module_discovery.py +55 -0
- utils/reload_sentinel.py +9 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from fastmcp import Context
|
|
2
|
+
|
|
3
|
+
from models import MCPResponse
|
|
4
|
+
from services.registry import mcp_for_unity_resource
|
|
5
|
+
from services.tools import get_unity_instance_from_context
|
|
6
|
+
from transport.unity_transport import send_with_unity_instance
|
|
7
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LayersResponse(MCPResponse):
|
|
11
|
+
"""Dictionary of layer indices to layer names."""
|
|
12
|
+
data: dict[int, str] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp_for_unity_resource(
|
|
16
|
+
uri="unity://project/layers",
|
|
17
|
+
name="project_layers",
|
|
18
|
+
description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools."
|
|
19
|
+
)
|
|
20
|
+
async def get_layers(ctx: Context) -> LayersResponse | MCPResponse:
|
|
21
|
+
"""Get all project layers with their indices."""
|
|
22
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
23
|
+
response = await send_with_unity_instance(
|
|
24
|
+
async_send_command_with_retry,
|
|
25
|
+
unity_instance,
|
|
26
|
+
"get_layers",
|
|
27
|
+
{}
|
|
28
|
+
)
|
|
29
|
+
return LayersResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from fastmcp import Context
|
|
2
|
+
|
|
3
|
+
from models import MCPResponse
|
|
4
|
+
from services.registry import mcp_for_unity_resource
|
|
5
|
+
from services.tools import get_unity_instance_from_context
|
|
6
|
+
from transport.unity_transport import send_with_unity_instance
|
|
7
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GetMenuItemsResponse(MCPResponse):
|
|
11
|
+
data: list[str] = []
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp_for_unity_resource(
|
|
15
|
+
uri="mcpforunity://menu-items",
|
|
16
|
+
name="menu_items",
|
|
17
|
+
description="Provides a list of all menu items."
|
|
18
|
+
)
|
|
19
|
+
async def get_menu_items(ctx: Context) -> GetMenuItemsResponse | MCPResponse:
|
|
20
|
+
"""Provides a list of all menu items.
|
|
21
|
+
"""
|
|
22
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
23
|
+
params = {
|
|
24
|
+
"refresh": True,
|
|
25
|
+
"search": "",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
response = await send_with_unity_instance(
|
|
29
|
+
async_send_command_with_retry,
|
|
30
|
+
unity_instance,
|
|
31
|
+
"get_menu_items",
|
|
32
|
+
params,
|
|
33
|
+
)
|
|
34
|
+
return GetMenuItemsResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrefabStageData(BaseModel):
|
|
12
|
+
"""Prefab stage data fields."""
|
|
13
|
+
isOpen: bool = False
|
|
14
|
+
assetPath: str | None = None
|
|
15
|
+
prefabRootName: str | None = None
|
|
16
|
+
mode: str | None = None
|
|
17
|
+
isDirty: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrefabStageResponse(MCPResponse):
|
|
21
|
+
"""Information about the current prefab editing context."""
|
|
22
|
+
data: PrefabStageData = PrefabStageData()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mcp_for_unity_resource(
|
|
26
|
+
uri="unity://editor/prefab-stage",
|
|
27
|
+
name="editor_prefab_stage",
|
|
28
|
+
description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited."
|
|
29
|
+
)
|
|
30
|
+
async def get_prefab_stage(ctx: Context) -> PrefabStageResponse | MCPResponse:
|
|
31
|
+
"""Get current prefab stage information."""
|
|
32
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
33
|
+
response = await send_with_unity_instance(
|
|
34
|
+
async_send_command_with_retry,
|
|
35
|
+
unity_instance,
|
|
36
|
+
"get_prefab_stage",
|
|
37
|
+
{}
|
|
38
|
+
)
|
|
39
|
+
return PrefabStageResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectInfoData(BaseModel):
|
|
12
|
+
"""Project info data fields."""
|
|
13
|
+
projectRoot: str = ""
|
|
14
|
+
projectName: str = ""
|
|
15
|
+
unityVersion: str = ""
|
|
16
|
+
platform: str = ""
|
|
17
|
+
assetsPath: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectInfoResponse(MCPResponse):
|
|
21
|
+
"""Static project configuration information."""
|
|
22
|
+
data: ProjectInfoData = ProjectInfoData()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@mcp_for_unity_resource(
|
|
26
|
+
uri="unity://project/info",
|
|
27
|
+
name="project_info",
|
|
28
|
+
description="Static project information including root path, Unity version, and platform. This data rarely changes."
|
|
29
|
+
)
|
|
30
|
+
async def get_project_info(ctx: Context) -> ProjectInfoResponse | MCPResponse:
|
|
31
|
+
"""Get static project configuration information."""
|
|
32
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
33
|
+
response = await send_with_unity_instance(
|
|
34
|
+
async_send_command_with_retry,
|
|
35
|
+
unity_instance,
|
|
36
|
+
"get_project_info",
|
|
37
|
+
{}
|
|
38
|
+
)
|
|
39
|
+
return ProjectInfoResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SelectionObjectInfo(BaseModel):
|
|
12
|
+
"""Information about a selected object."""
|
|
13
|
+
name: str | None = None
|
|
14
|
+
type: str | None = None
|
|
15
|
+
instanceID: int | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SelectionGameObjectInfo(BaseModel):
|
|
19
|
+
"""Information about a selected GameObject."""
|
|
20
|
+
name: str | None = None
|
|
21
|
+
instanceID: int | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SelectionData(BaseModel):
|
|
25
|
+
"""Selection data fields."""
|
|
26
|
+
activeObject: str | None = None
|
|
27
|
+
activeGameObject: str | None = None
|
|
28
|
+
activeTransform: str | None = None
|
|
29
|
+
activeInstanceID: int = 0
|
|
30
|
+
count: int = 0
|
|
31
|
+
objects: list[SelectionObjectInfo] = []
|
|
32
|
+
gameObjects: list[SelectionGameObjectInfo] = []
|
|
33
|
+
assetGUIDs: list[str] = []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SelectionResponse(MCPResponse):
|
|
37
|
+
"""Detailed information about the current editor selection."""
|
|
38
|
+
data: SelectionData = SelectionData()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp_for_unity_resource(
|
|
42
|
+
uri="unity://editor/selection",
|
|
43
|
+
name="editor_selection",
|
|
44
|
+
description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties."
|
|
45
|
+
)
|
|
46
|
+
async def get_selection(ctx: Context) -> SelectionResponse | MCPResponse:
|
|
47
|
+
"""Get detailed editor selection information."""
|
|
48
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
49
|
+
response = await send_with_unity_instance(
|
|
50
|
+
async_send_command_with_retry,
|
|
51
|
+
unity_instance,
|
|
52
|
+
"get_selection",
|
|
53
|
+
{}
|
|
54
|
+
)
|
|
55
|
+
return SelectionResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TagsResponse(MCPResponse):
|
|
12
|
+
"""List of all tags in the project."""
|
|
13
|
+
data: list[str] = Field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@mcp_for_unity_resource(
|
|
17
|
+
uri="unity://project/tags",
|
|
18
|
+
name="project_tags",
|
|
19
|
+
description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools."
|
|
20
|
+
)
|
|
21
|
+
async def get_tags(ctx: Context) -> TagsResponse | MCPResponse:
|
|
22
|
+
"""Get all project tags."""
|
|
23
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
24
|
+
response = await send_with_unity_instance(
|
|
25
|
+
async_send_command_with_retry,
|
|
26
|
+
unity_instance,
|
|
27
|
+
"get_tags",
|
|
28
|
+
{}
|
|
29
|
+
)
|
|
30
|
+
return TagsResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Annotated, Literal
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
from fastmcp import Context
|
|
5
|
+
|
|
6
|
+
from models import MCPResponse
|
|
7
|
+
from services.registry import mcp_for_unity_resource
|
|
8
|
+
from services.tools import get_unity_instance_from_context
|
|
9
|
+
from transport.unity_transport import send_with_unity_instance
|
|
10
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestItem(BaseModel):
|
|
14
|
+
name: Annotated[str, Field(description="The name of the test.")]
|
|
15
|
+
full_name: Annotated[str, Field(description="The full name of the test.")]
|
|
16
|
+
mode: Annotated[Literal["EditMode", "PlayMode"],
|
|
17
|
+
Field(description="The mode the test is for.")]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GetTestsResponse(MCPResponse):
|
|
21
|
+
data: list[TestItem] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.")
|
|
25
|
+
async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse:
|
|
26
|
+
"""Provides a list of all tests.
|
|
27
|
+
"""
|
|
28
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
29
|
+
response = await send_with_unity_instance(
|
|
30
|
+
async_send_command_with_retry,
|
|
31
|
+
unity_instance,
|
|
32
|
+
"get_tests",
|
|
33
|
+
{},
|
|
34
|
+
)
|
|
35
|
+
return GetTestsResponse(**response) if isinstance(response, dict) else response
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.")
|
|
39
|
+
async def get_tests_for_mode(
|
|
40
|
+
ctx: Context,
|
|
41
|
+
mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")],
|
|
42
|
+
) -> GetTestsResponse | MCPResponse:
|
|
43
|
+
"""Provides a list of tests for a specific mode.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
mode: The test mode to filter by (EditMode or PlayMode).
|
|
47
|
+
"""
|
|
48
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
49
|
+
response = await send_with_unity_instance(
|
|
50
|
+
async_send_command_with_retry,
|
|
51
|
+
unity_instance,
|
|
52
|
+
"get_tests_for_mode",
|
|
53
|
+
{"mode": mode},
|
|
54
|
+
)
|
|
55
|
+
return GetTestsResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,122 @@
|
|
|
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 transport.unity_transport import _current_transport
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp_for_unity_resource(
|
|
14
|
+
uri="unity://instances",
|
|
15
|
+
name="unity_instances",
|
|
16
|
+
description="Lists all running Unity Editor instances with their details."
|
|
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 = _current_transport()
|
|
40
|
+
if transport == "http":
|
|
41
|
+
# HTTP/WebSocket transport: query PluginHub
|
|
42
|
+
sessions_data = await PluginHub.get_sessions()
|
|
43
|
+
sessions = sessions_data.sessions
|
|
44
|
+
|
|
45
|
+
instances = []
|
|
46
|
+
for session_id, session_info in sessions.items():
|
|
47
|
+
project = session_info.project
|
|
48
|
+
project_hash = session_info.hash
|
|
49
|
+
|
|
50
|
+
if not project or not project_hash:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"PluginHub session missing required 'project' or 'hash' fields."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
instances.append({
|
|
56
|
+
"id": f"{project}@{project_hash}",
|
|
57
|
+
"name": project,
|
|
58
|
+
"hash": project_hash,
|
|
59
|
+
"unity_version": session_info.unity_version,
|
|
60
|
+
"connected_at": session_info.connected_at,
|
|
61
|
+
"session_id": session_id,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
# Check for duplicate project names
|
|
65
|
+
name_counts = {}
|
|
66
|
+
for inst in instances:
|
|
67
|
+
name_counts[inst["name"]] = name_counts.get(
|
|
68
|
+
inst["name"], 0) + 1
|
|
69
|
+
|
|
70
|
+
duplicates = [name for name,
|
|
71
|
+
count in name_counts.items() if count > 1]
|
|
72
|
+
|
|
73
|
+
result = {
|
|
74
|
+
"success": True,
|
|
75
|
+
"transport": transport,
|
|
76
|
+
"instance_count": len(instances),
|
|
77
|
+
"instances": instances,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if duplicates:
|
|
81
|
+
result["warning"] = (
|
|
82
|
+
f"Multiple instances found with duplicate project names: {duplicates}. "
|
|
83
|
+
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
else:
|
|
88
|
+
# Stdio/TCP transport: query connection pool
|
|
89
|
+
pool = get_unity_connection_pool()
|
|
90
|
+
instances = pool.discover_all_instances(force_refresh=False)
|
|
91
|
+
|
|
92
|
+
# Check for duplicate project names
|
|
93
|
+
name_counts = {}
|
|
94
|
+
for inst in instances:
|
|
95
|
+
name_counts[inst.name] = name_counts.get(inst.name, 0) + 1
|
|
96
|
+
|
|
97
|
+
duplicates = [name for name,
|
|
98
|
+
count in name_counts.items() if count > 1]
|
|
99
|
+
|
|
100
|
+
result = {
|
|
101
|
+
"success": True,
|
|
102
|
+
"transport": transport,
|
|
103
|
+
"instance_count": len(instances),
|
|
104
|
+
"instances": [inst.to_dict() for inst in instances],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if duplicates:
|
|
108
|
+
result["warning"] = (
|
|
109
|
+
f"Multiple instances found with duplicate project names: {duplicates}. "
|
|
110
|
+
f"Use full format (e.g., 'ProjectName@hash') to specify which instance."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
await ctx.error(f"Error listing Unity instances: {e}")
|
|
117
|
+
return {
|
|
118
|
+
"success": False,
|
|
119
|
+
"error": f"Failed to list Unity instances: {str(e)}",
|
|
120
|
+
"instance_count": 0,
|
|
121
|
+
"instances": []
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from fastmcp import Context
|
|
3
|
+
|
|
4
|
+
from models import MCPResponse
|
|
5
|
+
from services.registry import mcp_for_unity_resource
|
|
6
|
+
from services.tools import get_unity_instance_from_context
|
|
7
|
+
from transport.unity_transport import send_with_unity_instance
|
|
8
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WindowPosition(BaseModel):
|
|
12
|
+
"""Window position and size."""
|
|
13
|
+
x: float = 0.0
|
|
14
|
+
y: float = 0.0
|
|
15
|
+
width: float = 0.0
|
|
16
|
+
height: float = 0.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WindowInfo(BaseModel):
|
|
20
|
+
"""Information about an editor window."""
|
|
21
|
+
title: str = ""
|
|
22
|
+
typeName: str = ""
|
|
23
|
+
isFocused: bool = False
|
|
24
|
+
position: WindowPosition = WindowPosition()
|
|
25
|
+
instanceID: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WindowsResponse(MCPResponse):
|
|
29
|
+
"""List of all open editor windows."""
|
|
30
|
+
data: list[WindowInfo] = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp_for_unity_resource(
|
|
34
|
+
uri="unity://editor/windows",
|
|
35
|
+
name="editor_windows",
|
|
36
|
+
description="All currently open editor windows with their titles, types, positions, and focus state."
|
|
37
|
+
)
|
|
38
|
+
async def get_windows(ctx: Context) -> WindowsResponse | MCPResponse:
|
|
39
|
+
"""Get all open editor windows."""
|
|
40
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
41
|
+
response = await send_with_unity_instance(
|
|
42
|
+
async_send_command_with_retry,
|
|
43
|
+
unity_instance,
|
|
44
|
+
"get_windows",
|
|
45
|
+
{}
|
|
46
|
+
)
|
|
47
|
+
return WindowsResponse(**response) if isinstance(response, dict) else response
|
|
@@ -0,0 +1,76 @@
|
|
|
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):
|
|
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
|
+
# Apply the @mcp.tool decorator, telemetry, and logging
|
|
50
|
+
wrapped = log_execution(tool_name, "Tool")(func)
|
|
51
|
+
wrapped = telemetry_tool(tool_name)(wrapped)
|
|
52
|
+
wrapped = mcp.tool(
|
|
53
|
+
name=tool_name, description=description, **kwargs)(wrapped)
|
|
54
|
+
tool_info['func'] = wrapped
|
|
55
|
+
logger.debug(f"Registered tool: {tool_name} - {description}")
|
|
56
|
+
|
|
57
|
+
logger.info(f"Registered {len(tools)} MCP tools")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_unity_instance_from_context(
|
|
61
|
+
ctx: Context,
|
|
62
|
+
key: str = "unity_instance",
|
|
63
|
+
) -> str | None:
|
|
64
|
+
"""Extract the unity_instance value from middleware state.
|
|
65
|
+
|
|
66
|
+
The instance is set via the set_active_instance tool and injected into
|
|
67
|
+
request state by UnityInstanceMiddleware.
|
|
68
|
+
"""
|
|
69
|
+
get_state_fn = getattr(ctx, "get_state", None)
|
|
70
|
+
if callable(get_state_fn):
|
|
71
|
+
try:
|
|
72
|
+
return get_state_fn(key)
|
|
73
|
+
except Exception: # pragma: no cover - defensive
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
|
|
8
|
+
from services.registry import mcp_for_unity_tool
|
|
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
|
+
MAX_COMMANDS_PER_BATCH = 25
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@mcp_for_unity_tool(
|
|
17
|
+
name="batch_execute",
|
|
18
|
+
description=(
|
|
19
|
+
"Runs a list of MCP tool calls as one batch. Use it to send a full sequence of commands, "
|
|
20
|
+
"inspect the results, then submit the next batch for the following step."
|
|
21
|
+
),
|
|
22
|
+
)
|
|
23
|
+
async def batch_execute(
|
|
24
|
+
ctx: Context,
|
|
25
|
+
commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."],
|
|
26
|
+
parallel: Annotated[bool | None, "Attempt to run read-only commands in parallel"] = None,
|
|
27
|
+
fail_fast: Annotated[bool | None, "Stop processing after the first failure"] = None,
|
|
28
|
+
max_parallelism: Annotated[int | None, "Hint for the maximum number of parallel workers"] = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Proxy the batch_execute tool to the Unity Editor transporter."""
|
|
31
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
32
|
+
|
|
33
|
+
if not isinstance(commands, list) or not commands:
|
|
34
|
+
raise ValueError("'commands' must be a non-empty list of command specifications")
|
|
35
|
+
|
|
36
|
+
if len(commands) > MAX_COMMANDS_PER_BATCH:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"batch_execute currently supports up to {MAX_COMMANDS_PER_BATCH} commands; received {len(commands)}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
normalized_commands: list[dict[str, Any]] = []
|
|
42
|
+
for index, command in enumerate(commands):
|
|
43
|
+
if not isinstance(command, dict):
|
|
44
|
+
raise ValueError(f"Command at index {index} must be an object with 'tool' and 'params' keys")
|
|
45
|
+
|
|
46
|
+
tool_name = command.get("tool")
|
|
47
|
+
params = command.get("params", {})
|
|
48
|
+
|
|
49
|
+
if not tool_name or not isinstance(tool_name, str):
|
|
50
|
+
raise ValueError(f"Command at index {index} is missing a valid 'tool' name")
|
|
51
|
+
|
|
52
|
+
if params is None:
|
|
53
|
+
params = {}
|
|
54
|
+
if not isinstance(params, dict):
|
|
55
|
+
raise ValueError(f"Command '{tool_name}' must specify parameters as an object/dict")
|
|
56
|
+
|
|
57
|
+
normalized_commands.append({
|
|
58
|
+
"tool": tool_name,
|
|
59
|
+
"params": params,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
payload: dict[str, Any] = {
|
|
63
|
+
"commands": normalized_commands,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if parallel is not None:
|
|
67
|
+
payload["parallel"] = bool(parallel)
|
|
68
|
+
if fail_fast is not None:
|
|
69
|
+
payload["failFast"] = bool(fail_fast)
|
|
70
|
+
if max_parallelism is not None:
|
|
71
|
+
payload["maxParallelism"] = int(max_parallelism)
|
|
72
|
+
|
|
73
|
+
return await send_with_unity_instance(
|
|
74
|
+
async_send_command_with_retry,
|
|
75
|
+
unity_instance,
|
|
76
|
+
"batch_execute",
|
|
77
|
+
payload,
|
|
78
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastmcp import Context
|
|
4
|
+
from services.registry import mcp_for_unity_tool
|
|
5
|
+
from transport.unity_instance_middleware import get_unity_instance_middleware
|
|
6
|
+
from transport.plugin_hub import PluginHub
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@mcp_for_unity_tool(
|
|
10
|
+
description="Return the current FastMCP request context details (client_id, session_id, and meta dump)."
|
|
11
|
+
)
|
|
12
|
+
def debug_request_context(ctx: Context) -> dict[str, Any]:
|
|
13
|
+
# Check request_context properties
|
|
14
|
+
rc = getattr(ctx, "request_context", None)
|
|
15
|
+
rc_client_id = getattr(rc, "client_id", None)
|
|
16
|
+
rc_session_id = getattr(rc, "session_id", None)
|
|
17
|
+
meta = getattr(rc, "meta", None)
|
|
18
|
+
|
|
19
|
+
# Check direct ctx properties (per latest FastMCP docs)
|
|
20
|
+
ctx_session_id = getattr(ctx, "session_id", None)
|
|
21
|
+
ctx_client_id = getattr(ctx, "client_id", None)
|
|
22
|
+
|
|
23
|
+
meta_dump = None
|
|
24
|
+
if meta is not None:
|
|
25
|
+
try:
|
|
26
|
+
dump_fn = getattr(meta, "model_dump", None)
|
|
27
|
+
if callable(dump_fn):
|
|
28
|
+
meta_dump = dump_fn(exclude_none=False)
|
|
29
|
+
elif isinstance(meta, dict):
|
|
30
|
+
meta_dump = dict(meta)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
meta_dump = {"_error": str(e)}
|
|
33
|
+
|
|
34
|
+
# List all ctx attributes for debugging
|
|
35
|
+
ctx_attrs = [attr for attr in dir(ctx) if not attr.startswith("_")]
|
|
36
|
+
|
|
37
|
+
# Get session state info via middleware
|
|
38
|
+
middleware = get_unity_instance_middleware()
|
|
39
|
+
derived_key = middleware.get_session_key(ctx)
|
|
40
|
+
active_instance = middleware.get_active_instance(ctx)
|
|
41
|
+
|
|
42
|
+
# Debugging middleware internals
|
|
43
|
+
# NOTE: These fields expose internal implementation details and may change between versions.
|
|
44
|
+
with middleware._lock:
|
|
45
|
+
all_keys = list(middleware._active_by_key.keys())
|
|
46
|
+
|
|
47
|
+
# Debugging PluginHub state
|
|
48
|
+
plugin_hub_configured = PluginHub.is_configured()
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"success": True,
|
|
52
|
+
"data": {
|
|
53
|
+
"request_context": {
|
|
54
|
+
"client_id": rc_client_id,
|
|
55
|
+
"session_id": rc_session_id,
|
|
56
|
+
"meta": meta_dump,
|
|
57
|
+
},
|
|
58
|
+
"direct_properties": {
|
|
59
|
+
"session_id": ctx_session_id,
|
|
60
|
+
"client_id": ctx_client_id,
|
|
61
|
+
},
|
|
62
|
+
"session_state": {
|
|
63
|
+
"derived_key": derived_key,
|
|
64
|
+
"active_instance": active_instance,
|
|
65
|
+
"all_keys_in_store": all_keys,
|
|
66
|
+
"plugin_hub_configured": plugin_hub_configured,
|
|
67
|
+
"middleware_id": id(middleware),
|
|
68
|
+
},
|
|
69
|
+
"available_attributes": ctx_attrs,
|
|
70
|
+
},
|
|
71
|
+
}
|