Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 66 additions & 35 deletions Server/src/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from starlette.requests import Request
from transport.unity_instance_middleware import (
InstanceTargetError,
UnityInstanceMiddleware,
get_unity_instance_middleware
get_unity_instance_middleware,
resolve_instance_identifier,
)
from services.api_key_service import ApiKeyService
from transport.legacy.unity_connection import get_unity_connection_pool, UnityConnectionPool
Expand All @@ -10,6 +12,7 @@
from services.resources import register_all_resources
from transport.plugin_registry import PluginRegistry
from transport.plugin_hub import PluginHub
from transport.models import SessionDetails
from services.custom_tool_service import (
CustomToolService,
resolve_project_id_for_unity_instance,
Expand All @@ -31,7 +34,8 @@
import os
import threading
import time
from typing import AsyncIterator, Any
from types import SimpleNamespace
from typing import AsyncIterator, Any, Mapping
from urllib.parse import urlparse

# Workaround for environments where tool signature evaluation runs with a globals
Expand Down Expand Up @@ -363,13 +367,41 @@ def _build_instructions(project_scoped_tools: bool) -> str:
"""


def _normalize_instance_token(instance_token: str | None) -> tuple[str | None, str | None]:
if not instance_token:
return None, None
if "@" in instance_token:
name_part, _, hash_part = instance_token.partition("@")
return (name_part or None), (hash_part or None)
return None, instance_token
def _resolve_http_instance_target(
unity_instance: str,
sessions: Mapping[str, SessionDetails],
) -> tuple[str, str, SessionDetails]:
"""Resolve an explicit HTTP target to ``(session_id, Name@hash, details)``."""
candidates: list[SimpleNamespace] = []
for session_id, details in sessions.items():
project = details.project or "Unknown"
hash_value = details.hash
if not hash_value:
continue
candidates.append(SimpleNamespace(
id=f"{project}@{hash_value}",
name=project,
hash=hash_value,
session_id=session_id,
))

canonical_id = resolve_instance_identifier(
unity_instance,
candidates,
transport_mode="http",
)
target = next(
(candidate for candidate in candidates if candidate.id == canonical_id),
None,
)
if target is None:
# The resolver only returns ids from candidates; keep the failure explicit
# if a future candidate type violates that invariant.
raise InstanceTargetError(
f"Unity instance '{unity_instance}' not found.",
status_code=404,
)
return target.session_id, target.id, sessions[target.session_id]


def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
Expand Down Expand Up @@ -434,15 +466,18 @@ async def cli_command_route(request: Request) -> JSONResponse:
# Find target session
session_id = None
session_details = None
instance_name, instance_hash = _normalize_instance_token(
unity_instance)
if unity_instance:
# Try to match by hash or project name
for sid, details in sessions.sessions.items():
if details.hash == instance_hash or details.project in (instance_name, unity_instance):
session_id = sid
session_details = details
break
resolved_instance = None
if unity_instance is not None and unity_instance != "":
try:
session_id, resolved_instance, session_details = _resolve_http_instance_target(
unity_instance,
sessions.sessions,
)
except InstanceTargetError as exc:
return JSONResponse(
{"success": False, "error": str(exc)},
status_code=exc.status_code,
)

# If a specific unity_instance was requested but not found, return an error
# (Check done here so execute_custom_tool can also validate the instance)
Expand Down Expand Up @@ -503,9 +538,9 @@ async def cli_command_route(request: Request) -> JSONResponse:
)

# Prefer a concrete hash for project-scoped tools.
unity_instance_hint = unity_instance
unity_instance_hint = resolved_instance
if session_details and session_details.hash:
unity_instance_hint = session_details.hash
unity_instance_hint = resolved_instance or session_details.hash

project_id = resolve_project_id_for_unity_instance(
unity_instance_hint)
Expand Down Expand Up @@ -553,8 +588,6 @@ async def cli_custom_tools_route(request: Request) -> JSONResponse:
"""REST endpoint to list custom tools for the active Unity project."""
try:
unity_instance = request.query_params.get("instance")
instance_name, instance_hash = _normalize_instance_token(
unity_instance)

sessions = await PluginHub.get_sessions()
if not sessions.sessions:
Expand All @@ -564,27 +597,25 @@ async def cli_custom_tools_route(request: Request) -> JSONResponse:
}, status_code=503)

session_details = None
resolved_instance = None
if unity_instance:
# Try to match by hash or project name
for _, details in sessions.sessions.items():
if details.hash == instance_hash or details.project in (instance_name, unity_instance):
session_details = details
break
if not session_details:
try:
_, resolved_instance, session_details = _resolve_http_instance_target(
unity_instance,
sessions.sessions,
)
except InstanceTargetError as exc:
return JSONResponse(
{
"success": False,
"error": f"Unity instance '{unity_instance}' not found",
},
status_code=404,
{"success": False, "error": str(exc)},
status_code=exc.status_code,
)
else:
# No specific unity_instance requested: use first available session
session_details = next(iter(sessions.sessions.values()))

unity_instance_hint = unity_instance
unity_instance_hint = resolved_instance
if session_details and session_details.hash:
unity_instance_hint = session_details.hash
unity_instance_hint = resolved_instance or session_details.hash

project_id = resolve_project_id_for_unity_instance(
unity_instance_hint)
Expand Down
3 changes: 2 additions & 1 deletion Server/src/services/custom_tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
from transport.plugin_hub import PluginHub
from services.tools import get_unity_instance_from_context
from services.registry import get_registered_tools
from services.registry import get_registered_tools, UNITY_TARGETABLE_TAG

logger = logging.getLogger("mcp-for-unity-server")

Expand Down Expand Up @@ -361,6 +361,7 @@ def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
wrapped = self._mcp.tool(
name=definition.name,
description=definition.description,
tags={UNITY_TARGETABLE_TAG},
)(wrapped)
except Exception as exc: # pragma: no cover - defensive against tool conflicts
logger.warning(
Expand Down
2 changes: 2 additions & 0 deletions Server/src/services/registry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
clear_tool_registry,
TOOL_GROUPS,
DEFAULT_ENABLED_GROUPS,
UNITY_TARGETABLE_TAG,
)
from .resource_registry import (
mcp_for_unity_resource,
Expand All @@ -22,6 +23,7 @@
'clear_tool_registry',
'TOOL_GROUPS',
'DEFAULT_ENABLED_GROUPS',
'UNITY_TARGETABLE_TAG',
'mcp_for_unity_resource',
'get_registered_resources',
'clear_resource_registry'
Expand Down
4 changes: 4 additions & 0 deletions Server/src/services/registry/resource_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def mcp_for_unity_resource(
uri: str,
name: str | None = None,
description: str | None = None,
unity_targetable: bool = True,
**kwargs
) -> Callable:
"""
Expand All @@ -21,6 +22,8 @@ def mcp_for_unity_resource(
Args:
name: Resource name (defaults to function name)
description: Resource description
unity_targetable: Whether to expose the optional ``unity_instance``
URI query parameter for per-call routing.
**kwargs: Additional arguments passed to @mcp.resource()

Example:
Expand All @@ -35,6 +38,7 @@ def decorator(func: Callable) -> Callable:
'uri': uri,
'name': resource_name,
'description': description,
'unity_targetable': unity_targetable,
'kwargs': kwargs
})

Expand Down
25 changes: 25 additions & 0 deletions Server/src/services/registry/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@

DEFAULT_ENABLED_GROUPS: set[str] = {"core"}

# FastMCP tag used to identify tools whose calls can target a Unity instance.
UNITY_TARGETABLE_TAG = "mcpforunity:unity-targetable"


def mcp_for_unity_tool(
name: str | None = None,
description: str | None = None,
unity_target: str | None = "self",
group: str | None = "core",
unity_targetable: bool | None = None,
**kwargs
) -> Callable:
"""
Expand All @@ -50,6 +54,9 @@ def mcp_for_unity_tool(
- "self" (default): tool follows its own enabled state.
- None: server-only tool, always visible in tool listing.
- "<tool_name>": alias tool that follows another Unity tool state.
unity_targetable: Whether calls to this tool accept the optional
``unity_instance`` routing envelope. Defaults to True for Unity
tools and False for server-only tools.
group: Tool group for dynamic visibility.
- A group name string (e.g. "core", "vfx") assigns the tool to
that group and adds a ``tags={"group:<name>"}`` entry.
Expand All @@ -67,6 +74,8 @@ def decorator(func: Callable) -> Callable:
tool_kwargs = dict(kwargs) # Create a copy to avoid side effects
if "unity_target" in tool_kwargs:
del tool_kwargs["unity_target"]
if "unity_targetable" in tool_kwargs:
del tool_kwargs["unity_targetable"]
if "group" in tool_kwargs:
del tool_kwargs["group"]

Expand Down Expand Up @@ -96,11 +105,27 @@ def decorator(func: Callable) -> Callable:
"Expected None or a non-empty string."
)

if unity_targetable is None:
resolved_unity_targetable = normalized_unity_target is not None
elif isinstance(unity_targetable, bool):
resolved_unity_targetable = unity_targetable
else:
raise ValueError(
f"Invalid unity_targetable for tool '{tool_name}': "
f"{unity_targetable!r}. Expected a bool or None."
)

if resolved_unity_targetable:
existing_tags = set(tool_kwargs.get("tags") or set())
existing_tags.add(UNITY_TARGETABLE_TAG)
tool_kwargs["tags"] = existing_tags

_tool_registry.append({
'func': func,
'name': tool_name,
'description': description,
'unity_target': normalized_unity_target,
'unity_targetable': resolved_unity_targetable,
'group': resolved_group,
'kwargs': tool_kwargs,
})
Expand Down
Loading