关于 create-toolset
create-toolset 是面向 Unreal Engine 开发者的工具集编写指南,说明如何通过 ToolsetRegistry 注册可供 AI 调用的函数。内容涵盖设计、文档、C++ 与 Python 实现、错误处理和测试。
主要功能
- 01工具集设计与复用原则
- 02C++ 与 Python 实现指导
- 03工具及类型文档规范
- 04注册与异步调用说明
- 05错误处理和测试要求
适用场景
- 01为现有 Unreal 工具集添加工具
- 02判断功能应归属的工具集
- 03实现并测试新的 AI 可调用函数
MCP 相关
该指南描述通过 ToolsetRegistry 注册工具集,并将 AI 可调用函数经 MCP 服务暴露给客户端。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ epicgames/ unreal-engine-skills-for-claude-code-plugin --skill create-toolset安装后在 Claude Code 中输入/create-toolset确认已加载。
详细介绍
创建工具集
你正在编写或扩展 Unreal Engine 工具集:一组静态、可由 AI 调用的函数,这些函数通过 ToolsetRegistry 注册,并由 MCP 服务提供。目标是扩展 Claude 在编辑器中能够执行的操作。
原则
优秀的工具集应当:
简洁:设计能够在该领域完成实用工作的最简单 API。不要直接照搬 Unreal 现有的 API;它们往往不必要地复杂。一个实用的判断标准是:技术美术人员不看实现,也能理解它吗?
完整:支持 CRUD 对称性。如果可以设置某项内容,就应该能够获取它。如果可以创建,就应该可以删除。如果无法或没有必要进行修改,则只有 getter 也没问题。
可组合:在整个工具集中,为同类操作使用一致的类型。如果 get_graph() 返回 Graph,那么 get_graph_nodes() 就应接受 Graph。各个函数应能自然组合,生成更复杂的结果。
DRY:工具集内部以及不同工具集之间都不要重复实现。ObjectTools 已提供通用的 UObject 属性获取/设置功能,不要重新实现。如果其他地方已有相应功能,就调用它或指出其位置。
编写之前
动手编写代码前,按顺序考虑以下问题。
1. 该功能是否已经存在? 如果编辑器正在运行,通过 MCP 调用 list_toolsets,然后对相关工具集调用 describe_toolset。如果 MCP 不可用,则在代码库中搜索名为 Toolsets 的文件夹,并阅读其中的 C++ 头文件或 Python 工具集文件。如果该功能已经开放使用,就无需再做任何事。告知用户,并指向相应的工具。
2. 用户的请求是否比表面上更通用? 用户经常会提出特定领域的需求,但它实际上属于更广泛的通用模式。在查找领域工具集之前,先判断该功能是否确实属于该领域,还是适用范围更广。例如,“读取蓝图资源元数据”听起来像是属于 BlueprintTools,但元数据适用于所有资源,因此 AssetTools 才是合适的位置。用通用方式解决问题几乎总是优于局限于特定领域的方案。
3. 该领域是否已有工具集? 如果缺少该功能,但已有工具集涵盖同一领域(例如,要向 StaticMeshTools 添加网格查询功能),就扩展现有工具集,而不是创建新的工具集。
4. 新工具集是否需要独立插件? 如果你要创建的新工具集与现有插件密切相关,就将它添加到该插件中。如果它所属的领域足够独立,则可能需要自己的插件。如果不确定,请询问用户。
5. 选择实现语言。 由用户决定,但要帮助他们在了解情况后作出选择。通常优先选择 Python:迭代更快,也更容易修改。评估以下两种方案:
- Python:检查 Python 存根中有哪些 API(
<project_root>/Intermediate/PythonStub/unreal.py;通过搜索检查,不要通读整个文件)。如果该文件不存在,用户需要在 Edit → Project Settings → Plugins → Python 中启用 Developer Mode,然后重启编辑器。任何工具集开发都值得这样做,因此请主动建议。如果所需 API 都已提供,Python 就是合适的选择。如果大部分功能都可用,但有少量缺失,请向用户说明这一缺口;即使需要对引擎做少许调整,Python 总体上仍可能是更好的选择。 - C++:当 Python 覆盖不足或完全没有覆盖时,这是合适的选择。所需的大部分功能根本不在存根中。
总结两种语言各自可用的功能,并让用户在编写任何代码之前作出决定。如果所需 API 在两种语言中都不可用,就不要绕过限制。停止并告知用户,他们可以扩展引擎。变通方案会产生脆弱的工具,并可能在无声无息中失效。
通用约定
以下约定适用于 C++ 和 Python:
- 不要重复实现现有工具。 例如,
ObjectTools已经能够处理通用的 UObject 属性获取和设置。 - 必须提供文档。 每个工具集和每次工具调用都必须有文档。请参阅下文的「文档」部分。
- 使用实际类型。 参数和返回值应使用实际类型(
int32、FVector、TArray<FString>、结构体等)。ToolsetRegistry 会自动处理序列化。在工具调用中转换为 JSON 格式字符串或从中转换回来,是一种不良代码气味。字符串类型应表示真正的字符串,而不是伪装成结构化数据。 - 返回值用于携带数据,而非状态。 正常返回表示成功;引发异常表示失败。不要返回布尔值、错误字符串或结果包装对象来表示错误,而应引发异常。
- 所有工具方法都必须是静态方法。 不得有实例状态。
- 必须编写测试。 每个工具都必须有测试覆盖。请参阅下文的「测试」部分。
文档
每个层级都必须提供优质文档。这不是可有可无的润色。不要写得过于详细;简短、准确的描述胜过长篇大论。如果某句话没有补充代码本身无法体现的含义,就删掉。
工具集类:用 1-3 句话描述工具集的用途及其涵盖的领域。大模型通常只会看到工具集名称和这段描述,而不会加载其中的工具,因此描述必须能独立说明问题。说明其所属领域及工具集支持的操作类型。不要逐一列出工具名称;工具本身会说明用途。
每个工具:清晰描述工具的功能,说明每个参数,并说明返回值。重点说明含义和单位。
对每一行文档都进行以下检查:仅凭签名、名称和类型,有经验的读者能推断出这一点吗?如果可以,就删掉。 大模型默认会写过多文档;请克制。只有在说明代码无法体现的信息时,文档内容才有保留的价值。因此,应删去对签名的复述(类型、默认值、可选性)、对用法和调用链的叙述(如何组合工具,从签名中就能看出)、对显而易见内容的示例,以及对字段可能包含什么的猜测,而应说明字段实际表示什么。
记录代码的行为,而非你这样编写代码的原因。「直接遍历子类,因为资产注册表会遗漏未加载的类型」属于实现理由,而非行为约定。说明工具的功能;删去推理过程。
保留无法推断的信息:单位、范围、不明显的编码方式(例如「以空格分隔的单个字符串」)、空或 null 结果的含义,以及名称未体现的领域含义。
// Over-documented: every line restates the signature or narrates usage.
/**
* Runs a cheat. @param CheatName The cheat name (case-insensitive), pass it to run the cheat.
* @param Args Optional arguments; fill in the slots from the Args hint (e.g. "<float F>" -> "2.0",
* "<float A> <float B>" -> "1 2"). Empty string for no arguments.
*/
// Trimmed: only the non-inferable facts remain.
/**
* Runs a cheat on the local player, as if typed into the console.
* @param CheatName The cheat command name.
* @param Args Arguments as a single space-separated string. Empty for none.
*/
结构体:说明结构体本身(它表示什么以及何时使用),并说明每个字段(其含义及适用时的单位)。UPROPERTY 元数据(例如 ClampMin 和 ClampMax)会自动提取并纳入架构。请使用这些元数据,而不要在文档注释中重复约束。
C++ 特定说明
结构
- 继承自
UToolsetDefinition(来自ToolsetRegistry/ToolsetDefinition.h)。 - 每个
.h/.cpp文件对应一个工具集类。 - 所有可由 AI 调用的工具都必须是带有
UFUNCTION(meta = (AICallable))标记的静态方法。函数的文档注释会成为 AI 可见的工具描述。请清晰撰写。 - 私有辅助方法应为不带
UFUNCTION(meta = (AICallable))的静态方法。省略该宏即可避免将其公开。
/**
* Snapshot of a MyThing's current state, returned by GetThingInfo.
*/
USTRUCT(BlueprintType)
struct FMyThingInfo
{
GENERATED_BODY()
/** How thing-like this thing is. */
UPROPERTY(meta=(ClampMin="0.0", ClampMax="1.0")) float Thinginess;
/** Things that belong to this thing. */
UPROPERTY() TArray<UMyThing*> SubThings;
};
/**
* Manages MyThings in the current level. Covers the full lifecycle of these objects:
* querying by name, reading their state, and performing operations on them.
*/
UCLASS(BlueprintType, MinimalAPI)
class UMyToolset : public UToolsetDefinition
{
GENERATED_BODY()
public:
/**
* Returns all things whose name matches the given pattern.
* @param NamePattern Substring to match against thing names.
* @return Matching things, or an empty array if none are found.
*/
UFUNCTION(meta = (AICallable), Category = "MyToolset")
static TArray<UMyThing*> FindThings(const FString& NamePattern);
/**
* Returns detailed info about the given thing.
* @param Thing The thing to read state from.
* @return The thing's current state.
*/
UFUNCTION(meta = (AICallable), Category = "MyToolset")
static FMyThingInfo GetThingInfo(UMyThing* Thing);
/**
* Performs the primary operation on the given thing.
* @param Thing The thing to perform the operation on.
*/
UFUNCTION(meta = (AICallable), Category = "MyToolset")
static void DoTheThing(UMyThing* Thing);
};
异步工具调用
大多数工具都是同步的:它们在游戏线程上运行并直接返回值。对于截屏或等待编辑器状态变化等长时间运行的操作,请使用异步调用。
异步工具返回的是 UToolCallAsyncResult 的子类,而不是直接返回值。许多结果类型已经存在;对于任意值类型,也可以通过继承 UToolCallAsyncResult 创建新的结果类型。创建新类型之前,请先检查现有类型。
其声明与其他工具相同,只是返回类型为异步结果:
/**
* Renders an image of the given thing.
* @param Thing The thing to capture.
* @return An image of the thing.
*/
UFUNCTION(meta = (AICallable), Category = "MyToolset")
static UToolCallAsyncResultImage* CaptureThingImage(UMyThing* Thing);
与同步工具不同,成功和失败都通过结果对象传达。成功时调用 SetValue(),失败时调用 SetError()。
UToolCallAsyncResultImage* UMyToolset::CaptureThingImage(UMyThing* Thing)
{
UToolCallAsyncResultImage* Result = NewObject<UToolCallAsyncResultImage>();
if (!IsRenderingEnabled())
{
Result->SetError(TEXT("Rendering is not enabled."));
return Result;
}
// Initiate the capture; call Result->SetValue(image) on completion.
return Result;
}
正确的实现方式取决于所操作的系统。编写代码之前,请查看现有工具集中的实际示例。
注册
工具集可以随时动态注册和取消注册。一种常见做法是在模块启动和关闭时执行:
class FMyToolsetModule : public IModuleInterface
{
void StartupModule()
{
UToolsetRegistry::RegisterToolsetClass(UMyToolset::StaticClass());
}
void ShutdownModule()
{
UToolsetRegistry::UnregisterToolsetClass(UMyToolset::StaticClass());
}
};
请查看同一插件中附近的工具集,了解其采用的模式。
自定义类型到 JSON 的转换器
ToolsetRegistry 会自动将所有 Unreal 类型与 JSON 相互转换。通常无需考虑序列化。JSON 转换器可让你进一步控制特定类型的表示方式,通常用于生成比默认模式更简洁或更适合 AI 的模式。
这是一项高级功能,仅应在明确需要时使用,而不是仅仅因为它可能有用就使用。
一些类型已内置自定义转换器。例如:
FToolsetColorConverter:将FColor和FLinearColor统一为一种颜色表示,这样 AI 就无需区分字节和浮点数。FToolsetReferenceConverter:将所有 UObject* 和 UClass* 属性转换为带类型的软路径对象,而不是通常会得到的原始字符串。FToolsetTransformConverter:将FTransform的位置、旋转和缩放作为可选字段公开,使用起来比每次都要求提供全部三个分量方便得多。
如果需要为尚未覆盖的类型自定义序列化,请继承 FToolsetJsonConverter(位于 ToolsetRegistry/ToolsetJsonConverter.h),并将其与工具集一同注册。编写自己的转换器之前,请先阅读 ToolsetRegistry 插件源代码中的一个现有转换器。该接口涉及多个部分,现有实现是最佳参考。
错误处理
如果工具无法完成工作(输入无效、资源缺失、未满足前置条件),请引发脚本错误并立即返回 null 或默认值:
TArray<UMyThing*> UMyToolset::FindThings(const FString& NamePattern)
{
if (NamePattern.IsEmpty())
{
UKismetSystemLibrary::RaiseScriptError(
EScriptExceptionType::Error,
TEXT("NamePattern must not be empty."));
return {};
}
}
测试
运行测试之前,请使用 LiveCodingToolset.CompileLiveCoding 编译更改。该命令会一直阻塞到编译完成,并显示 MSVC 诊断信息。继续之前,请修复所有编译错误。
每个工具都需要覆盖成功路径和每一种错误路径的测试。至少编写一个测试,确认工具能按说明工作,并针对每种会引发错误的情况分别编写测试。使用 BEGIN_DEFINE_SPEC / END_DEFINE_SPEC 模式。可参考 Plugins/Experimental/Toolsets 中的现有测试。将测试放在工具集附近,并遵循同一插件中的约定:
BEGIN_DEFINE_SPEC(
FMyToolsetSpec,
"AI.MyToolset",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter)
END_DEFINE_SPEC(FMyToolsetSpec)
void FMyToolsetSpec::Define()
{
Describe("FindThings", [this]()
{
It("returns matching things", [this]()
{
TArray<UMyThing*> Results = UMyToolset::FindThings(TEXT("expected_name"));
TestFalse(TEXT("Result is not empty"), Results.IsEmpty());
});
It("returns an empty array when no things match", [this]()
{
TArray<UMyThing*> Results = UMyToolset::FindThings(TEXT("nonexistent"));
TestTrue(TEXT("Result is empty"), Results.IsEmpty());
});
It("raises when the name pattern is empty", [this]()
{
AddExpectedError(TEXT("NamePattern must not be empty"));
UMyToolset::FindThings(TEXT(""));
});
});
}
Python 特定说明
结构
- 使用
@unreal.uclass()装饰类,并继承unreal.ToolsetDefinition。 - 每个
.py文件中只定义一个工具集类。 - 所有可由 AI 调用的工具都使用
@toolset_registry.tool_call,后接@staticmethod。文档字符串会成为 AI 可见的工具描述。 - 私有辅助函数使用
@staticmethod,名称以_开头,并放在类的末尾。 - 每个参数和返回值都必须添加类型注解。 架构生成完全依赖这些注解。
- 使用标准 Python 类型注解(
list[str]、dict[str, str]等),而非unreal.Array[str]等 Unreal 类型。@toolset_registry.tool_call装饰器会自动处理转换。
@unreal.uclass()
class MyToolset(unreal.ToolsetDefinition):
"""Manages MyThings in the current level. Covers the full lifecycle of these objects:
querying by name, reading their state, and performing operations on them."""
@toolset_registry.tool_call
@staticmethod
def find_things(name_pattern: str) -> list[MyThing]:
"""Returns all things whose name matches the given pattern.
Args:
name_pattern: Substring to match against thing names.
Returns:
Matching things, or an empty list if none are found.
"""
...
@toolset_registry.tool_call
@staticmethod
def get_thing_info(thing: MyThing) -> MyThingInfo:
"""Returns detailed info about the given thing.
Args:
thing: The thing to read state from.
Returns:
The thing's current state.
"""
...
@toolset_registry.tool_call
@staticmethod
def do_the_thing(thing: MyThing) -> None:
"""Performs the primary operation on the given thing.
Args:
thing: The thing to perform the operation on.
"""
...
注册
注册不会自动进行。将工具集类添加到插件的注册列表中,并在初始化时调用 unreal.ToolsetRegistry.register_toolset_class,通常是在同一插件中与其他工具集并列的 __init__.py 或 init_unreal.py 文件中:
def register_toolsets():
unreal.ToolsetRegistry.register_toolset_class(MyToolset)
def unregister_toolsets():
unreal.ToolsetRegistry.unregister_toolset_class(MyToolset)
在你正在开发的插件中找到对应的函数,并在那里添加你的工具集。
错误处理
工具无法完成工作时,直接抛出异常:
@toolset_registry.tool_call
@staticmethod
def find_things(name_pattern: str) -> list[MyThing]:
"""Returns things whose name matches the given pattern."""
if not name_pattern:
raise ValueError("name_pattern must not be empty.")
测试
每个工具都需要覆盖成功路径和所有错误路径的测试。至少编写一个测试,确认工具按说明正常工作,并针对每种会引发异常的情况分别编写测试。可参考 Plugins/Experimental/Toolsets 中现有的测试。继承 ToolCallTestCase,并使用 assertToolRaisesRuntimeError 测试错误路径:
class MyToolsetTestCase(ToolCallTestCase):
"""Test MyToolset toolset."""
def test_find_things_returns_matches(self):
"""Returns matching things when they exist."""
results = MyToolset.find_things("expected_name")
self.assertGreater(len(results), 0)
def test_find_things_returns_empty_for_no_match(self):
"""Returns an empty list when no things match the pattern."""
results = MyToolset.find_things("nonexistent")
self.assertEqual(results, [])
def test_find_things_raises_on_empty_pattern(self):
"""Raises when name_pattern is empty."""
with self.assertToolRaisesRuntimeError():
MyToolset.find_things("")
编辑后重新运行测试前,请重新加载插件的包,否则编辑器不会获取更改。启用 Remote Execution:进入 Edit → Project Settings → Plugins → Python → Enable Remote Execution,然后运行:
python Engine/Plugins/Experimental/ToolsetRegistry/Content/Python/toolset_registry/tests/reload_remote.py your_plugin
测试你的工作
测试可以验证工具集是否正常工作,并在情况发生变化时发现回归问题。采用紧凑的迭代流程:编写代码、按需编译、运行测试、查看失败结果、修复问题,然后重复上述步骤,直到所有测试通过。
实时编辑器(首选)
使用 unreal-mcp 针对正在运行的编辑器实例执行测试,是最快的迭代方式。无需重启即可编译或热重载更改,并能立即获取结果。此流程适用于 C++ 和 Python 测试。
通过 MCP 运行测试:
- 加载
AutomationTestToolset并调用DiscoverTests。在使用其他测试工具前必须先执行此步骤;跳过会导致结果为空。 - 调用
ListTests,并按工具集名称筛选,以确认已发现这些测试。 - 使用完整测试路径调用
RunTests。 - 轮询
GetTestStatus,然后调用GetTestResults查看每项测试的详细错误和警告。
迭代 Python 测试时,重新加载后调用 DiscoverTests 并设置 force_rediscover=true,这样自动化系统才能发现新增或移除的测试。
命令行(未运行编辑器)
在编辑器未运行时,命令行会启动一个无头编辑器实例,运行指定的测试,然后退出。启动过程会增加耗时(约 30 秒),但无需运行中的编辑器,因此适用于 CI 或编辑器不可用的情况。
使用 UnrealEditor-Cmd 和 -ExecCmds 直接调用自动化测试系统:
UnrealEditor-Cmd.exe <Project>.uproject -ExecCmds="Automation RunTests AI.MyToolset;quit" -Unattended -NullRHI
将 AI.MyToolset 替换为与你的工具集规格名称匹配的测试筛选器。查看同一插件中现有测试的运行方式,确认项目所需的标志和筛选器前缀。
审查你的工作
编写完代码并通过测试后,在宣布完成之前,先退后一步,通读整体成果。初稿代码往往存在一些逐个工具检查时不易发现的问题:
- 重复的样板代码。 多个工具中反复出现的类似模式,其实可以共用一个辅助函数。
- 测试覆盖不完整。 后期添加的抛出异常路径从未经过测试。成功场景也可能只检查了一种情况。
- 功能重复。 某个工具实现的功能,工具集中的另一个工具或其他工具集其实已经具备。
- API 不一致。 参数名称或类型与工具集其他地方使用的约定不符。
- 文档缺漏。 参数描述含糊不清,或类文档字符串信息不足,无法独立发挥作用。
- 文档只是重复代码内容。 这是更常见的问题:某一行只是重复了类型、默认值或调用顺序;示例展示了显而易见的内容;或解释代码为何采用当前结构。重新阅读每条注释,删掉读者能从签名中推断出来的内容。
交付前,修复发现的所有问题。现在认真进行一轮复查,总比之后收到错误报告后再处理更省时。