Three-Layer Python Design
The Python server (Server/src/) exposes three distinct surfaces. They look similar but serve different consumers and are not auto-generated from each other.
| Layer | Where | Framework | Consumer | Transport to Unity |
|---|---|---|---|---|
| MCP Tools | Server/src/services/tools/ | FastMCP (@mcp_for_unity_tool) | AI assistants via MCP | WebSocket (send_with_unity_instance) |
| CLI Commands | Server/src/cli/commands/ | Click (@click.command) | Developers in a terminal | HTTP (run_command) |
| Resources | Server/src/services/resources/ | FastMCP (@mcp_for_unity_resource) | AI assistants, read-only | WebSocket |
Both MCP tools and CLI commands eventually call the same C# HandleCommand methods inside MCPForUnity/Editor/Tools/. Resources are read-only — they observe state without mutating it.
Why three layers, not one
Each surface has a different shape of consumer:
- MCP tools need rich type annotations (
Annotated[Type, "description"]) because they're handed to an LLM. The descriptions are the prompt the LLM reads. - CLI commands need composable flags, shell-friendly defaults, and graceful error messages. Click gives those for free.
- Resources need to be cheap to call repeatedly because the LLM polls them. They use a lighter decorator and skip the routing middleware.
Trying to autogenerate one layer from another erodes the ergonomics of all three. The cost is keeping the three in sync — which is mostly a discipline problem solved by domain symmetry.
Domain symmetry
When you add a new domain (say, manage_navigation), you write three files:
Server/src/services/tools/manage_navigation.py # @mcp_for_unity_tool
Server/src/cli/commands/navigation.py # @click.command
MCPForUnity/Editor/Tools/ManageNavigation.cs # [McpForUnityTool]
The Python tool and CLI command both invoke the C# handler — they just take different paths to it.
Tool registration
Tools are auto-discovered by walking Server/src/services/tools/. Each .py file with @mcp_for_unity_tool-decorated functions is imported at server startup; the decorator side-effects populate a global registry (services.registry). The registry is also what tools/generate_docs_reference.py reads to emit the tool reference.
A tool's group parameter controls per-session visibility — see Tool Groups. group=None means the tool is always visible (server meta-tools like set_active_instance and manage_tools).
Where the layers diverge from "just call the C# handler"
- MCP tools add parameter normalization (camelCase → snake_case via
ParamNormalizerMiddleware), telemetry, and per-session routing. - CLI commands add
@handle_unity_errorsfor terminal-friendly stack traces, and synchronous wrappers around the async core. - Resources skip middleware entirely — they're meant to be hot-path.
Server entry point
Server/src/main.py (~935 lines) is the orchestrator:
- Builds the FastMCP server
- Calls
register_all_tools(mcp)— auto-discovery - Calls
register_all_resources(mcp)— same pattern, different decorator - Sets up the WebSocket hub for HTTP transport
- Configures middleware (telemetry, normalization, instance routing)
- Starts the transport (
http/stdiofromcore.config)
Where to read more
- Tool/CLI handler shape:
Server/src/services/tools/manage_material.pyis a canonical example - Registry:
Server/src/services/registry/tool_registry.py(~130 LOC, the single source the docs reference generator reads) - Transport:
Server/src/transport/— plugin hub (plugin_hub.py), websocket client, legacy stdio bridge - C# side:
MCPForUnity/Editor/Tools/ManageMaterial.csis the C# half ofmanage_material