mcpField
C
create-toolset

create-toolset

github.com/epicgames开发1.7k68 次安装5 天前更新
代码仓库

关于 create-toolset

create-toolset 是面向 Unreal Engine 开发者的工具集编写指南,说明如何通过 ToolsetRegistry 注册可供 AI 调用的函数。内容涵盖设计、文档、C++ 与 Python 实现、错误处理和测试。

主要功能

  1. 01工具集设计与复用原则
  2. 02C++ 与 Python 实现指导
  3. 03工具及类型文档规范
  4. 04注册与异步调用说明
  5. 05错误处理和测试要求

适用场景

  1. 01为现有 Unreal 工具集添加工具
  2. 02判断功能应归属的工具集
  3. 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 运行测试:

  1. 加载 AutomationTestToolset 并调用 DiscoverTests。在使用其他测试工具前必须先执行此步骤;跳过会导致结果为空。
  2. 调用 ListTests,并按工具集名称筛选,以确认已发现这些测试。
  3. 使用完整测试路径调用 RunTests。
  4. 轮询 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 不一致。 参数名称或类型与工具集其他地方使用的约定不符。
  • 文档缺漏。 参数描述含糊不清,或类文档字符串信息不足,无法独立发挥作用。
  • 文档只是重复代码内容。 这是更常见的问题:某一行只是重复了类型、默认值或调用顺序;示例展示了显而易见的内容;或解释代码为何采用当前结构。重新阅读每条注释,删掉读者能从签名中推断出来的内容。

交付前,修复发现的所有问题。现在认真进行一轮复查,总比之后收到错误报告后再处理更省时。

评论

支持 Markdown