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.
Files changed (65) hide show
  1. __init__.py +0 -0
  2. core/__init__.py +0 -0
  3. core/config.py +56 -0
  4. core/logging_decorator.py +37 -0
  5. core/telemetry.py +533 -0
  6. core/telemetry_decorator.py +164 -0
  7. main.py +411 -0
  8. mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
  9. mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
  10. mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
  11. mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
  12. mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
  13. mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
  14. models/__init__.py +4 -0
  15. models/models.py +56 -0
  16. models/unity_response.py +47 -0
  17. routes/__init__.py +0 -0
  18. services/__init__.py +0 -0
  19. services/custom_tool_service.py +339 -0
  20. services/registry/__init__.py +22 -0
  21. services/registry/resource_registry.py +53 -0
  22. services/registry/tool_registry.py +51 -0
  23. services/resources/__init__.py +81 -0
  24. services/resources/active_tool.py +47 -0
  25. services/resources/custom_tools.py +57 -0
  26. services/resources/editor_state.py +42 -0
  27. services/resources/layers.py +29 -0
  28. services/resources/menu_items.py +34 -0
  29. services/resources/prefab_stage.py +39 -0
  30. services/resources/project_info.py +39 -0
  31. services/resources/selection.py +55 -0
  32. services/resources/tags.py +30 -0
  33. services/resources/tests.py +55 -0
  34. services/resources/unity_instances.py +122 -0
  35. services/resources/windows.py +47 -0
  36. services/tools/__init__.py +76 -0
  37. services/tools/batch_execute.py +78 -0
  38. services/tools/debug_request_context.py +71 -0
  39. services/tools/execute_custom_tool.py +38 -0
  40. services/tools/execute_menu_item.py +29 -0
  41. services/tools/find_in_file.py +174 -0
  42. services/tools/manage_asset.py +129 -0
  43. services/tools/manage_editor.py +63 -0
  44. services/tools/manage_gameobject.py +240 -0
  45. services/tools/manage_material.py +95 -0
  46. services/tools/manage_prefabs.py +62 -0
  47. services/tools/manage_scene.py +75 -0
  48. services/tools/manage_script.py +602 -0
  49. services/tools/manage_shader.py +64 -0
  50. services/tools/read_console.py +115 -0
  51. services/tools/run_tests.py +108 -0
  52. services/tools/script_apply_edits.py +998 -0
  53. services/tools/set_active_instance.py +112 -0
  54. services/tools/utils.py +60 -0
  55. transport/__init__.py +0 -0
  56. transport/legacy/port_discovery.py +329 -0
  57. transport/legacy/stdio_port_registry.py +65 -0
  58. transport/legacy/unity_connection.py +785 -0
  59. transport/models.py +62 -0
  60. transport/plugin_hub.py +412 -0
  61. transport/plugin_registry.py +123 -0
  62. transport/unity_instance_middleware.py +141 -0
  63. transport/unity_transport.py +103 -0
  64. utils/module_discovery.py +55 -0
  65. 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
+ }