How Handler Discovery Works in Unity MCP for TypeScript: Automatic Runtime Registration Explained
Unity MCP automatically discovers, instantiates, and registers TypeScript handlers at runtime by scanning the compiled handlers directory, dynamically importing JavaScript modules, and validating instances against type guards before wiring them to the MCP server.
Unity MCP (isuzu-shiranui/unitymcp) implements a plug-and-play architecture for its TypeScript server that eliminates manual registration boilerplate. The HandlerDiscovery.ts module orchestrates a ten-step runtime pipeline that transforms static handler files into live MCP tools, resources, and prompts without requiring code changes to the core server.
The Runtime Discovery Pipeline in HandlerDiscovery.ts
The discovery process is encapsulated in unity-mcp-ts/src/core/HandlerDiscovery.ts and executes automatically when the MCP server initializes.
Scanning and Importing Handler Modules
The discovery class first resolves the handlers directory relative to its own location using __dirname, then reads all compiled JavaScript files:
const handlersDir = join(__dirname, '../handlers');
const files = await fs.readdir(handlersDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
Since the TypeScript source compiles to JavaScript before execution, the system specifically targets .js files. Each file undergoes an ES-module dynamic import:
const module = await import(`../handlers/${file}`);
Instantiation and Initialization
For every exported member in the imported module, the discovery logic checks if the export is a constructor function with a prototype. Valid classes are instantiated and immediately initialized with the shared UnityConnection instance:
const instance = new exportedValue();
instance.initialize(this.unityConnection);
This initialization step ensures every handler receives the connection object required to communicate with the Unity editor before registration proceeds.
Type-Guard Validation and Registration
The discovery system implements three private type-guards to enforce interface contracts: isCommandHandler(), isResourceHandler(), and isPromptHandler(). These functions verify that instances expose the required members, such as commandPrefix and execute() for commands, or resourceUriTemplate and fetchResource() for resources.
Once validated, handlers are stored in their respective registries (CommandRegistry, ResourceRegistry, PromptRegistry) and simultaneously adapted to the MCP SDK via HandlerAdapter.ts:
this.commandRegistry.registerHandler(instance);
this.adapter.registerCommandHandler(instance);
The system maintains counters for each HandlerType and logs successful registrations to stderr with the format [INFO] Registered command handler: ${instance.commandPrefix}.
Interface Contracts and Type Safety
Handler implementations must satisfy one of three interfaces defined in unity-mcp-ts/src/core/interfaces/ to pass discovery validation.
ICommandHandler requires commandPrefix, description, execute(action, parameters), and initialize(connection), plus optional getToolDefinitions() for exposing tools to the MCP UI.
IResourceHandler mandates resourceName, resourceUriTemplate, fetchResource(uri, parameters?), and initialize(connection) for URI-based data providers.
IPromptHandler specifies promptName, getPromptDefinitions(), and initialize(connection) for template-based chat prompts.
The type-guards in HandlerDiscovery.ts perform runtime structural typing checks, ensuring that only valid handlers reach the registration phase while providing clear error boundaries that log [ERROR] messages for failed imports or invalid exports.
Bridging Discovery to the MCP Server
HandlerAdapter.ts (unity-mcp-ts/src/core/HandlerAdapter.ts) serves as the bridge between discovered handlers and the MCP SDK's McpServer instance. It translates internal handler representations into standard MCP protocol registrations:
- Command handlers become tools via
server.tool(), utilizing definitions fromgetToolDefinitions()when available - Resource handlers register as resources or resource templates via
server.resource()depending on whether their URIs contain template parameters - Prompt handlers expose prompts through
server.prompt()with parameter injection support
This adapter pattern separates the discovery mechanics from protocol-specific implementation details, allowing the discovery system to remain agnostic of the underlying MCP SDK version.
Implementing Custom Handlers
Adding functionality requires only creating a new file in unity-mcp-ts/src/handlers/ that exports a class implementing one of the required interfaces.
Command Handler Example
// File: unity-mcp-ts/src/handlers/MyAwesomeCommandHandler.ts
import { BaseCommandHandler } from "../core/BaseCommandHandler.js";
export class MyAwesomeCommandHandler extends BaseCommandHandler {
public get commandPrefix() { return "awesome"; }
public get description() { return "Demo command that greets the user"; }
public async execute(action: string, params: any) {
if (action === "greet") {
return { success: true, message: `Hello, ${params.name ?? "world"}!` };
}
return { success: false, error: "Unsupported action" };
}
public getToolDefinitions() {
return new Map([
["awesome_greet", {
description: "Greets a user",
parameterSchema: { name: z.string().optional() },
annotations: { title: "Greet", readOnlyHint: true }
}]
]);
}
}
Upon server startup, HandlerDiscovery finds the compiled MyAwesomeCommandHandler.js, instantiates the class, validates it against isCommandHandler, and exposes the awesome_greet tool through the adapter.
Resource Handler Example
// File: unity-mcp-ts/src/handlers/AssetResourceHandler.ts
import { BaseResourceHandler } from "../core/BaseResourceHandler.js";
export class AssetResourceHandler extends BaseResourceHandler {
public get resourceName() { return "unity/asset"; }
public get resourceUriTemplate() { return "unity://asset/{guid}"; }
public get description() { return "Fetch Unity assets by GUID"; }
public async fetchResource(uri: string, parameters: any) {
const asset = await this.unityConnection.request("asset.get", parameters);
return asset;
}
}
Because the resourceUriTemplate contains a {guid} parameter, HandlerAdapter automatically registers this as a ResourceTemplate, enabling clients to request specific assets via URIs like unity://asset/1234abcd.
Summary
- Automatic Discovery:
HandlerDiscovery.tsscans../handlersrelative to__dirname, filtering for.jsfiles to accommodate the TypeScript compilation pipeline - Dynamic Loading: ES-module dynamic imports load handler classes at runtime, enabling true plug-and-play extensibility without server restarts or configuration changes
- Type Safety: Runtime type-guards (
isCommandHandler,isResourceHandler,isPromptHandler) enforce interface contracts before registration - Unified Initialization: Every handler receives a
UnityConnectioninstance viainitialize()before registration, ensuring immediate access to Unity editor communication - SDK Integration:
HandlerAdapter.tstranslates discovered handlers into standard MCP tools, resources, and prompts, bridging the custom handler architecture with the protocol SDK
Frequently Asked Questions
Why does the discovery system scan .js files instead of .ts files?
The TypeScript server must compile to JavaScript before Node.js can execute it. The discovery mechanism runs against the compiled output in the handlers directory, targeting .js files because that is what the Node.js runtime actually loads when executing import() statements against the compiled unity-mcp-ts package.
What happens if a handler fails to import or instantiate?
Any failure during dynamic import, class instantiation, or initialization is caught by the error handling boundary in HandlerDiscovery.ts and logged to stderr with an [ERROR] prefix containing the specific failure reason. The failed handler is skipped, but the discovery process continues processing remaining handlers, ensuring that one broken file does not crash the entire MCP server.
Can a single handler implement multiple interface types?
While the discovery system checks each exported class against all three type-guards sequentially, the architecture expects handlers to implement only one primary interface (Command, Resource, or Prompt) for clarity and separation of concerns. However, if a class satisfies multiple guards, it would theoretically be registered in multiple registries, though this pattern is not used in the standard handler implementations like ConsoleCommandHandler.ts or PackageResourceHandler.ts.
How can I verify that my handler was discovered successfully?
Check the server logs for [INFO] Registered command handler: your-prefix (or the equivalent resource/prompt message). If you do not see this log entry immediately after server startup, verify that your file ends with .js after compilation, exports the class as a named export, and that the class properly implements all required interface methods for the type-guard to pass validation.
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 →