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,38 @@
1
+ from fastmcp import Context
2
+ from models.models import MCPResponse
3
+
4
+ from services.custom_tool_service import (
5
+ CustomToolService,
6
+ resolve_project_id_for_unity_instance,
7
+ )
8
+ from services.registry import mcp_for_unity_tool
9
+ from services.tools import get_unity_instance_from_context
10
+
11
+
12
+ @mcp_for_unity_tool(
13
+ name="execute_custom_tool",
14
+ description="Execute a project-scoped custom tool registered by Unity.",
15
+ )
16
+ async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict | None = None) -> MCPResponse:
17
+ unity_instance = get_unity_instance_from_context(ctx)
18
+ if not unity_instance:
19
+ return MCPResponse(
20
+ success=False,
21
+ message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.",
22
+ )
23
+
24
+ project_id = resolve_project_id_for_unity_instance(unity_instance)
25
+ if project_id is None:
26
+ return MCPResponse(
27
+ success=False,
28
+ message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
29
+ )
30
+
31
+ if not isinstance(parameters, dict):
32
+ return MCPResponse(
33
+ success=False,
34
+ message="parameters must be an object/dictionary",
35
+ )
36
+
37
+ service = CustomToolService.get_instance()
38
+ return await service.execute_tool(project_id, tool_name, unity_instance, parameters)
@@ -0,0 +1,29 @@
1
+ """
2
+ Defines the execute_menu_item tool for executing and reading Unity Editor menu items.
3
+ """
4
+ from typing import Annotated, Any
5
+
6
+ from fastmcp import Context
7
+
8
+ from models import MCPResponse
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
+
15
+ @mcp_for_unity_tool(
16
+ description="Execute a Unity menu item by path."
17
+ )
18
+ async def execute_menu_item(
19
+ ctx: Context,
20
+ menu_path: Annotated[str,
21
+ "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
22
+ ) -> MCPResponse:
23
+ # Get active instance from session state
24
+ # Removed session_state import
25
+ unity_instance = get_unity_instance_from_context(ctx)
26
+ params_dict: dict[str, Any] = {"menuPath": menu_path}
27
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
28
+ result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "execute_menu_item", params_dict)
29
+ return MCPResponse(**result) if isinstance(result, dict) else result
@@ -0,0 +1,174 @@
1
+ import base64
2
+ import os
3
+ import re
4
+ from typing import Annotated, Any
5
+ from urllib.parse import unquote, urlparse
6
+
7
+ from fastmcp import Context
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
+
15
+ def _split_uri(uri: str) -> tuple[str, str]:
16
+ """Split an incoming URI or path into (name, directory) suitable for Unity.
17
+
18
+ Rules:
19
+ - unity://path/Assets/... → keep as Assets-relative (after decode/normalize)
20
+ - file://... → percent-decode, normalize, strip host and leading slashes,
21
+ then, if any 'Assets' segment exists, return path relative to that 'Assets' root.
22
+ Otherwise, fall back to original name/dir behavior.
23
+ - plain paths → decode/normalize separators; if they contain an 'Assets' segment,
24
+ return relative to 'Assets'.
25
+ """
26
+ raw_path: str
27
+ if uri.startswith("unity://path/"):
28
+ raw_path = uri[len("unity://path/"):]
29
+ elif uri.startswith("file://"):
30
+ parsed = urlparse(uri)
31
+ host = (parsed.netloc or "").strip()
32
+ p = parsed.path or ""
33
+ # UNC: file://server/share/... -> //server/share/...
34
+ if host and host.lower() != "localhost":
35
+ p = f"//{host}{p}"
36
+ # Use percent-decoded path, preserving leading slashes
37
+ raw_path = unquote(p)
38
+ else:
39
+ raw_path = uri
40
+
41
+ # Percent-decode any residual encodings and normalize separators
42
+ raw_path = unquote(raw_path).replace("\\", "/")
43
+ # Strip leading slash only for Windows drive-letter forms like "/C:/..."
44
+ if os.name == "nt" and len(raw_path) >= 3 and raw_path[0] == "/" and raw_path[2] == ":":
45
+ raw_path = raw_path[1:]
46
+
47
+ # Normalize path (collapse ../, ./)
48
+ norm = os.path.normpath(raw_path).replace("\\", "/")
49
+
50
+ # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
51
+ parts = [p for p in norm.split("/") if p not in ("", ".")]
52
+ idx = next((i for i, seg in enumerate(parts)
53
+ if seg.lower() == "assets"), None)
54
+ assets_rel = "/".join(parts[idx:]) if idx is not None else None
55
+
56
+ effective_path = assets_rel if assets_rel else norm
57
+ # For POSIX absolute paths outside Assets, drop the leading '/'
58
+ # to return a clean relative-like directory (e.g., '/tmp' -> 'tmp').
59
+ if effective_path.startswith("/"):
60
+ effective_path = effective_path[1:]
61
+
62
+ name = os.path.splitext(os.path.basename(effective_path))[0]
63
+ directory = os.path.dirname(effective_path)
64
+ return name, directory
65
+
66
+
67
+ @mcp_for_unity_tool(description="Searches a file with a regex pattern and returns line numbers and excerpts.")
68
+ async def find_in_file(
69
+ ctx: Context,
70
+ uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
71
+ pattern: Annotated[str, "The regex pattern to search for"],
72
+ project_root: Annotated[str | None, "Optional project root path"] = None,
73
+ max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200,
74
+ ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True,
75
+ ) -> dict[str, Any]:
76
+ # project_root is currently unused but kept for interface consistency
77
+ unity_instance = get_unity_instance_from_context(ctx)
78
+ await ctx.info(
79
+ f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})")
80
+
81
+ name, directory = _split_uri(uri)
82
+
83
+ # 1. Read file content via Unity
84
+ read_resp = await send_with_unity_instance(
85
+ async_send_command_with_retry,
86
+ unity_instance,
87
+ "manage_script",
88
+ {
89
+ "action": "read",
90
+ "name": name,
91
+ "path": directory,
92
+ },
93
+ )
94
+
95
+ if not isinstance(read_resp, dict) or not read_resp.get("success"):
96
+ return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
97
+
98
+ data = read_resp.get("data", {})
99
+ contents = data.get("contents")
100
+ if not contents and data.get("contentsEncoded") and data.get("encodedContents"):
101
+ try:
102
+ contents = base64.b64decode(data.get("encodedContents", "").encode(
103
+ "utf-8")).decode("utf-8", "replace")
104
+ except (ValueError, TypeError, base64.binascii.Error):
105
+ contents = contents or ""
106
+
107
+ if contents is None:
108
+ return {"success": False, "message": "Could not read file content."}
109
+
110
+ # 2. Perform regex search
111
+ flags = re.MULTILINE
112
+ # Handle ignore_case which can be boolean or string from some clients
113
+ ic = ignore_case
114
+ if isinstance(ic, str):
115
+ ic = ic.lower() in ("true", "1", "yes")
116
+ if ic:
117
+ flags |= re.IGNORECASE
118
+
119
+ try:
120
+ regex = re.compile(pattern, flags)
121
+ except re.error as e:
122
+ return {"success": False, "message": f"Invalid regex pattern: {e}"}
123
+
124
+ # If the regex is not multiline specific (doesn't contain \n literal match logic),
125
+ # we could iterate lines. But users might use multiline regexes.
126
+ # Let's search the whole content and map back to lines.
127
+
128
+ found = list(regex.finditer(contents))
129
+
130
+ results = []
131
+ count = 0
132
+
133
+ for m in found:
134
+ if count >= max_results:
135
+ break
136
+
137
+ start_idx = m.start()
138
+ end_idx = m.end()
139
+
140
+ # Calculate line number
141
+ # Count newlines up to start_idx
142
+ line_num = contents.count('\n', 0, start_idx) + 1
143
+
144
+ # Get line content for excerpt
145
+ # Find start of line
146
+ line_start = contents.rfind('\n', 0, start_idx) + 1
147
+ # Find end of line
148
+ line_end = contents.find('\n', start_idx)
149
+ if line_end == -1:
150
+ line_end = len(contents)
151
+
152
+ line_content = contents[line_start:line_end]
153
+
154
+ # Create excerpt
155
+ # We can just return the line content as excerpt
156
+
157
+ results.append({
158
+ "line": line_num,
159
+ "content": line_content.strip(), # detailed match info?
160
+ "match": m.group(0),
161
+ "start": start_idx,
162
+ "end": end_idx
163
+ })
164
+ count += 1
165
+
166
+ return {
167
+ "success": True,
168
+ "data": {
169
+ "matches": results,
170
+ "count": len(results),
171
+ "total_matches": len(found)
172
+ }
173
+ }
174
+
@@ -0,0 +1,129 @@
1
+ """
2
+ Defines the manage_asset tool for interacting with Unity assets.
3
+ """
4
+ import ast
5
+ import asyncio
6
+ import json
7
+ from typing import Annotated, Any, Literal
8
+
9
+ from fastmcp import Context
10
+ from services.registry import mcp_for_unity_tool
11
+ from services.tools import get_unity_instance_from_context
12
+ from services.tools.utils import parse_json_payload
13
+ from transport.unity_transport import send_with_unity_instance
14
+ from transport.legacy.unity_connection import async_send_command_with_retry
15
+
16
+
17
+ @mcp_for_unity_tool(
18
+ description="Performs asset operations (import, create, modify, delete, etc.) in Unity."
19
+ )
20
+ async def manage_asset(
21
+ ctx: Context,
22
+ action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
23
+ path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
24
+ asset_type: Annotated[str,
25
+ "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
26
+ properties: Annotated[dict[str, Any] | str,
27
+ "Dictionary (or JSON string) of properties for 'create'/'modify'."] | None = None,
28
+ destination: Annotated[str,
29
+ "Target path for 'duplicate'/'move'."] | None = None,
30
+ generate_preview: Annotated[bool,
31
+ "Generate a preview/thumbnail for the asset when supported."] = False,
32
+ search_pattern: Annotated[str,
33
+ "Search pattern (e.g., '*.prefab')."] | None = None,
34
+ filter_type: Annotated[str, "Filter type for search"] | None = None,
35
+ filter_date_after: Annotated[str,
36
+ "Date after which to filter"] | None = None,
37
+ page_size: Annotated[int | float | str,
38
+ "Page size for pagination"] | None = None,
39
+ page_number: Annotated[int | float | str,
40
+ "Page number for pagination"] | None = None,
41
+ ) -> dict[str, Any]:
42
+ unity_instance = get_unity_instance_from_context(ctx)
43
+
44
+ def _parse_properties_string(raw: str) -> tuple[dict[str, Any] | None, str | None]:
45
+ try:
46
+ parsed = json.loads(raw)
47
+ if not isinstance(parsed, dict):
48
+ return None, f"manage_asset: properties JSON must decode to a dictionary; received {type(parsed)}"
49
+ return parsed, "JSON"
50
+ except json.JSONDecodeError as json_err:
51
+ try:
52
+ parsed = ast.literal_eval(raw)
53
+ if not isinstance(parsed, dict):
54
+ return None, f"manage_asset: properties string must evaluate to a dictionary; received {type(parsed)}"
55
+ return parsed, "Python literal"
56
+ except (ValueError, SyntaxError) as literal_err:
57
+ return None, f"manage_asset: failed to parse properties string. JSON error: {json_err}; literal_eval error: {literal_err}"
58
+
59
+ async def _normalize_properties(raw: dict[str, Any] | str | None) -> tuple[dict[str, Any] | None, str | None]:
60
+ if raw is None:
61
+ return {}, None
62
+ if isinstance(raw, dict):
63
+ await ctx.info(f"manage_asset: received properties as dict with keys: {list(raw.keys())}")
64
+ return raw, None
65
+ if isinstance(raw, str):
66
+ await ctx.info(f"manage_asset: received properties as string (first 100 chars): {raw[:100]}")
67
+ # Try our robust centralized parser first, then fallback to ast.literal_eval specific to manage_asset if needed
68
+ parsed = parse_json_payload(raw)
69
+ if isinstance(parsed, dict):
70
+ await ctx.info("manage_asset: coerced properties using centralized parser")
71
+ return parsed, None
72
+
73
+ # Fallback to original logic for ast.literal_eval which parse_json_payload avoids for safety/simplicity
74
+ parsed, source = _parse_properties_string(raw)
75
+ if parsed is None:
76
+ return None, source
77
+ await ctx.info(f"manage_asset: coerced properties from {source} string to dict")
78
+ return parsed, None
79
+ return None, f"manage_asset: properties must be a dict or JSON string; received {type(raw)}"
80
+
81
+ properties, parse_error = await _normalize_properties(properties)
82
+ if parse_error:
83
+ await ctx.error(parse_error)
84
+ return {"success": False, "message": parse_error}
85
+
86
+ # Coerce numeric inputs defensively
87
+ def _coerce_int(value, default=None):
88
+ if value is None:
89
+ return default
90
+ try:
91
+ if isinstance(value, bool):
92
+ return default
93
+ if isinstance(value, int):
94
+ return int(value)
95
+ s = str(value).strip()
96
+ if s.lower() in ("", "none", "null"):
97
+ return default
98
+ return int(float(s))
99
+ except Exception:
100
+ return default
101
+
102
+ page_size = _coerce_int(page_size)
103
+ page_number = _coerce_int(page_number)
104
+
105
+ # Prepare parameters for the C# handler
106
+ params_dict = {
107
+ "action": action.lower(),
108
+ "path": path,
109
+ "assetType": asset_type,
110
+ "properties": properties,
111
+ "destination": destination,
112
+ "generatePreview": generate_preview,
113
+ "searchPattern": search_pattern,
114
+ "filterType": filter_type,
115
+ "filterDateAfter": filter_date_after,
116
+ "pageSize": page_size,
117
+ "pageNumber": page_number
118
+ }
119
+
120
+ # Remove None values to avoid sending unnecessary nulls
121
+ params_dict = {k: v for k, v in params_dict.items() if v is not None}
122
+
123
+ # Get the current asyncio event loop
124
+ loop = asyncio.get_running_loop()
125
+
126
+ # Use centralized async retry helper with instance routing
127
+ result = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_asset", params_dict, loop=loop)
128
+ # Return the result obtained from Unity
129
+ return result if isinstance(result, dict) else {"success": False, "message": str(result)}
@@ -0,0 +1,63 @@
1
+ from typing import Annotated, Any, Literal
2
+
3
+ from fastmcp import Context
4
+ from services.registry import mcp_for_unity_tool
5
+ from core.telemetry import is_telemetry_enabled, record_tool_usage
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
+ from services.tools.utils import coerce_bool
10
+
11
+
12
+ @mcp_for_unity_tool(
13
+ description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted."
14
+ )
15
+ async def manage_editor(
16
+ ctx: Context,
17
+ action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."],
18
+ wait_for_completion: Annotated[bool | str,
19
+ "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None,
20
+ tool_name: Annotated[str,
21
+ "Tool name when setting active tool"] | None = None,
22
+ tag_name: Annotated[str,
23
+ "Tag name when adding and removing tags"] | None = None,
24
+ layer_name: Annotated[str,
25
+ "Layer name when adding and removing layers"] | None = None,
26
+ ) -> dict[str, Any]:
27
+ # Get active instance from request state (injected by middleware)
28
+ unity_instance = get_unity_instance_from_context(ctx)
29
+
30
+ wait_for_completion = coerce_bool(wait_for_completion)
31
+
32
+ try:
33
+ # Diagnostics: quick telemetry checks
34
+ if action == "telemetry_status":
35
+ return {"success": True, "telemetry_enabled": is_telemetry_enabled()}
36
+
37
+ if action == "telemetry_ping":
38
+ record_tool_usage("diagnostic_ping", True, 1.0, None)
39
+ return {"success": True, "message": "telemetry ping queued"}
40
+ # Prepare parameters, removing None values
41
+ params = {
42
+ "action": action,
43
+ "waitForCompletion": wait_for_completion,
44
+ "toolName": tool_name, # Corrected parameter name to match C#
45
+ "tagName": tag_name, # Pass tag name
46
+ "layerName": layer_name, # Pass layer name
47
+ # Add other parameters based on the action being performed
48
+ # "width": width,
49
+ # "height": height,
50
+ # etc.
51
+ }
52
+ params = {k: v for k, v in params.items() if v is not None}
53
+
54
+ # Send command using centralized retry helper with instance routing
55
+ response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "manage_editor", params)
56
+
57
+ # Preserve structured failure data; unwrap success into a friendlier shape
58
+ if isinstance(response, dict) and response.get("success"):
59
+ return {"success": True, "message": response.get("message", "Editor operation successful."), "data": response.get("data")}
60
+ return response if isinstance(response, dict) else {"success": False, "message": str(response)}
61
+
62
+ except Exception as e:
63
+ return {"success": False, "message": f"Python error managing editor: {str(e)}"}