Authoring a plugin
Authoring a plugin documentation.
Everything you need to build and publish a plugin. If you only want to use a plugin somebody else wrote, see Using plugins instead.
Two capabilities have their own pages: Native C++ modules (shipping engine APIs the bridge doesn't expose) and Guards (gating every bridge call).
Quick scaffolder
ue-mcp plugin create my-thing
cd my-thing
npm install
npm run build
npm run check # validate the manifest + task wiringThe scaffold is a superset: it ships every way a plugin can extend ue-mcp, wired and working, so you never have to discover a capability before using it. Keep the shapes you want and delete the rest.
| Shape | What it scaffolds |
|---|---|
inject | An action added onto a built-in category, e.g. project(action="[prefix]_hello"). |
provides | A brand-new top-level category the plugin owns (actions unprefixed), e.g. [prefix](action="greet"). |
flows | A chained, one-call orchestration ([prefix]_demo). |
nativeModule | A compile-ready C++ handler skeleton under ue/Plugins/[UePlugin]/, dormant by default. |
The native C++ module is the one shape that is scaffolded but not active: declaring nativeModule: makes ue-mcp plugin install deploy the module and force a UE rebuild, so the manifest block ships commented out. The C++ source still lands on disk - uncomment the block in ue-mcp.plugin.yml and reinstall to activate it. No separate flag, no compile cost until you opt in. See Native C++ modules.
The scaffold also stamps package.json, tsconfig.json, a src/index.ts entry, knowledge/, a scripts/check.mjs validator, LICENSE, and .gitignore.
Package layout
my-plugin/
package.json
tsconfig.json
ue-mcp.plugin.yml # author declaration: actionPrefix, inject, provides, tasks, flows, nativeModule
src/ # author writes TypeScript here
index.ts # package entry (tasks load by class_path, but `main` points here)
tasks/
MyAction.ts # one UeMcpTask subclass per file, default export
shared/ # optional cross-task helpers (never referenced from the declaration)
dist/ # tsc output - what actually ships and loads
tasks/
MyAction.js
ue/ # dormant native C++ module (only deployed when nativeModule: is uncommented)
Plugins/
MyPlugin/
scripts/
check.mjs # pre-publish validator: manifest parses, task refs resolve
knowledge/
gameplay.md # one markdown file per target category
skills/
import/
SKILL.md # Claude Code skill, installed as .claude/skills/<prefix>-import
LICENSE
README.mdConventions:
- One task class per file, default export, extending
UeMcpTaskfromue-mcp/task. class_pathin the declaration is resolved against the plugin'sdist/(the loader triesdist/[path].jsthendist/tasks/[path].js).src/shared/holds helpers; never reference it from the declaration.- Compile to
dist/withtscso users need no TypeScript toolchain.
package.json
{
"name": "my-plugin",
"version": "0.1.0",
"description": "My custom actions for ue-mcp",
"type": "module",
"main": "dist/index.js",
"files": ["dist", "ue-mcp.plugin.yml", "knowledge", "README.md"],
"keywords": ["unreal-engine"],
"devDependencies": {
"ue-mcp": "^1.0.65",
"typescript": "^5.7.0"
},
"scripts": {
"build": "tsc"
}
}UeMcpTask (and the types you import alongside it) come from ue-mcp/task - a thin, server-free entry point. The running server answers every ue-mcp and ue-mcp/* import a plugin makes with its own package, so your task extends the same base class the server uses and the user needs no copy of ue-mcp in node_modules. The dev dependency is only there to type-check your build. Do not declare a peerDependencies range on ue-mcp: npm admits a prerelease only when a range names the same major.minor.patch, so >=1.0.65 rejects 1.4.0-beta.1 and the install fails with ERESOLVE. minServerVersion in ue-mcp.plugin.yml is the version gate. You never depend on the underlying flow runtime directly - it stays an implementation detail behind ue-mcp/task.
ue-mcp.plugin.yml
This is the only file ue-mcp reads from your package. Authored once; never edited by users.
actionPrefix: mypfx # mandatory, lowercase, must match /^[a-z][a-z0-9_]*$/
minServerVersion: 1.0.15 # optional - the server enforces this at install and load
uePluginDependency: SomePlugin # optional - .uplugin filename to check in .uproject
inject:
gameplay:
inspect_something: # -> gameplay(action="mypfx_inspect_something")
task: mypfx.inspect_something
description: "Inspect some game state during a PIE session."
schema:
actorLabel: { type: string, required: true }
includeComponents: { type: boolean }
tasks:
mypfx.inspect_something:
class_path: tasks/InspectSomething
description: "Inspect game state for a given actor"The key under each category is the bare action name. The loader prepends your actionPrefix to compute the injected name: mypfx + inspect_something -> mypfx_inspect_something. The user always sees the prefixed form.
Source control provider plugins
The core source_control category is provider-neutral. Every action in it (get_provider, connect, get_status, history, list_changes, checkout, add, mark_for_delete, revert, sync, diff, submit, set_checkout_mode) goes through Unreal's ISourceControlModule and ISourceControlProvider, whichever provider the editor has configured, and is gated on what that provider reports it can do (get_provider returns it as operations). Core never checks a provider's name.
Anything only one provider has belongs in that provider's plugin: shelves, streams and changelist edits for Perforce, branches and stashes for Git, and so on. Such a plugin injects into source_control under its own actionPrefix, so its actions sit beside the core ones without colliding: p4_* for Perforce, git_* for Git, dv_* for Diversion.
actionPrefix: p4
inject:
source_control:
shelve: # -> source_control(action="p4_shelve")
task: p4.shelve
description: "Shelve the files of a pending changelist."
schema:
changelist: { type: string, required: true }Do not re-implement a core action under a prefix. A plugin action should call the provider for what core cannot express, and leave checkout, add, delete, revert, sync and submit to source_control. Checkout on write is core's too: see Guards.
knowledge: and flows: are optional - omit them when you have nothing to attach. A plugin can ship a single action and nothing else.
Param schemas under schema: accept these types: string, number, boolean, object, array. Every param is optional at the top level of the host category tool's schema, because one schema serves all of that category's actions. required: true holds on the action itself: describe_action and the action's signature report it as required, and a call to that action without it is refused before your task runs. Never name a parameter action, timeoutMs, select, omit, editor or toEditor: the dispatcher consumes those before any handler runs, so such a parameter could never arrive, and the server drops the action that declares one at load with the reason in project(get_status). The bridge refuses the same names in a C++ contract. A native C++ handler declares its parameters in C++ instead, with required names, integers, enums, ranges and nested fields, and every call is checked against them; see Typed contracts.
type is optional. Omit it for a param that genuinely has no single type, and the param accepts any JSON value:
value: { description: "Value to write (number, bool, string, or enum name)" }Give a list instead when you know the alternatives, and the param compiles to a union of exactly those:
value: { type: [number, boolean, string] }Providing new categories (provides:)
When the plugin's actions don't belong inside any built-in category, declare a provides: block. Each entry registers a brand-new top-level MCP category that the plugin owns. Action names are NOT prefixed inside provided categories - the category itself is the namespace.
actionPrefix: terrain # still required (used for any inject: entries)
provides:
terrain_sculpt: # -> terrain_sculpt(action="sample_density", ...)
description: "Terrain sculpting operations"
actions:
sample_density:
task: terrain_sculpt.sample_density
description: "Sample density values along a curve through the terrain"
schema:
start: { type: array, required: true }
end: { type: array, required: true }
steps: { type: number }
tasks:
terrain_sculpt.sample_density:
class_path: tasks/SampleDensityRules:
- Provided category names must match
/^[a-z][a-z0-9_]*$/. - A provided name may not collide with a built-in category. The CLI fails install with the offending name; the runtime loader skips the plugin with a clear status reason.
- Inter-plugin collisions resolve first-writer-wins. If two installed plugins both
provides: terrain_sculpt, the one earlier in yourplugins:array claims the name; the other is skipped with a warning visible inplugins(list). - Knowledge files keyed by a provided category name (
knowledge/terrain_sculpt.md) attach to that category's AI-facing docs the same way they do for injected categories.
A plugin can mix inject: and provides: freely - whatever fits each action best. To back actions with C++ instead of TypeScript tasks, see Native C++ modules.
Writing tasks
// src/tasks/InspectSomething.ts
import { UeMcpTask, type TaskResult } from "ue-mcp/task";
interface Options {
actorLabel: string;
includeComponents?: boolean;
}
export default class InspectSomething extends UeMcpTask<Options> {
get taskName() { return "mypfx.inspect_something"; }
async execute(): Promise<TaskResult> {
const { actorLabel, includeComponents = false } = this.options;
const details = await this.call("level.get_actor_details", {
actorLabel,
includeComponents,
});
if (!details.success) return details;
// ... process the result ...
return { success: true, data: details.data };
}
}Notes:
- Compose existing actions through
this.call('[category].[action]', params). Don't reach into the bridge directly unless you have to - composition gives you free observability and rollback hooks. When you do need a raw bridge method that no task wraps,UeMcpTaskgives you a typedthis.bridge.call(method, params)and a typedthis.ctx(bridge,project) with no casting. - Use the real parameter names of the host task you're calling. Param name drift between TS and C++ is how silent failures start.
- If your task makes multi-step mutations, return a
rollbackrecord so users can opt intorollback_on_failure: trueon the wrapping flow. - Throw, don't return success-with-error-data. The runtime catches throws and turns them into structured failures.
A task can also be a guard that gates every bridge call, not just an action's implementation.
Knowledge files
For each category your plugin injects into, ship a short markdown file under knowledge/. The server attaches it to that category's AI-facing docs at boot, so the agent sees plugin-specific guidance the moment it looks at that category.
Keep it terse - one screenful per category. Concrete examples beat prose. The agent already knows how the category works; the knowledge file is just the delta the plugin introduces.
# Sample Recorder - gameplay actions
4 actions for recording and replaying gameplay input.
Quick start:
1. `gameplay(action="rec_record_arm", sample_hz=60)` - arm the recorder
2. Press Play, play, stop PIE
3. `gameplay(action="rec_replay_arm", recording_id="[id]")` - replay itSkills
A plugin can ship Claude Code skills: one directory per skill under skills/, each holding a SKILL.md. No manifest key is needed. Add skills to files in package.json so it ships.
---
name: import
description: Use when importing a character with MyPlugin. Claude reads this line to decide whether to load the skill.
---
# Importing a character
1. `myplugin(action="import", path="...")`
2. `animation(action="list_skeletal_meshes")` to confirm the skeletonue-mcp plugin install copies every skill directory into the project's .claude/skills/, and ue-mcp plugin uninstall removes them. ue-mcp plugin update, ue-mcp init and ue-mcp deploy refresh them and delete any your new version stopped shipping. Which package installed each skill is recorded in .ue-mcp/skills.json.
Each skill installs under your package's prefix: the package name without ue-mcp-, with a scope folded in. skills/import/ in ue-mcp-meshy installs as .claude/skills/meshy-import/, and in @studio/ue-mcp-foo as studio-foo-import/. The installer sets the name: field to match. Two plugins can therefore ship the same skill name without colliding. A name that already starts with the prefix is not prefixed again.
A skill directory the user created by hand under the same installed name is never overwritten; the install skips it with a warning.
Check your skills before publishing:
ue-mcp plugin check-skills .It prints the name each skill installs as, and fails on a SKILL.md without a description: or with a category(action="x") call that does not exist, counting the actions your plugin adds. A call into another plugin's category cannot be checked and is listed as unverified. The scaffold's npm run check runs it.
Publishing
npm run build # tsc -> dist/
npm publish # public registryUsers install with:
ue-mcp plugin install <your-package-name>Before publishing, confirm your plugin passes every validation rule.
The registry listing
ue-mcp plugin publish [dir] creates or updates your listing on the plugin registry. Log in once with ue-mcp login.
-
Slug. A listing that already names your package keeps its slug.
--slugcreates a new listing's slug; changing an existing one also needs--rename. -
Derived on every publish.
providescomes fromue-mcp.plugin.yml:toolsfor a native module,inject:orprovides:,flowsfor a non-emptyflows:, andskillswhenskills/has a skill.ueVersionscomes fromnativeModule.supportedEngineVersions. -
Curated text. Put it in the manifest so it moves with the code, or pass it as flags (
--tagline,--description,--category).--tags-from-keywordssets the tags frompackage.jsonkeywords. Anything you set neither way keeps the listing's current value.registry: tagline: Voxel terrain authoring for ue-mcp description: Create, stamp and query voxel terrain from the editor. category: landscape tags: [voxel, terrain] -
README.
--readme snapshot(the default) stores your README.md, and the listing shows that copy until the next publish.--readme livestores none, and the listing renders the README from your public repository each time it is viewed. -
--dry-runprints each field the publish would change, the slug first, and posts nothing.
In CI, publish the listing right after the package:
- run: npm publish
- run: npx ue-mcp plugin publish .
env:
UE_MCP_PUBLISH_TOKEN: ${{ secrets.UE_MCP_PUBLISH_TOKEN }}Mint the token on the registry's account page.