-
-
Notifications
You must be signed in to change notification settings - Fork 79
Sibling Plugin Guide
How to extend Monolith with a separate UE plugin that bridges a third-party integration (paid marketplace plugin, license-isolated code, project-specific subsystem) into Monolith's MCP action registry — without merging into the core Monolith repo.
Audience: plugin authors who want their tool callable as <namespace>_query({ action, params }) over MCP, but whose plugin can't or shouldn't ship inside Monolith itself.
In-tree modules (the ones living in Plugins/Monolith/Source/Monolith*/) ship inside every public Monolith release zip. That works for code Monolith owns and licenses permissively, but breaks down for:
| Reason | Example |
|---|---|
| Paid marketplace dependency — you can't redistribute someone else's plugin | A bridge that calls into an Epic Marketplace / Fab plugin (Inventory systems, networking kits, AI middleware, etc.) |
| License isolation — your code is GPL/MIT/proprietary and the host project is the other | A library wrapper where you need to keep the LICENSE files and source tree separate |
| Project-specific — useful for one game, noise for everyone else | A bridge into your game's bespoke save system, gameplay subsystem, or build pipeline |
| Independent versioning — your plugin ships on its own cadence | A subsystem that updates faster (or slower) than Monolith's release cycle |
| Optional binary footprint — users who don't have the dep shouldn't pay for the DLL | A bridge whose binaries would be dead weight in 90% of installs |
The sibling-plugin pattern lets you do all of this while still appearing as a first-class MCP tool to AI clients (Claude Code, Cursor, Cline) — same registry, same dispatch path, same discovery output.
┌─────────────────────────────────────────────────────────┐
│ Plugins/Monolith/ │
│ Source/MonolithCore/ │
│ • FMonolithToolRegistry (MONOLITHCORE_API) │
│ • HTTP server, MCP dispatch │
│ Source/Monolith<Domain>/ │
│ • In-tree modules: register("namespace", fn) │
└─────────────────────────────────────────────────────────┘
▲
│ resolves the same
│ FMonolithToolRegistry::Get() singleton
│
┌─────────────────────────────────────────────────────────┐
│ Plugins/MyPluginBridge/ ← YOUR sibling plugin │
│ Source/MyPluginBridge/ │
│ • StartupModule → register("myns", fn) │
│ • Build.cs detects third-party dep + #if gates │
└─────────────────────────────────────────────────────────┘
FMonolithToolRegistry is a process-wide singleton exported from Monolith's MONOLITHCORE_API. Any module — in-tree or sibling — that lists MonolithCore as a dependency can call FMonolithToolRegistry::Get().RegisterHandler("namespace", "action", &MyHandler) at startup and the action is immediately callable over MCP.
The sibling plugin's namespace shows up under monolith_discover() alongside in-tree namespaces. AI clients see no difference.
Before you start:
-
Monolith is installed and loading cleanly in your project. Sibling plugins depend on
Monolithas a hard plugin dependency. - Your third-party dependency (if any) is installed and loadable in the same project.
-
You've claimed a unique namespace that doesn't collide with Monolith's existing namespaces. Run
monolith_discover()first — current namespaces are listed inPlugins/Monolith/Docs/SPEC_CORE.md§Architecture (e.g.material,mesh,niagara,inventory, etc.). Pick something specific to your integration.
Sit beside Monolith, not inside it:
<YourProject>/
Plugins/
Monolith/ ← unmodified
Source/MonolithCore/...
Monolith.uplugin
MyPluginBridge/ ← your sibling plugin
Source/
MyPluginBridge/
MyPluginBridge.Build.cs
Private/
MyPluginBridgeModule.cpp
MyPluginBridgeActions.cpp
Public/
MyPluginBridgeModule.h
MyPluginBridgeActions.h
MyPluginBridge.uplugin
Docs/
SPEC_CORE.md
specs/
SPEC_<Subsystem>.md
ROADMAP.md
TESTING.md
Same docs convention as in-tree Monolith modules (per-module SPEC split for big plugins, single SPEC for small focused ones). See feedback_local_plugin_docs_convention.md if you're documenting your bridge.
Minimum viable shape:
{
"FileVersion": 3,
"Version": 1,
"VersionName": "0.1.0",
"FriendlyName": "MyPluginBridge",
"Description": "Bridges <ThirdPartyPlugin> into the Monolith MCP registry.",
"Category": "Editor",
"CreatedBy": "<you>",
"EnabledByDefault": true,
"Installed": false,
"Plugins": [
{ "Name": "Monolith", "Enabled": true },
{ "Name": "<ThirdPartyPlugin>", "Enabled": true, "Optional": true }
],
"Modules": [
{
"Name": "MyPluginBridge",
"Type": "Editor",
"LoadingPhase": "Default"
}
]
}Notes:
-
Monolithis a hard plugin dep — your bridge is meaningless without it. -
The third-party dep is
Optional: trueso UE doesn't refuse to load your bridge when the user removes the third-party plugin. Your bridge degrades gracefully (see Build.cs section). -
LoadingPhase: Default— runs afterPostEngineInit(whenMonolithCorefinishes initializing the registry). If your bridge needs to register into a subsystem that initializes later, usePostDefault.
The canonical pattern is detect-then-gate: probe for the third-party plugin at compile time, set a WITH_<DEP>=0/1 PublicDefinition, then gate every dep-using #include and call site behind that define. And honour MONOLITH_RELEASE_BUILD=1 so release builds force the dep off regardless of on-disk state.
using UnrealBuildTool;
using System.IO;
public class MyPluginBridge : ModuleRules
{
public MyPluginBridge(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// Always-on dependencies (Monolith + UE basics)
PrivateDependencyModuleNames.AddRange(new string[]
{
"Core", "CoreUObject", "Engine",
"MonolithCore"
});
// Release builds: set MONOLITH_RELEASE_BUILD=1 to force optional deps off.
// Public Monolith release zips set this so accidentally-bundled siblings
// can't end up with a hard import on a paid third-party DLL the user
// doesn't have installed.
bool bReleaseBuild =
System.Environment.GetEnvironmentVariable("MONOLITH_RELEASE_BUILD") == "1";
bool bHasThirdParty = false;
if (!bReleaseBuild)
{
// Check three locations: project Plugins/, Engine Plugins/Marketplace/, Engine Plugins/
string ProjectPluginsDir = Path.Combine(
Target.ProjectFile.Directory.FullName, "Plugins");
if (Directory.Exists(ProjectPluginsDir))
{
bHasThirdParty = Directory.GetDirectories(
ProjectPluginsDir, "ThirdParty*",
SearchOption.TopDirectoryOnly).Length > 0;
}
if (!bHasThirdParty)
{
string EngineDir = Path.GetFullPath(Target.RelativeEnginePath);
string MarketplaceDir = Path.Combine(EngineDir, "Plugins", "Marketplace");
if (Directory.Exists(MarketplaceDir))
{
bHasThirdParty = Directory.GetDirectories(
MarketplaceDir, "ThirdParty*",
SearchOption.TopDirectoryOnly).Length > 0;
}
}
}
if (bHasThirdParty)
{
PrivateDependencyModuleNames.Add("ThirdPartyModule");
PublicDefinitions.Add("WITH_THIRDPARTY=1");
}
else
{
PublicDefinitions.Add("WITH_THIRDPARTY=0");
}
}
}Why three locations? Marketplace / Fab installs land in different folders across UE versions and install methods. The wildcard glob ("ThirdParty*") survives obfuscated marketplace folder names.
Why MONOLITH_RELEASE_BUILD? Without it, anyone building a public Monolith release on a machine that happens to have your third-party plugin installed would produce binaries with hard imports on UnrealEditor-ThirdPartyModule.dll. Users who download that release and don't have the third-party plugin will hit LoadLibrary failures with GetLastError=126 at module load. The kill-switch is a hard guard against accidental leakage.
This same pattern is used by every in-tree Monolith optional module: MonolithBABridge (Blueprint Assist), MonolithComboGraph, MonolithLogicDriver, MonolithGAS (Gameplay Behaviors), MonolithAudio (MetaSound), MonolithAI (Mass Entity / Zone Graph), MonolithUI (CommonUI), MonolithMesh (GeometryScripting). Reference any of those Build.cs files for variants.
// MyPluginBridgeModule.cpp
#include "MyPluginBridgeModule.h"
#include "MyPluginBridgeActions.h"
#include "MonolithToolRegistry.h"
#include "Modules/ModuleManager.h"
#define LOCTEXT_NAMESPACE "FMyPluginBridgeModule"
void FMyPluginBridgeModule::StartupModule()
{
#if WITH_THIRDPARTY
FMonolithToolRegistry& Registry = FMonolithToolRegistry::Get();
FMyPluginBridgeActions::RegisterActions(Registry);
UE_LOG(LogTemp, Log,
TEXT("MyPluginBridge — registered N actions in 'myns' namespace"));
#else
UE_LOG(LogTemp, Log,
TEXT("MyPluginBridge — third-party dep not detected, bridge inactive"));
#endif
}
void FMyPluginBridgeModule::ShutdownModule()
{
// Always unregister the namespace, even if we never registered handlers.
// Monolith's registry treats unregister-of-empty as a no-op.
if (FMonolithToolRegistry::IsAvailable())
{
FMonolithToolRegistry::Get().UnregisterNamespace(TEXT("myns"));
}
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FMyPluginBridgeModule, MyPluginBridge)Notes:
-
Always-register the shutdown sweep even when
WITH_THIRDPARTY=0. Costs nothing, prevents stale entries surviving hot-reload edge cases. -
IsAvailable()guard on shutdown — Monolith might be tearing down before you. Defensive. - Don't fail StartupModule when the dep is missing. Log, return cleanly. Your bridge should be invisible (zero registered actions) rather than crashing the editor.
Match the Monolith action handler signature so dispatch finds you:
// MyPluginBridgeActions.h
#pragma once
#include "CoreMinimal.h"
#include "MonolithActionResult.h"
class FMonolithToolRegistry;
class FMyPluginBridgeActions
{
public:
static void RegisterActions(FMonolithToolRegistry& Registry);
#if WITH_THIRDPARTY
static FMonolithActionResult HandleDoSomething(
const TSharedPtr<FJsonObject>& Params);
#endif
};// MyPluginBridgeActions.cpp
#include "MyPluginBridgeActions.h"
#include "MonolithToolRegistry.h"
#if WITH_THIRDPARTY
#include "ThirdParty/ThirdPartyAPI.h"
#endif
void FMyPluginBridgeActions::RegisterActions(FMonolithToolRegistry& Registry)
{
#if WITH_THIRDPARTY
Registry.RegisterHandler(
TEXT("myns"), // namespace claim
TEXT("do_something"), // action name
&FMyPluginBridgeActions::HandleDoSomething);
// ... more handlers ...
#endif
}
#if WITH_THIRDPARTY
FMonolithActionResult FMyPluginBridgeActions::HandleDoSomething(
const TSharedPtr<FJsonObject>& Params)
{
// Validate params...
FString TargetName;
if (!Params->TryGetStringField(TEXT("target"), TargetName))
{
return FMonolithActionResult::Error(
-32602, TEXT("Missing required param: target"));
}
// Call into third-party API...
bool bOk = ThirdPartyAPI::Do(TargetName);
// Return result as JSON...
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
Result->SetBoolField(TEXT("success"), bOk);
Result->SetStringField(TEXT("target"), TargetName);
return FMonolithActionResult::Ok(Result);
}
#endifConventions:
-
Namespace = lowercase, no spaces, project-specific. Goes to
<myns>_queryMCP tool. -
Action name = snake_case. Verbs preferred (
get_x,set_y,create_z). -
Handler signature is fixed:
static FMonolithActionResult Handle*(const TSharedPtr<FJsonObject>& Params). -
Errors return JSON-RPC error codes.
-32601(method not found),-32602(invalid params),-32603(internal error). UseFMonolithActionResult::Error(code, message). -
Successful results return a
TSharedRef<FJsonObject>with whatever fields make sense for the action. Wrap inFMonolithActionResult::Ok(payload).
If you have many handler classes, follow the in-tree Monolith convention: one class per logical subsystem (FMyPluginInspectionActions, FMyPluginCRUDActions, etc.), each with its own RegisterActions entry point that the module entry calls in sequence.
Two distribution scenarios:
-
Independent
.upluginversioning — your bridge has its own VersionName, decoupled from Monolith's. -
Dependency declaration —
Plugins[]listsMonolithas a hard dep. Users install both. -
CI builds on multiple UE versions if you want to support more than one engine release. Honour the same conditional patterns Monolith uses (
#if ENGINE_MINOR_VERSION >= ...). - Don't redistribute the third-party plugin. Tell users to install it themselves; your Build.cs detects it.
- Sibling plugin lives outside the Monolith repo entirely (separate VCS branch, separate folder). Public Monolith release zip never sees it.
-
Defence in depth:
- Public Monolith release script (
Plugins/Monolith/Scripts/make_release.ps1) gates against accidental sibling inclusion viagit ls-files(only files tracked by Monolith's own VCS make it into the zip). -
MONOLITH_RELEASE_BUILD=1env-var forces all optional deps off so even an accidentally-bundled sibling can't carry a hard DLL import. - Public Monolith docs scrub references to private siblings before push.
- Public Monolith release script (
- Per-module specs in your sibling stay private. Public docs may acknowledge the existence of the integration if it's not embarrassing, but the action roster + implementation specs are yours.
| Plugin | Status | Bridges | Namespace | Notes |
|---|---|---|---|---|
inventory bridge |
private repo | Paid Fab inventory plugin | inventory |
~158 actions. Lives outside the Monolith repo at sibling-plugin level. Source + per-module specs private. |
steam bridge |
private repo | Steam Integration Kit (paid Fab plugin) | steam |
~28 actions across 7 subsystems (Achievement, Cloud, Infra, Leaderboard, Overlay, Stat, UserAuth). Composed with a leaderboard-extension sibling for full-fidelity upload/download (UHT + SIK USTRUCT workaround). Source + per-module specs private. |
The in-tree optional modules (MonolithBABridge, MonolithComboGraph, MonolithLogicDriver, MonolithGAS, MonolithAudio, MonolithAI, MonolithUI, MonolithMesh) all use the same conditional-compilation pattern described in the Build.cs section above — those are the canonical reference implementations. Crack any of them open for variants of the detection probe and RegisterActions shape.
-
Compile both with and without the third-party dep installed.
- With dep present:
WITH_THIRDPARTY=1, all your actions register, full functionality. - With dep absent:
WITH_THIRDPARTY=0, your module compiles clean, no actions register, log line states "bridge inactive".
- With dep present:
-
Compile with
MONOLITH_RELEASE_BUILD=1set. Should behave identically to "dep absent" — no hard DLL import, zero registered actions. Verify withdumpbin /imports Binaries/Win64/UnrealEditor-MyPluginBridge.dll(Windows) orotool -L(macOS). -
monolith_discover()should list your namespace in the response whenWITH_THIRDPARTY=1, and not list it when=0. -
Round-trip an MCP call:
curl -sS -X POST http://localhost:9316/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call", "params":{"name":"myns_query", "arguments":{"action":"do_something", "params":{"target":"foo"}}}}' - Hot-reload survival. Edit a handler, recompile via Live Coding, re-run the MCP call. The handler should execute the new code without an editor restart. (Live Coding patches are in-memory only; for header changes, full editor restart + UBT.)
| Pitfall | Symptom | Fix |
|---|---|---|
Forgot MONOLITH_RELEASE_BUILD honor |
Released DLL hard-imports a third-party DLL the user doesn't have → GetLastError=126 at module load |
Add the env-var check before Directory.Exists (see Build.cs section) |
| Namespace collision | Your handlers don't fire; Monolith's existing handlers fire instead | Run monolith_discover() before claiming a namespace; pick something specific |
| Module loads before MonolithCore |
FMonolithToolRegistry::Get() returns null at startup |
LoadingPhase: Default (not PreEngineInit / PostConfigInit) |
Forgot IsAvailable() guard on shutdown |
Editor exit crash on plugin unload during teardown | Wrap registry calls in if (FMonolithToolRegistry::IsAvailable()) in ShutdownModule
|
Registered actions outside #if WITH_THIRDPARTY |
Actions register but every call returns -32601 because handler bodies are stubbed out | Move both RegisterHandler calls AND the handler bodies inside the #if
|
| Handler doesn't return JSON-RPC error on missing param | AI client gets cryptic 500 instead of actionable error message | Validate params at the top, return FMonolithActionResult::Error(-32602, ...)
|
- Tool Reference — Monolith plugin overview, registered namespaces, action counts
-
Source/MonolithCore/Public/MonolithToolRegistry.h— registry API surface -
Source/MonolithBABridge/MonolithBABridge.Build.cs— canonical Build.cs pattern -
Scripts/make_release.ps1— public release pipeline