Project scoped tools (#596)
* feat: Add project-scoped tools flag to control custom tool registration behavior Add `--project-scoped-tools` CLI flag and `UNITY_MCP_PROJECT_SCOPED_TOOLS` environment variable to control whether custom tools are registered globally or scoped to specific Unity projects. Closes #416 * Add .meta file * feat: Add project-scoped tools toggle for local HTTP transport Add UI toggle in Connection section to control project-scoped tools flag when using HTTP Local transport. The toggle: - Defaults to enabled (true) - Persists state in EditorPrefs - Only displays when HTTP Local transport is selected - Automatically appends `--project-scoped-tools` flag to uvx server command - Updates manual config display when toggled * Update Server/src/services/custom_tool_service.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Pass project_scoped_tools flag directly without environment variable conversion Remove unnecessary environment variable conversion for project_scoped_tools flag. * fix: Improve error handling and logging in global custom tool registration Split exception handling to distinguish between expected RuntimeError (service not initialized) and unexpected errors. --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>main
parent
d96dad3e9a
commit
b69ee80da9
|
|
@ -28,6 +28,7 @@ namespace MCPForUnity.Editor.Constants
|
||||||
internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl";
|
internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl";
|
||||||
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
|
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
|
||||||
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
|
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
|
||||||
|
internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp";
|
||||||
|
|
||||||
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
|
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
|
||||||
internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath";
|
internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath";
|
||||||
|
|
|
||||||
|
|
@ -1312,9 +1312,14 @@ namespace MCPForUnity.Editor.Services
|
||||||
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
|
||||||
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
|
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
|
||||||
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
|
||||||
|
bool projectScopedTools = EditorPrefs.GetBool(
|
||||||
|
EditorPrefKeys.ProjectScopedToolsLocalHttp,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
|
||||||
string args = string.IsNullOrEmpty(fromUrl)
|
string args = string.IsNullOrEmpty(fromUrl)
|
||||||
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
|
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
|
||||||
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}";
|
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
|
||||||
|
|
||||||
fileName = uvxPath;
|
fileName = uvxPath;
|
||||||
arguments = args;
|
arguments = args;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
private TextField httpUrlField;
|
private TextField httpUrlField;
|
||||||
private Button startHttpServerButton;
|
private Button startHttpServerButton;
|
||||||
private Button stopHttpServerButton;
|
private Button stopHttpServerButton;
|
||||||
|
private VisualElement projectScopedToolsRow;
|
||||||
|
private Toggle projectScopedToolsToggle;
|
||||||
private VisualElement unitySocketPortRow;
|
private VisualElement unitySocketPortRow;
|
||||||
private TextField unityPortField;
|
private TextField unityPortField;
|
||||||
private VisualElement statusIndicator;
|
private VisualElement statusIndicator;
|
||||||
|
|
@ -83,6 +85,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
httpUrlField = Root.Q<TextField>("http-url");
|
httpUrlField = Root.Q<TextField>("http-url");
|
||||||
startHttpServerButton = Root.Q<Button>("start-http-server-button");
|
startHttpServerButton = Root.Q<Button>("start-http-server-button");
|
||||||
stopHttpServerButton = Root.Q<Button>("stop-http-server-button");
|
stopHttpServerButton = Root.Q<Button>("stop-http-server-button");
|
||||||
|
projectScopedToolsRow = Root.Q<VisualElement>("project-scoped-tools-row");
|
||||||
|
projectScopedToolsToggle = Root.Q<Toggle>("project-scoped-tools-toggle");
|
||||||
unitySocketPortRow = Root.Q<VisualElement>("unity-socket-port-row");
|
unitySocketPortRow = Root.Q<VisualElement>("unity-socket-port-row");
|
||||||
unityPortField = Root.Q<TextField>("unity-port");
|
unityPortField = Root.Q<TextField>("unity-port");
|
||||||
statusIndicator = Root.Q<VisualElement>("status-indicator");
|
statusIndicator = Root.Q<VisualElement>("status-indicator");
|
||||||
|
|
@ -124,6 +128,14 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
|
|
||||||
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
|
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
|
||||||
|
|
||||||
|
if (projectScopedToolsToggle != null)
|
||||||
|
{
|
||||||
|
projectScopedToolsToggle.value = EditorPrefs.GetBool(
|
||||||
|
EditorPrefKeys.ProjectScopedToolsLocalHttp,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int unityPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
|
int unityPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
|
||||||
if (unityPort == 0)
|
if (unityPort == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -231,6 +243,16 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectScopedToolsToggle != null)
|
||||||
|
{
|
||||||
|
projectScopedToolsToggle.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
EditorPrefs.SetBool(EditorPrefKeys.ProjectScopedToolsLocalHttp, evt.newValue);
|
||||||
|
UpdateHttpServerCommandDisplay();
|
||||||
|
OnManualConfigUpdateRequested?.Invoke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (copyHttpServerCommandButton != null)
|
if (copyHttpServerCommandButton != null)
|
||||||
{
|
{
|
||||||
copyHttpServerCommandButton.clicked += () =>
|
copyHttpServerCommandButton.clicked += () =>
|
||||||
|
|
@ -456,9 +478,24 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
bool useHttp = (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
bool useHttp = (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
||||||
|
|
||||||
httpUrlRow.style.display = useHttp ? DisplayStyle.Flex : DisplayStyle.None;
|
httpUrlRow.style.display = useHttp ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
|
UpdateProjectScopedToolsVisibility();
|
||||||
unitySocketPortRow.style.display = useHttp ? DisplayStyle.None : DisplayStyle.Flex;
|
unitySocketPortRow.style.display = useHttp ? DisplayStyle.None : DisplayStyle.Flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateProjectScopedToolsVisibility()
|
||||||
|
{
|
||||||
|
if (projectScopedToolsRow == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
|
||||||
|
bool httpLocalSelected = IsHttpLocalSelected();
|
||||||
|
projectScopedToolsRow.style.display = useHttp && httpLocalSelected
|
||||||
|
? DisplayStyle.Flex
|
||||||
|
: DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsHttpLocalSelected()
|
private bool IsHttpLocalSelected()
|
||||||
{
|
{
|
||||||
return transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTPLocal;
|
return transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTPLocal;
|
||||||
|
|
@ -514,6 +551,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
|
||||||
{
|
{
|
||||||
UpdateStartHttpButtonState();
|
UpdateStartHttpButtonState();
|
||||||
UpdateHttpServerCommandDisplay();
|
UpdateHttpServerCommandDisplay();
|
||||||
|
UpdateProjectScopedToolsVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnHttpServerToggleClicked()
|
private async void OnHttpServerToggleClicked()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@
|
||||||
<ui:Button name="start-http-server-button" text="Start Server" class="action-button start-server-button" style="width: auto; flex-grow: 1;" />
|
<ui:Button name="start-http-server-button" text="Start Server" class="action-button start-server-button" style="width: auto; flex-grow: 1;" />
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
|
<ui:VisualElement class="setting-row" name="project-scoped-tools-row">
|
||||||
|
<ui:Label text="Project-Scoped Tools:" class="setting-label" />
|
||||||
|
<ui:Toggle name="project-scoped-tools-toggle" />
|
||||||
|
</ui:VisualElement>
|
||||||
<ui:VisualElement class="setting-row" name="unity-socket-port-row">
|
<ui:VisualElement class="setting-row" name="unity-socket-port-row">
|
||||||
<ui:Label text="Unity Socket Port:" class="setting-label" />
|
<ui:Label text="Unity Socket Port:" class="setting-label" />
|
||||||
<ui:TextField name="unity-port" class="port-field" />
|
<ui:TextField name="unity-port" class="port-field" />
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ namespace MCPForUnity.Editor.Windows
|
||||||
{ EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool },
|
{ EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool },
|
||||||
{ EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool },
|
{ EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool },
|
||||||
{ EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool },
|
{ EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool },
|
||||||
|
{ EditorPrefKeys.ProjectScopedToolsLocalHttp, EditorPrefType.Bool },
|
||||||
|
|
||||||
// Integer prefs
|
// Integer prefs
|
||||||
{ EditorPrefKeys.UnitySocketPort, EditorPrefType.Int },
|
{ EditorPrefKeys.UnitySocketPort, EditorPrefType.Int },
|
||||||
|
|
|
||||||
|
|
@ -241,14 +241,23 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
||||||
_unity_connection_pool.disconnect_all()
|
_unity_connection_pool.disconnect_all()
|
||||||
logger.info("MCP for Unity Server shut down")
|
logger.info("MCP for Unity Server shut down")
|
||||||
|
|
||||||
# Initialize MCP server
|
|
||||||
mcp = FastMCP(
|
def _build_instructions(project_scoped_tools: bool) -> str:
|
||||||
name="mcp-for-unity-server",
|
if project_scoped_tools:
|
||||||
lifespan=server_lifespan,
|
custom_tools_note = (
|
||||||
instructions="""
|
"I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first "
|
||||||
|
"to see what special capabilities are available for the current project."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
custom_tools_note = (
|
||||||
|
"Custom tools are registered as standard tools when Unity connects. "
|
||||||
|
"No project-scoped custom tools resource is available."
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
This server provides tools to interact with the Unity Game Engine Editor.
|
This server provides tools to interact with the Unity Game Engine Editor.
|
||||||
|
|
||||||
I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first to see what special capabilities are available for the current project.
|
{custom_tools_note}
|
||||||
|
|
||||||
Targeting Unity instances:
|
Targeting Unity instances:
|
||||||
- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
|
- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
|
||||||
|
|
@ -295,46 +304,54 @@ Payload sizing & paging (important):
|
||||||
- Use paging (`page_size`, `page_number`) and keep `page_size` modest (e.g. **25-50**) to avoid token-heavy responses.
|
- Use paging (`page_size`, `page_number`) and keep `page_size` modest (e.g. **25-50**) to avoid token-heavy responses.
|
||||||
- Keep `generate_preview=false` unless you explicitly need thumbnails (previews may include large base64 payloads).
|
- Keep `generate_preview=false` unless you explicitly need thumbnails (previews may include large base64 payloads).
|
||||||
"""
|
"""
|
||||||
)
|
|
||||||
|
|
||||||
custom_tool_service = CustomToolService(mcp)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.custom_route("/health", methods=["GET"])
|
def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
|
||||||
async def health_http(_: Request) -> JSONResponse:
|
mcp = FastMCP(
|
||||||
|
name="mcp-for-unity-server",
|
||||||
|
lifespan=server_lifespan,
|
||||||
|
instructions=_build_instructions(project_scoped_tools),
|
||||||
|
)
|
||||||
|
|
||||||
|
global custom_tool_service
|
||||||
|
custom_tool_service = CustomToolService(
|
||||||
|
mcp, project_scoped_tools=project_scoped_tools)
|
||||||
|
|
||||||
|
@mcp.custom_route("/health", methods=["GET"])
|
||||||
|
async def health_http(_: Request) -> JSONResponse:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"message": "MCP for Unity server is running"
|
"message": "MCP for Unity server is running"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@mcp.custom_route("/plugin/sessions", methods=["GET"])
|
||||||
@mcp.custom_route("/plugin/sessions", methods=["GET"])
|
async def plugin_sessions_route(_: Request) -> JSONResponse:
|
||||||
async def plugin_sessions_route(_: Request) -> JSONResponse:
|
|
||||||
data = await PluginHub.get_sessions()
|
data = await PluginHub.get_sessions()
|
||||||
return JSONResponse(data.model_dump())
|
return JSONResponse(data.model_dump())
|
||||||
|
|
||||||
|
# Initialize and register middleware for session-based Unity instance routing
|
||||||
|
# Using the singleton getter ensures we use the same instance everywhere
|
||||||
|
unity_middleware = get_unity_instance_middleware()
|
||||||
|
mcp.add_middleware(unity_middleware)
|
||||||
|
logger.info("Registered Unity instance middleware for session-based routing")
|
||||||
|
|
||||||
# Initialize and register middleware for session-based Unity instance routing
|
# Mount plugin websocket hub at /hub/plugin when HTTP transport is active
|
||||||
# Using the singleton getter ensures we use the same instance everywhere
|
existing_routes = [
|
||||||
unity_middleware = get_unity_instance_middleware()
|
|
||||||
mcp.add_middleware(unity_middleware)
|
|
||||||
logger.info("Registered Unity instance middleware for session-based routing")
|
|
||||||
|
|
||||||
# Mount plugin websocket hub at /hub/plugin when HTTP transport is active
|
|
||||||
existing_routes = [
|
|
||||||
route for route in mcp._get_additional_http_routes()
|
route for route in mcp._get_additional_http_routes()
|
||||||
if isinstance(route, WebSocketRoute) and route.path == "/hub/plugin"
|
if isinstance(route, WebSocketRoute) and route.path == "/hub/plugin"
|
||||||
]
|
]
|
||||||
if not existing_routes:
|
if not existing_routes:
|
||||||
mcp._additional_http_routes.append(
|
mcp._additional_http_routes.append(
|
||||||
WebSocketRoute("/hub/plugin", PluginHub))
|
WebSocketRoute("/hub/plugin", PluginHub))
|
||||||
|
|
||||||
# Register all tools
|
# Register all tools
|
||||||
register_all_tools(mcp)
|
register_all_tools(mcp, project_scoped_tools=project_scoped_tools)
|
||||||
|
|
||||||
# Register all resources
|
# Register all resources
|
||||||
register_all_resources(mcp)
|
register_all_resources(mcp, project_scoped_tools=project_scoped_tools)
|
||||||
|
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -421,6 +438,11 @@ Examples:
|
||||||
help="Optional path where the server will write its PID on startup. "
|
help="Optional path where the server will write its PID on startup. "
|
||||||
"Used by Unity to stop the exact process it launched when running in a terminal."
|
"Used by Unity to stop the exact process it launched when running in a terminal."
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--project-scoped-tools",
|
||||||
|
action="store_true",
|
||||||
|
help="Keep custom tools scoped to the active Unity project and enable the custom tools resource."
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
@ -469,6 +491,8 @@ Examples:
|
||||||
if args.http_port:
|
if args.http_port:
|
||||||
logger.info(f"HTTP port override: {http_port}")
|
logger.info(f"HTTP port override: {http_port}")
|
||||||
|
|
||||||
|
mcp = create_mcp_server(args.project_scoped_tools)
|
||||||
|
|
||||||
# Determine transport mode
|
# Determine transport mode
|
||||||
if transport_mode == 'http':
|
if transport_mode == 'http':
|
||||||
# Use HTTP transport for FastMCP
|
# Use HTTP transport for FastMCP
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import Context, FastMCP
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
|
from models.models import MCPResponse, ToolDefinitionModel, ToolParameterModel
|
||||||
|
from core.logging_decorator import log_execution
|
||||||
|
from core.telemetry_decorator import telemetry_tool
|
||||||
from transport.unity_transport import send_with_unity_instance
|
from transport.unity_transport import send_with_unity_instance
|
||||||
from transport.legacy.unity_connection import (
|
from transport.legacy.unity_connection import (
|
||||||
async_send_command_with_retry,
|
async_send_command_with_retry,
|
||||||
get_unity_connection_pool,
|
get_unity_connection_pool,
|
||||||
)
|
)
|
||||||
from transport.plugin_hub import PluginHub
|
from transport.plugin_hub import PluginHub
|
||||||
|
from services.tools import get_unity_instance_from_context
|
||||||
|
|
||||||
logger = logging.getLogger("mcp-for-unity-server")
|
logger = logging.getLogger("mcp-for-unity-server")
|
||||||
|
|
||||||
|
|
@ -39,11 +43,13 @@ class ToolRegistrationResponse(BaseModel):
|
||||||
class CustomToolService:
|
class CustomToolService:
|
||||||
_instance: "CustomToolService | None" = None
|
_instance: "CustomToolService | None" = None
|
||||||
|
|
||||||
def __init__(self, mcp: FastMCP):
|
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
|
||||||
CustomToolService._instance = self
|
CustomToolService._instance = self
|
||||||
self._mcp = mcp
|
self._mcp = mcp
|
||||||
|
self._project_scoped_tools = project_scoped_tools
|
||||||
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
|
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
|
||||||
self._hash_to_project: dict[str, str] = {}
|
self._hash_to_project: dict[str, str] = {}
|
||||||
|
self._global_tools: dict[str, ToolDefinitionModel] = {}
|
||||||
self._register_http_routes()
|
self._register_http_routes()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -61,17 +67,8 @@ class CustomToolService:
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
|
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
|
||||||
|
|
||||||
registered: list[str] = []
|
registered, replaced = self._register_project_tools(
|
||||||
replaced: list[str] = []
|
payload.project_id, payload.tools, project_hash=payload.project_hash)
|
||||||
for tool in payload.tools:
|
|
||||||
if self._is_registered(payload.project_id, tool.name):
|
|
||||||
replaced.append(tool.name)
|
|
||||||
self._register_tool(payload.project_id, tool)
|
|
||||||
registered.append(tool.name)
|
|
||||||
|
|
||||||
if payload.project_hash:
|
|
||||||
self._hash_to_project[payload.project_hash.lower(
|
|
||||||
)] = payload.project_id
|
|
||||||
|
|
||||||
message = f"Registered {len(registered)} tool(s)"
|
message = f"Registered {len(registered)} tool(s)"
|
||||||
if replaced:
|
if replaced:
|
||||||
|
|
@ -266,6 +263,153 @@ class CustomToolService:
|
||||||
return None
|
return None
|
||||||
return {"message": str(response)}
|
return {"message": str(response)}
|
||||||
|
|
||||||
|
def _register_project_tools(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
tools: list[ToolDefinitionModel],
|
||||||
|
project_hash: str | None = None,
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
registered: list[str] = []
|
||||||
|
replaced: list[str] = []
|
||||||
|
for tool in tools:
|
||||||
|
if self._is_registered(project_id, tool.name):
|
||||||
|
replaced.append(tool.name)
|
||||||
|
self._register_tool(project_id, tool)
|
||||||
|
registered.append(tool.name)
|
||||||
|
if not self._project_scoped_tools:
|
||||||
|
self._register_global_tool(tool)
|
||||||
|
|
||||||
|
if project_hash:
|
||||||
|
self._hash_to_project[project_hash.lower()] = project_id
|
||||||
|
|
||||||
|
return registered, replaced
|
||||||
|
|
||||||
|
def register_global_tools(self, tools: list[ToolDefinitionModel]) -> None:
|
||||||
|
if self._project_scoped_tools:
|
||||||
|
return
|
||||||
|
for tool in tools:
|
||||||
|
self._register_global_tool(tool)
|
||||||
|
|
||||||
|
def _register_global_tool(self, definition: ToolDefinitionModel) -> None:
|
||||||
|
existing = self._global_tools.get(definition.name)
|
||||||
|
if existing:
|
||||||
|
if existing.model_dump() != definition.model_dump():
|
||||||
|
logger.warning(
|
||||||
|
"Custom tool '%s' already registered with a different schema; keeping existing definition.",
|
||||||
|
definition.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
handler = self._build_global_tool_handler(definition)
|
||||||
|
wrapped = log_execution(definition.name, "Tool")(handler)
|
||||||
|
wrapped = telemetry_tool(definition.name)(wrapped)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wrapped = self._mcp.tool(
|
||||||
|
name=definition.name,
|
||||||
|
description=definition.description,
|
||||||
|
)(wrapped)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive against tool conflicts
|
||||||
|
logger.warning(
|
||||||
|
"Failed to register custom tool '%s' globally: %s",
|
||||||
|
definition.name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._global_tools[definition.name] = definition
|
||||||
|
|
||||||
|
def _build_global_tool_handler(self, definition: ToolDefinitionModel):
|
||||||
|
async def _handler(ctx: Context, **kwargs) -> MCPResponse:
|
||||||
|
unity_instance = get_unity_instance_from_context(ctx)
|
||||||
|
if not unity_instance:
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.",
|
||||||
|
)
|
||||||
|
|
||||||
|
project_id = resolve_project_id_for_unity_instance(unity_instance)
|
||||||
|
if project_id is None:
|
||||||
|
return MCPResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Could not resolve project id for {unity_instance}. Ensure Unity is running and reachable.",
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {k: v for k, v in kwargs.items() if v is not None}
|
||||||
|
service = CustomToolService.get_instance()
|
||||||
|
return await service.execute_tool(project_id, definition.name, unity_instance, params)
|
||||||
|
|
||||||
|
_handler.__name__ = f"custom_tool_{definition.name}"
|
||||||
|
_handler.__doc__ = definition.description or ""
|
||||||
|
_handler.__signature__ = self._build_signature(definition)
|
||||||
|
_handler.__annotations__ = self._build_annotations(definition)
|
||||||
|
return _handler
|
||||||
|
|
||||||
|
def _build_signature(self, definition: ToolDefinitionModel) -> inspect.Signature:
|
||||||
|
params: list[inspect.Parameter] = [
|
||||||
|
inspect.Parameter(
|
||||||
|
"ctx",
|
||||||
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
annotation=Context,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for param in definition.parameters:
|
||||||
|
if not param.name.isidentifier():
|
||||||
|
logger.warning(
|
||||||
|
"Custom tool '%s' has non-identifier parameter '%s'; exposing via kwargs only.",
|
||||||
|
definition.name,
|
||||||
|
param.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
default = inspect._empty if param.required else self._coerce_default(
|
||||||
|
param.default_value, param.type)
|
||||||
|
params.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
param.name,
|
||||||
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
default=default,
|
||||||
|
annotation=self._map_param_type(param),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return inspect.Signature(parameters=params)
|
||||||
|
|
||||||
|
def _build_annotations(self, definition: ToolDefinitionModel) -> dict[str, object]:
|
||||||
|
annotations: dict[str, object] = {"ctx": Context}
|
||||||
|
for param in definition.parameters:
|
||||||
|
if not param.name.isidentifier():
|
||||||
|
continue
|
||||||
|
annotations[param.name] = self._map_param_type(param)
|
||||||
|
return annotations
|
||||||
|
|
||||||
|
def _map_param_type(self, param: ToolParameterModel):
|
||||||
|
ptype = (param.type or "string").lower()
|
||||||
|
if ptype in ("integer", "int"):
|
||||||
|
return int
|
||||||
|
if ptype in ("number", "float", "double"):
|
||||||
|
return float
|
||||||
|
if ptype in ("bool", "boolean"):
|
||||||
|
return bool
|
||||||
|
if ptype in ("array", "list"):
|
||||||
|
return list
|
||||||
|
if ptype in ("object", "dict"):
|
||||||
|
return dict
|
||||||
|
return str
|
||||||
|
|
||||||
|
def _coerce_default(self, value: str | None, param_type: str | None):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
ptype = (param_type or "string").lower()
|
||||||
|
if ptype in ("integer", "int"):
|
||||||
|
return int(value)
|
||||||
|
if ptype in ("number", "float", "double"):
|
||||||
|
return float(value)
|
||||||
|
if ptype in ("bool", "boolean"):
|
||||||
|
return str(value).lower() in ("1", "true", "yes", "on")
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def compute_project_id(project_name: str, project_path: str) -> str:
|
def compute_project_id(project_name: str, project_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ logger = logging.getLogger("mcp-for-unity-server")
|
||||||
__all__ = ['register_all_resources']
|
__all__ = ['register_all_resources']
|
||||||
|
|
||||||
|
|
||||||
def register_all_resources(mcp: FastMCP):
|
def register_all_resources(mcp: FastMCP, *, project_scoped_tools: bool = True):
|
||||||
"""
|
"""
|
||||||
Auto-discover and register all resources in the resources/ directory.
|
Auto-discover and register all resources in the resources/ directory.
|
||||||
|
|
||||||
|
|
@ -46,6 +46,11 @@ def register_all_resources(mcp: FastMCP):
|
||||||
description = resource_info['description']
|
description = resource_info['description']
|
||||||
kwargs = resource_info['kwargs']
|
kwargs = resource_info['kwargs']
|
||||||
|
|
||||||
|
if not project_scoped_tools and resource_name == "custom_tools":
|
||||||
|
logger.info(
|
||||||
|
"Skipping custom_tools resource registration (project-scoped tools disabled)")
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if URI contains query parameters (e.g., {?unity_instance})
|
# Check if URI contains query parameters (e.g., {?unity_instance})
|
||||||
has_query_params = '{?' in uri
|
has_query_params = '{?' in uri
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ __all__ = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def register_all_tools(mcp: FastMCP):
|
def register_all_tools(mcp: FastMCP, *, project_scoped_tools: bool = True):
|
||||||
"""
|
"""
|
||||||
Auto-discover and register all tools in the tools/ directory.
|
Auto-discover and register all tools in the tools/ directory.
|
||||||
|
|
||||||
|
|
@ -46,6 +46,11 @@ def register_all_tools(mcp: FastMCP):
|
||||||
description = tool_info['description']
|
description = tool_info['description']
|
||||||
kwargs = tool_info['kwargs']
|
kwargs = tool_info['kwargs']
|
||||||
|
|
||||||
|
if not project_scoped_tools and tool_name == "execute_custom_tool":
|
||||||
|
logger.info(
|
||||||
|
"Skipping execute_custom_tool registration (project-scoped tools disabled)")
|
||||||
|
continue
|
||||||
|
|
||||||
# Apply the @mcp.tool decorator, telemetry, and logging
|
# Apply the @mcp.tool decorator, telemetry, and logging
|
||||||
wrapped = log_execution(tool_name, "Tool")(func)
|
wrapped = log_execution(tool_name, "Tool")(func)
|
||||||
wrapped = telemetry_tool(tool_name)(wrapped)
|
wrapped = telemetry_tool(tool_name)(wrapped)
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Registered {len(payload.tools)} tools for session {session_id}")
|
f"Registered {len(payload.tools)} tools for session {session_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.custom_tool_service import CustomToolService
|
||||||
|
|
||||||
|
service = CustomToolService.get_instance()
|
||||||
|
service.register_global_tools(payload.tools)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping global custom tool registration: CustomToolService not initialized yet (%s)",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected error during global custom tool registration; "
|
||||||
|
"custom tools may not be available globally",
|
||||||
|
exc_info=exc,
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_command_result(self, payload: CommandResultMessage) -> None:
|
async def _handle_command_result(self, payload: CommandResultMessage) -> None:
|
||||||
cls = type(self)
|
cls = type(self)
|
||||||
lock = cls._lock
|
lock = cls._lock
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 569d5f32146c348ad96a95a3ba1b394a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Loading…
Reference in New Issue