How MCP Custom Tool Service Handles Project-Scoped Tools in Unity
The MCP server in CoplayDev/unity-mcp supports both global and project-scoped custom tools, storing per-project definitions in _project_tools and checking the project_scoped_tools flag during registration to determine visibility scope.
The MCP custom tool service provides a flexible registration system that isolates tools per Unity project or shares them globally across all connected instances. This architecture lives primarily in Server/src/services/custom_tool_service.py and allows developers to expose project-specific utilities without polluting the global tool namespace.
Service Initialization and Scope Configuration
When the server boots in Server/src/main.py, it instantiates CustomToolService with a boolean flag that controls scoping behavior.
The constructor signature defines the default behavior:
def __init__(self, mcp: FastMCP, project_scoped_tools: bool = True):
CustomToolService._instance = self
self._mcp = mcp
self._project_scoped_tools = project_scoped_tools # ← scope flag
project_scoped_tools defaults to True, meaning tools submitted by a Unity project are stored privately unless the server explicitly disables isolation. This flag is checked later during the registration flow at lines L108‑L124.
Registering Tools via the HTTP Endpoint
Unity projects communicate with the service through the /register-tools POST route. This endpoint accepts a JSON payload containing the project_id, optional project_hash, and a list of tool definitions.
The route handler in Server/src/services/custom_tool_service.py‑L89) validates the payload and delegates to the internal registration method:
@self._mcp.custom_route("/register-tools", methods=["POST"])
async def register_tools(request: Request) -> JSONResponse:
payload = RegisterToolsPayload.model_validate(await request.json())
registered, replaced = self._register_project_tools(
payload.project_id, payload.tools, project_hash=payload.project_hash)
Project-Scoped Registration Logic
The method _register_project_tools implements the core isolation logic. It stores every tool definition under self._project_tools[project_id], creating a nested dictionary that maps project identifiers to their private tool sets.
During iteration, the service respects the project_scoped_tools flag to decide whether to also promote the tool globally:
for tool in tools:
# ... validation logic ...
self._register_tool(project_id, tool) # always saved per-project
if not self._project_scoped_tools: # global only when disabled
self._register_global_tool(tool)
When project_scoped_tools is True, the tool remains private to the specified project_id. When False, the definition is duplicated into self._global_tools, making it visible to every Unity project connected to the server.
Global Tool Handling and Overrides
Even when project-scoped mode is enabled, administrators can inject global tools programmatically using register_global_tools (lines L290‑L334). This public method skips any names that collide with built-in tools to prevent accidental overrides:
# From Server/src/services/custom_tool_service.py
def register_global_tools(self, tools: List[ToolDefinitionModel]):
for tool in tools:
if tool.name in self.BUILT_IN_TOOL_NAMES:
logger.warning(f"Skipping global registration of built-in tool: {tool.name}")
continue
self._register_global_tool(tool)
Tool Execution and Lookup Order
When an MCP client invokes a tool, execute_tool resolves the correct definition using a prioritized fallback chain (lines L20‑L26):
# 1. Check project-scoped registry first
tool = self._project_tools.get(project_id, {}).get(tool_name)
if tool:
return tool
# 2. Fall back to global registry
tool = self._global_tools.get(tool_name)
if tool:
return tool
# 3. Final fallback to Hub-provided definitions
return await PluginHub.get_tool_definition(project_id, tool_name, user_id=user_id)
This lookup order ensures that project-scoped definitions shadow global tools of the same name, allowing per-project overrides while maintaining a shared fallback layer.
Resolving Project Identity via Hash
Unity instances often identify themselves using a Name@hash format. The service resolves these to canonical project_id values through resolve_project_id_for_unity_instance (lines L99‑L146):
- Direct resolution: If the instance was discovered via the stdio connection pool, the hash provided by Unity is returned immediately.
- Hash-to-project mapping: During registration, the service populates
self._hash_to_projectwith mappings derived from theproject_hashfield, enabling lookup even for instances not currently in the connection pool.
Practical Examples
Registering a Project-Scoped Tool via HTTP
Projects send tool definitions to the registration endpoint. Because the server defaults to project_scoped_tools=True, these remain private:
curl -X POST http://localhost:8000/register-tools \
-H "Content-Type: application/json" \
-d '{
"project_id": "my-awesome-game",
"tools": [
{
"name": "generate-terrain",
"description": "Creates procedural terrain",
"parameters": [
{"name": "width", "type": "int", "required": true},
{"name": "height", "type": "int", "required": true}
],
"requires_polling": false
}
]
}'
The generate-terrain tool is now only callable when the MCP client specifies "my-awesome-game" as the active project.
Disabling Project Scope for Global Registration
To force all incoming tools into the global registry, modify the service instantiation in Server/src/main.py:
from services.custom_tool_service import CustomToolService
# Disable per-project isolation
custom_tool_service = CustomToolService(mcp, project_scoped_tools=False)
Now _register_project_tools will invoke self._register_global_tool(tool) for every submitted definition, making them available to any Unity instance regardless of project_id.
Programmatic Global Registration
Server-side plugins can inject shared utilities without using the HTTP route:
from services.custom_tool_service import CustomToolService
from models.models import ToolDefinitionModel, ToolParameterModel
ci_tool = ToolDefinitionModel(
name="upload-build",
description="Uploads a build to the CI server",
parameters=[ToolParameterModel(name="branch", type="string", required=True)],
requires_polling=False,
)
CustomToolService.get_instance().register_global_tools([ci_tool])
Summary
- Isolation by default: The
CustomToolServiceconstructor setsproject_scoped_tools=True, ensuring tools are stored per-project inself._project_toolsunless explicitly configured otherwise. - Dual storage paths: The
_register_project_toolsmethod always saves tools to the project dictionary and conditionally adds them toself._global_toolsbased on the scope flag. - Priority resolution: During execution, the service checks project-scoped definitions first, then global tools, then falls back to the Plugin Hub.
- Hash resolution: The service maintains
self._hash_to_projectmappings to resolve Unity instance hashes to canonical project IDs for proper tool routing. - Collision safety: Global registration methods skip built-in tool names to prevent accidental overrides of core MCP functionality.
Frequently Asked Questions
How do I make a custom tool available to all Unity projects?
Set project_scoped_tools=False when initializing CustomToolService in Server/src/main.py. Alternatively, call register_global_tools() programmatically with your tool definitions, which bypasses the project-scoped storage entirely.
What happens if two projects register tools with the same name?
Each project's tools are stored under separate keys in self._project_tools[project_id], so name collisions only occur within the same project. During execution, the lookup uses the requesting project's ID, ensuring the correct definition is retrieved. Global tools share a single namespace, so the last registered global definition wins if names collide.
Can a project override a global tool with its own version?
Yes. The lookup order in execute_tool checks self._project_tools before self._global_tools. If a project registers a tool with the same name as a global tool, the project-scoped version takes precedence for that specific project.
How does the service handle Unity projects that haven't registered tools yet?
If no project-scoped or global definition exists, the service falls back to PluginHub.get_tool_definition(). This delegates to the Hub system, which may provide default tools or return a "not found" response depending on the Hub configuration.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →