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 GitUrlOverride = "MCPForUnity.GitUrlOverride";
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";
internal const string ProjectScopedToolsLocalHttp = "MCPForUnity.ProjectScopedTools.LocalHttp";
internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
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.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
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)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}";
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
fileName = uvxPath;
arguments = args;

View File

@ -35,6 +35,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
private TextField httpUrlField;
private Button startHttpServerButton;
private Button stopHttpServerButton;
private VisualElement projectScopedToolsRow;
private Toggle projectScopedToolsToggle;
private VisualElement unitySocketPortRow;
private TextField unityPortField;
private VisualElement statusIndicator;
@ -83,6 +85,8 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
httpUrlField = Root.Q<TextField>("http-url");
startHttpServerButton = Root.Q<Button>("start-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");
unityPortField = Root.Q<TextField>("unity-port");
statusIndicator = Root.Q<VisualElement>("status-indicator");
@ -124,6 +128,14 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
httpUrlField.value = HttpEndpointUtility.GetBaseUrl();
if (projectScopedToolsToggle != null)
{
projectScopedToolsToggle.value = EditorPrefs.GetBool(
EditorPrefKeys.ProjectScopedToolsLocalHttp,
true
);
}
int unityPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 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)
{
copyHttpServerCommandButton.clicked += () =>
@ -456,9 +478,24 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
bool useHttp = (TransportProtocol)transportDropdown.value != TransportProtocol.Stdio;
httpUrlRow.style.display = useHttp ? DisplayStyle.Flex : DisplayStyle.None;
UpdateProjectScopedToolsVisibility();
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()
{
return transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTPLocal;
@ -514,6 +551,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{
UpdateStartHttpButtonState();
UpdateHttpServerCommandDisplay();
UpdateProjectScopedToolsVisibility();
}
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: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:Label text="Unity Socket Port:" class="setting-label" />
<ui:TextField name="unity-port" class="port-field" />

View File

@ -40,6 +40,7 @@ namespace MCPForUnity.Editor.Windows
{ EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool },
{ EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool },
{ EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool },
{ EditorPrefKeys.ProjectScopedToolsLocalHttp, EditorPrefType.Bool },
// Integer prefs
{ 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()
logger.info("MCP for Unity Server shut down")
# Initialize MCP server
mcp = FastMCP(
name="mcp-for-unity-server",
lifespan=server_lifespan,
instructions="""
def _build_instructions(project_scoped_tools: bool) -> str:
if project_scoped_tools:
custom_tools_note = (
"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.
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:
- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash).
@ -295,10 +304,18 @@ Payload sizing & paging (important):
- 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).
"""
def create_mcp_server(project_scoped_tools: bool) -> FastMCP:
mcp = FastMCP(
name="mcp-for-unity-server",
lifespan=server_lifespan,
instructions=_build_instructions(project_scoped_tools),
)
custom_tool_service = CustomToolService(mcp)
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:
@ -308,13 +325,11 @@ async def health_http(_: Request) -> JSONResponse:
"message": "MCP for Unity server is running"
})
@mcp.custom_route("/plugin/sessions", methods=["GET"])
async def plugin_sessions_route(_: Request) -> JSONResponse:
data = await PluginHub.get_sessions()
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()
@ -331,10 +346,12 @@ if not existing_routes:
WebSocketRoute("/hub/plugin", PluginHub))
# Register all tools
register_all_tools(mcp)
register_all_tools(mcp, project_scoped_tools=project_scoped_tools)
# Register all resources
register_all_resources(mcp)
register_all_resources(mcp, project_scoped_tools=project_scoped_tools)
return mcp
def main():
@ -421,6 +438,11 @@ Examples:
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."
)
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()
@ -469,6 +491,8 @@ Examples:
if args.http_port:
logger.info(f"HTTP port override: {http_port}")
mcp = create_mcp_server(args.project_scoped_tools)
# Determine transport mode
if transport_mode == 'http':
# Use HTTP transport for FastMCP

View File

@ -1,21 +1,25 @@
import asyncio
import inspect
import logging
import time
from hashlib import sha256
from typing import Optional
from fastmcp import FastMCP
from fastmcp import Context, FastMCP
from pydantic import BaseModel, Field, ValidationError
from starlette.requests import Request
from starlette.responses import JSONResponse
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.legacy.unity_connection import (
async_send_command_with_retry,
get_unity_connection_pool,
)
from transport.plugin_hub import PluginHub
from services.tools import get_unity_instance_from_context
logger = logging.getLogger("mcp-for-unity-server")
@ -39,11 +43,13 @@ class ToolRegistrationResponse(BaseModel):
class CustomToolService:
_instance: "CustomToolService | None" = None
def __init__(self, mcp: FastMCP):
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
CustomToolService._instance = self
self._mcp = mcp
self._project_scoped_tools = project_scoped_tools
self._project_tools: dict[str, dict[str, ToolDefinitionModel]] = {}
self._hash_to_project: dict[str, str] = {}
self._global_tools: dict[str, ToolDefinitionModel] = {}
self._register_http_routes()
@classmethod
@ -61,17 +67,8 @@ class CustomToolService:
except ValidationError as exc:
return JSONResponse({"success": False, "error": exc.errors()}, status_code=400)
registered: list[str] = []
replaced: list[str] = []
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
registered, replaced = self._register_project_tools(
payload.project_id, payload.tools, project_hash=payload.project_hash)
message = f"Registered {len(registered)} tool(s)"
if replaced:
@ -266,6 +263,153 @@ class CustomToolService:
return None
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:
"""

View File

@ -18,7 +18,7 @@ logger = logging.getLogger("mcp-for-unity-server")
__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.
@ -46,6 +46,11 @@ def register_all_resources(mcp: FastMCP):
description = resource_info['description']
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})
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.
@ -46,6 +46,11 @@ def register_all_tools(mcp: FastMCP):
description = tool_info['description']
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
wrapped = log_execution(tool_name, "Tool")(func)
wrapped = telemetry_tool(tool_name)(wrapped)

View File

@ -315,6 +315,23 @@ class PluginHub(WebSocketEndpoint):
logger.info(
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:
cls = type(self)
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: