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
Marcus Sanatan 2026-01-21 13:07:52 -04:00 committed by GitHub
parent d96dad3e9a
commit b69ee80da9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 309 additions and 54 deletions

View File

@ -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";

View File

@ -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;

View File

@ -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()

View File

@ -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" />

View File

@ -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 },

View File

@ -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

View File

@ -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:
""" """

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 569d5f32146c348ad96a95a3ba1b394a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: