Native modules
Native modules documentation.
When a plugin needs engine APIs ue-mcp's bridge doesn't already expose, it ships a UE C++ module alongside the npm package. The module compiles into the user's project at install time and registers handlers on the bridge via UEMCP::RegisterExternalHandler. This is Shape C from the overview; most plugins never need it.
pie-studio is a real-world example of this shape. Its manifest:
actionPrefix: pie # used only when injecting into a built-in
nativeModule:
uePluginName: PIE_Studio # name of the .uplugin that gets deployed
minBridgeApi: 1 # gate against UEMCP_BRIDGE_API_VERSION
source: ue/Plugins/PIE_Studio # path inside your npm tarball
category: pie # surface handlers under a pie(...) tool
categoryDescription: "PIE record, replay, observe, and input injection"
handlers:
record_arm: { description: "Arm the PIE input recorder" }
replay_arm: { description: "Arm the PIE input replayer" }
inject_input:
description: "Single-frame Enhanced Input inject"
timeoutSeconds: 5
schema:
action_path: { type: string, description: "InputAction asset path (required)" }
value_x: { type: number }
# ... more handlersHow handlers become MCP actions
Set category and ue-mcp surfaces every handler as an MCP action that dispatches to the bare bridge method your C++ registered (record_arm above). No TypeScript task class is involved - the C++ handler is the implementation. The category value picks one of two shapes:
- A new (non-built-in) category - as in the
pieexample above - is provisioned as its own top-level tool the plugin owns. Actions are not prefixed (the category is the namespace):pie(action="record_arm"). SetcategoryDescriptionfor the tool's summary. This is the right choice when the handlers form their own domain. Cross-plugin name collisions resolve first-wins, likeprovides:. - A built-in category (e.g.
gameplay) injects the handlers into that existing tool, prefixed withactionPrefix: handlerrecord_armbecomesgameplay(action="pie_record_arm"). Choose this when the handlers belong inside a category that already exists.
Two rules that bite if missed:
- Declare params under each handler's
schema:. The MCP SDK strips any param not in the action's schema before it reaches the bridge, so an undeclared param silently never arrives. Same field types asinject:schemas. Params-free handlers (status polls, list calls) need no schema. Leave params optional (ue-mcp forces them optional regardless): one flat schema backs every action in a category, so a required param would be forced onto unrelated actions - let your C++ handler validate and return a clear error, and note "(required)" in the param description. timeoutSecondssets the bridge-call timeout for that action (default 30s). Raise it for long-running handlers.
Omit category entirely and handlers are still registered on the bridge but exposed as no MCP action - useful only if another task calls them internally. For an agent-facing plugin you almost always want category.
Layout inside the npm tarball
pie-studio/
ue-mcp.plugin.yml
dist/ # tsc output (TypeScript tasks, if any)
ue/ # native source ships here
Plugins/
PIE_Studio/
PIE_Studio.uplugin
Source/
PIE_Studio/
PIE_Studio.Build.cs
Private/
Handlers/ # handler .cpp files
PIE/ # engine subsystem wrappers
UI/ # editor UI panelsUpdate package.json files: so the ue/ directory ships with the published tarball:
"files": ["dist", "ue", "ue-mcp.plugin.yml", "knowledge", "README.md"]The native module
Add UE_MCP_Bridge to PrivateDependencyModuleNames in your .Build.cs:
public class PIE_Studio : ModuleRules
{
public PIE_Studio(ReadOnlyTargetRules Target) : base(Target)
{
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "Json" });
PrivateDependencyModuleNames.AddRange(new string[] { "UE_MCP_Bridge" });
}
}Register handlers from StartupModule:
#include "MCPHandlerRegistration.h"
void FPIE_StudioModule::StartupModule()
{
UEMCP::RegisterExternalHandler(
TEXT("inject_input"),
[](const TSharedPtr<FJsonObject>& Params) -> TSharedPtr<FJsonValue>
{
// ... do the work, return a JSON value
TSharedPtr<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), true);
return MakeShared<FJsonValueObject>(Result);
});
}
void FPIE_StudioModule::ShutdownModule()
{
UEMCP::UnregisterExternalHandler(TEXT("inject_input"));
}The handler's method name (inject_input) is the bare bridge method. It's what an auto-surfaced action (gameplay(action="pie_inject_input"), via nativeModule.category) dispatches to, what a TypeScript task can address through this.call(...), and what the bridge looks up on any dispatch. Register it bare - ue-mcp adds the actionPrefix when it surfaces the action.
Install flow
ue-mcp plugin install pie-studioThe CLI now also:
- Reads
MCPHandlerRegistration.hfrom the deployed bridge and checks thatUEMCP_BRIDGE_API_VERSION >= manifest.nativeModule.minBridgeApi. Install fails fast if the bridge is too old, with a pointer toue-mcp deploy. - Copies
[pkgDir]/[source]to[projectDir]/Plugins/[uePluginName]/. - Records every copied file in
[projectDir]/.ue-mcp/native-modules.jsonsoue-mcp plugin uninstallcan clean up without nuking user edits. - Prints
REBUILD REQUIRED- the user must build the UE project before launching the editor so the new module compiles in.
Bridge ABI versioning
UEMCP_BRIDGE_API_VERSION is the C++ ABI contract every native plugin compiles against. Bumps are reserved for breaking changes to the FExternalHandlerFn signature or the registration contract. A plugin declaring minBridgeApi: N refuses to load against a bridge whose version is below N. Inspect the deployed bridge's version with:
project(action="get_status")The response includes bridgeApiVersion when a bridge is deployed.