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,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Middleware for managing Unity instance selection per session.
|
|
3
|
+
|
|
4
|
+
This middleware intercepts all tool calls and injects the active Unity instance
|
|
5
|
+
into the request-scoped state, allowing tools to access it via ctx.get_state("unity_instance").
|
|
6
|
+
"""
|
|
7
|
+
from threading import RLock
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
11
|
+
|
|
12
|
+
from transport.plugin_hub import PluginHub
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
15
|
+
|
|
16
|
+
# Store a global reference to the middleware instance so tools can interact
|
|
17
|
+
# with it to set or clear the active unity instance.
|
|
18
|
+
_unity_instance_middleware = None
|
|
19
|
+
_middleware_lock = RLock()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_unity_instance_middleware() -> 'UnityInstanceMiddleware':
|
|
23
|
+
"""Get the global Unity instance middleware."""
|
|
24
|
+
global _unity_instance_middleware
|
|
25
|
+
if _unity_instance_middleware is None:
|
|
26
|
+
with _middleware_lock:
|
|
27
|
+
if _unity_instance_middleware is None:
|
|
28
|
+
# Auto-initialize if not set (lazy singleton) to handle import order or test cases
|
|
29
|
+
_unity_instance_middleware = UnityInstanceMiddleware()
|
|
30
|
+
|
|
31
|
+
return _unity_instance_middleware
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_unity_instance_middleware(middleware: 'UnityInstanceMiddleware') -> None:
|
|
35
|
+
"""Set the global Unity instance middleware (called during server initialization)."""
|
|
36
|
+
global _unity_instance_middleware
|
|
37
|
+
_unity_instance_middleware = middleware
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnityInstanceMiddleware(Middleware):
|
|
41
|
+
"""
|
|
42
|
+
Middleware that manages per-session Unity instance selection.
|
|
43
|
+
|
|
44
|
+
Stores active instance per session_id and injects it into request state
|
|
45
|
+
for all tool and resource calls.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
super().__init__()
|
|
50
|
+
self._active_by_key: dict[str, str] = {}
|
|
51
|
+
self._lock = RLock()
|
|
52
|
+
|
|
53
|
+
def get_session_key(self, ctx) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Derive a stable key for the calling session.
|
|
56
|
+
|
|
57
|
+
Prioritizes client_id for stability.
|
|
58
|
+
If client_id is missing, falls back to 'global' (assuming single-user local mode),
|
|
59
|
+
ignoring session_id which can be unstable in some transports/clients.
|
|
60
|
+
"""
|
|
61
|
+
client_id = getattr(ctx, "client_id", None)
|
|
62
|
+
if isinstance(client_id, str) and client_id:
|
|
63
|
+
return client_id
|
|
64
|
+
|
|
65
|
+
# Fallback to global for local dev stability
|
|
66
|
+
return "global"
|
|
67
|
+
|
|
68
|
+
def set_active_instance(self, ctx, instance_id: str) -> None:
|
|
69
|
+
"""Store the active instance for this session."""
|
|
70
|
+
key = self.get_session_key(ctx)
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._active_by_key[key] = instance_id
|
|
73
|
+
|
|
74
|
+
def get_active_instance(self, ctx) -> str | None:
|
|
75
|
+
"""Retrieve the active instance for this session."""
|
|
76
|
+
key = self.get_session_key(ctx)
|
|
77
|
+
with self._lock:
|
|
78
|
+
return self._active_by_key.get(key)
|
|
79
|
+
|
|
80
|
+
def clear_active_instance(self, ctx) -> None:
|
|
81
|
+
"""Clear the stored instance for this session."""
|
|
82
|
+
key = self.get_session_key(ctx)
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._active_by_key.pop(key, None)
|
|
85
|
+
|
|
86
|
+
async def _inject_unity_instance(self, context: MiddlewareContext) -> None:
|
|
87
|
+
"""Inject active Unity instance into context if available."""
|
|
88
|
+
ctx = context.fastmcp_context
|
|
89
|
+
|
|
90
|
+
active_instance = self.get_active_instance(ctx)
|
|
91
|
+
if active_instance:
|
|
92
|
+
# If using HTTP transport (PluginHub configured), validate session
|
|
93
|
+
# But for stdio transport (no PluginHub needed or maybe partially configured),
|
|
94
|
+
# we should be careful not to clear instance just because PluginHub can't resolve it.
|
|
95
|
+
# The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails.
|
|
96
|
+
|
|
97
|
+
session_id: str | None = None
|
|
98
|
+
# Only validate via PluginHub if we are actually using HTTP transport
|
|
99
|
+
# OR if we want to support hybrid mode. For now, let's be permissive.
|
|
100
|
+
if PluginHub.is_configured():
|
|
101
|
+
try:
|
|
102
|
+
# resolving session_id might fail if the plugin disconnected
|
|
103
|
+
# We only need session_id for HTTP transport routing.
|
|
104
|
+
# For stdio, we just need the instance ID.
|
|
105
|
+
session_id = await PluginHub._resolve_session_id(active_instance)
|
|
106
|
+
except (ConnectionError, ValueError, KeyError, TimeoutError) as exc:
|
|
107
|
+
# If resolution fails, it means the Unity instance is not reachable via HTTP/WS.
|
|
108
|
+
# If we are in stdio mode, this might still be fine if the user is just setting state?
|
|
109
|
+
# But usually if PluginHub is configured, we expect it to work.
|
|
110
|
+
# Let's LOG the error but NOT clear the instance immediately to avoid flickering,
|
|
111
|
+
# or at least debug why it's failing.
|
|
112
|
+
logger.debug(
|
|
113
|
+
"PluginHub session resolution failed for %s: %s; leaving active_instance unchanged",
|
|
114
|
+
active_instance,
|
|
115
|
+
exc,
|
|
116
|
+
exc_info=True,
|
|
117
|
+
)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
# Re-raise unexpected system exceptions to avoid swallowing critical failures
|
|
120
|
+
if isinstance(exc, (SystemExit, KeyboardInterrupt)):
|
|
121
|
+
raise
|
|
122
|
+
logger.error(
|
|
123
|
+
"Unexpected error during PluginHub session resolution for %s: %s",
|
|
124
|
+
active_instance,
|
|
125
|
+
exc,
|
|
126
|
+
exc_info=True
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
ctx.set_state("unity_instance", active_instance)
|
|
130
|
+
if session_id is not None:
|
|
131
|
+
ctx.set_state("unity_session_id", session_id)
|
|
132
|
+
|
|
133
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
134
|
+
"""Inject active Unity instance into tool context if available."""
|
|
135
|
+
await self._inject_unity_instance(context)
|
|
136
|
+
return await call_next(context)
|
|
137
|
+
|
|
138
|
+
async def on_read_resource(self, context: MiddlewareContext, call_next):
|
|
139
|
+
"""Inject active Unity instance into resource context if available."""
|
|
140
|
+
await self._inject_unity_instance(context)
|
|
141
|
+
return await call_next(context)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Transport helpers for routing commands to Unity."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
import os
|
|
7
|
+
from typing import Awaitable, Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context
|
|
10
|
+
|
|
11
|
+
from transport.plugin_hub import PluginHub
|
|
12
|
+
from models.unity_response import normalize_unity_response
|
|
13
|
+
from services.tools import get_unity_instance_from_context
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_http_transport() -> bool:
|
|
19
|
+
return os.environ.get("UNITY_MCP_TRANSPORT", "stdio").lower() == "http"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _current_transport() -> str:
|
|
23
|
+
"""Expose the active transport mode as a simple string identifier."""
|
|
24
|
+
return "http" if _is_http_transport() else "stdio"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def with_unity_instance(
|
|
28
|
+
log: str | Callable[[Context, tuple, dict, str | None], str] | None = None,
|
|
29
|
+
*,
|
|
30
|
+
kwarg_name: str = "unity_instance",
|
|
31
|
+
):
|
|
32
|
+
def _decorate(fn: Callable[..., T]):
|
|
33
|
+
is_coro = asyncio.iscoroutinefunction(fn)
|
|
34
|
+
|
|
35
|
+
def _compose_message(ctx: Context, a: tuple, k: dict, inst: str | None) -> str | None:
|
|
36
|
+
if log is None:
|
|
37
|
+
return None
|
|
38
|
+
if callable(log):
|
|
39
|
+
try:
|
|
40
|
+
return log(ctx, a, k, inst)
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
return str(log).format(unity_instance=inst or "default")
|
|
45
|
+
except Exception:
|
|
46
|
+
return str(log)
|
|
47
|
+
|
|
48
|
+
if is_coro:
|
|
49
|
+
async def _wrapper(ctx: Context, *args, **kwargs):
|
|
50
|
+
inst = get_unity_instance_from_context(ctx)
|
|
51
|
+
msg = _compose_message(ctx, args, kwargs, inst)
|
|
52
|
+
if msg:
|
|
53
|
+
try:
|
|
54
|
+
await ctx.info(msg)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
kwargs.setdefault(kwarg_name, inst)
|
|
58
|
+
return await fn(ctx, *args, **kwargs)
|
|
59
|
+
else:
|
|
60
|
+
async def _wrapper(ctx: Context, *args, **kwargs):
|
|
61
|
+
inst = get_unity_instance_from_context(ctx)
|
|
62
|
+
msg = _compose_message(ctx, args, kwargs, inst)
|
|
63
|
+
if msg:
|
|
64
|
+
try:
|
|
65
|
+
await ctx.info(msg)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
kwargs.setdefault(kwarg_name, inst)
|
|
69
|
+
return fn(ctx, *args, **kwargs)
|
|
70
|
+
|
|
71
|
+
from functools import wraps
|
|
72
|
+
|
|
73
|
+
return wraps(fn)(_wrapper) # type: ignore[arg-type]
|
|
74
|
+
|
|
75
|
+
return _decorate
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def send_with_unity_instance(
|
|
79
|
+
send_fn: Callable[..., Awaitable[T]],
|
|
80
|
+
unity_instance: str | None,
|
|
81
|
+
*args,
|
|
82
|
+
**kwargs,
|
|
83
|
+
) -> T:
|
|
84
|
+
if _is_http_transport():
|
|
85
|
+
if not args:
|
|
86
|
+
raise ValueError("HTTP transport requires command arguments")
|
|
87
|
+
command_type = args[0]
|
|
88
|
+
params = args[1] if len(args) > 1 else kwargs.get("params")
|
|
89
|
+
if params is None:
|
|
90
|
+
params = {}
|
|
91
|
+
if not isinstance(params, dict):
|
|
92
|
+
raise TypeError(
|
|
93
|
+
"Command parameters must be a dict for HTTP transport")
|
|
94
|
+
raw = await PluginHub.send_command_for_instance(
|
|
95
|
+
unity_instance,
|
|
96
|
+
command_type,
|
|
97
|
+
params,
|
|
98
|
+
)
|
|
99
|
+
return normalize_unity_response(raw)
|
|
100
|
+
|
|
101
|
+
if unity_instance:
|
|
102
|
+
kwargs.setdefault("instance_id", unity_instance)
|
|
103
|
+
return await send_fn(*args, **kwargs)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared module discovery utilities for auto-registering tools and resources.
|
|
3
|
+
"""
|
|
4
|
+
import importlib
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pkgutil
|
|
8
|
+
from typing import Generator
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("mcp-for-unity-server")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_modules(base_dir: Path, package_name: str) -> Generator[str, None, None]:
|
|
14
|
+
"""
|
|
15
|
+
Discover and import all Python modules in a directory and its subdirectories.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
base_dir: The base directory to search for modules
|
|
19
|
+
package_name: The package name to use for relative imports (e.g., 'tools' or 'resources')
|
|
20
|
+
|
|
21
|
+
Yields:
|
|
22
|
+
Full module names that were successfully imported
|
|
23
|
+
"""
|
|
24
|
+
# Discover modules in the top level
|
|
25
|
+
for _, module_name, _ in pkgutil.iter_modules([str(base_dir)]):
|
|
26
|
+
# Skip private modules and __init__
|
|
27
|
+
if module_name.startswith('_'):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
full_module_name = f'.{module_name}'
|
|
32
|
+
importlib.import_module(full_module_name, package_name)
|
|
33
|
+
yield full_module_name
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.warning(f"Failed to import module {module_name}: {e}")
|
|
36
|
+
|
|
37
|
+
# Discover modules in subdirectories (one level deep)
|
|
38
|
+
for subdir in base_dir.iterdir():
|
|
39
|
+
if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name.startswith('.'):
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Check if subdirectory contains Python modules
|
|
43
|
+
for _, module_name, _ in pkgutil.iter_modules([str(subdir)]):
|
|
44
|
+
# Skip private modules and __init__
|
|
45
|
+
if module_name.startswith('_'):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
# Import as package.subdirname.modulename
|
|
50
|
+
full_module_name = f'.{subdir.name}.{module_name}'
|
|
51
|
+
importlib.import_module(full_module_name, package_name)
|
|
52
|
+
yield full_module_name
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.warning(
|
|
55
|
+
f"Failed to import module {subdir.name}.{module_name}: {e}")
|
utils/reload_sentinel.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
|
|
3
|
+
'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim.
|
|
4
|
+
All functions are no-ops to prevent accidental external writes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def flip_reload_sentinel(*args, **kwargs) -> str:
|
|
9
|
+
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"
|