Funplay Unity MCP 是一个运行在 Unity Editor 内部的 HTTP MCP server,外部 AI 客户端(Claude Code、Cursor、Codex、VS Code 等)通过它驱动编辑器与 PlayMode。截至 v0.3.0,仓库一共注册了 91 个工具,按 [ToolProvider] 标注分布在 20 个模块下。

在所有这些工具中,使用频率最高、对客户端工作流影响最深的是 execute_code——一个允许 AI 客户端提交 C# 代码片段、由 Editor 在内存中编译并立即执行的工具。本文记录它的设计动因、实现要点以及在工具集合中扮演的角色。

1. 系统位置

整体的请求链路如下:

Unity Editor 主线程 工具方法 Editor/Tools/Builtins MCPExecutionBridge MCPRequestHandler HttpMCPTransport 外部 AI 客户端 Unity Editor 主线程 工具方法 Editor/Tools/Builtins MCPExecutionBridge MCPRequestHandler HttpMCPTransport 外部 AI 客户端 HTTP JSON-RPC 2.0 tools/call 解析参数 调度到主线程 执行 结构化 JSON 响应

execute_codeTools/Builtins/ScriptExecutionFunctions.cs 注册的众多工具方法之一,但它的输入不是预定义参数,而是任意 C# 源代码。

2. 为什么需要一个"通用工具"

工具系统最自然的演进路径是"专用工具优先"——为每个高频操作单独定义一个 RPC:create_primitiveset_transformadd_componentset_material_shader。这条路线的优点是参数明确、单元测试容易、文档化彻底。

但一旦面对组合性需求,专用工具的代价会迅速放大。考虑这样一个任务:

找到场景中所有名字以 Enemy_ 开头的 GameObject,将它们的 BoxCollider 改为 trigger,给每个对象添加一个名为 HitBox 的子物体,最后把整批对象保存为一个 prefab。

这一句需求拆解为专用工具调用至少需要 8 次 round-trip,且每次调用都要在 AI 客户端与 Editor 之间传递 instanceId 等中间状态。任意一步失败都需要回滚或重新拼装。

专用工具路线

find_game_objects

get_component

set_property

create_game_object

set_parent

create_prefab_asset

...

execute_code 路线

一次提交完整逻辑

一次结构化返回

更进一步,有些操作根本无法预先定义工具——例如调用项目内自定义 ScriptableObject 的某个静态方法,或访问第三方插件暴露的 Editor API。任何专用工具集合都不可能覆盖这些场景。

execute_code 的设计目标,就是为这一类"在工具表中找不到对应项"的需求提供统一出口。

3. 执行模型

execute_code 不是把代码写到 .cs 文件再触发 AssetDatabase.Refresh(),而是采用 CodeDom 在内存中编译后通过反射调用:

有 IFunplayCommand 实现

仅有 static Run 方法

接收 C# 源代码

包装:添加 using + namespace

AssetDatabase.Refresh
等待 isCompiling 与 isUpdating 结束

CodeDom 编译为内存 Assembly

发现入口类型

实例化 + 注入 ExecutionContext

反射调用 Run

收集 Created/Modified/Destroyed instanceId

结构化 JSON 返回

此模型相对文件落盘方案有三点关键差异:

  • 不触发 domain reload:编译产物是临时 in-memory assembly,不会引发脚本域重启,编辑器不会出现 1–3 秒的卡顿。
  • 失败隔离:代码错误仅影响当次调用,不会让整个项目陷入编译失败状态。其他工具继续可用。
  • 执行前主动同步EditorReadyHelper.RefreshAndWaitForReady() 在编译前自动 refresh asset 并等待 Unity 完成增量编译,调用方无需额外 request_recompile

4. IFunplayCommand:统一的执行入口

为了让 AI 生成的代码能稳定参与 Undo 堆栈与结构化返回,定义了 IFunplayCommand 接口:

using UnityEngine;
using UnityEditor;
using Funplay.Editor.Tools.Scripting;

public class CommandScript : IFunplayCommand
{
    public void Execute(ExecutionContext ctx)
    {
        var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
        ctx.RegisterObjectCreation(go);
        ctx.Log("Created {0} (id={1})", go.name, go.GetInstanceID());
        ctx.ReturnValue = new { instanceId = go.GetInstanceID() };
    }
}

ExecutionContext 提供三类基础能力:

API 作用
RegisterObjectCreation(go) 等价 Undo.RegisterCreatedObjectUndo,确保用户可 Ctrl+Z 撤销
RegisterObjectModification(obj) 等价 Undo.RecordObject,在修改前调用
Log(format, args) / LogWarning / LogError 写入工具响应中的 messages 字段,不污染 Unity Console

为了向后兼容旧脚本,框架保留了对 public static string Run() 入口的支持——若编译产物中未找到 IFunplayCommand 实现,则反射调用 Run 方法。新代码建议一律使用 IFunplayCommand

5. 实现细节:可见性与反射

CodeDom 编译生成的临时 Assembly 与 Funplay 包本身不在同一个程序集。Unity 包的常规做法是将内部类型标记为 internal,避免外部用户依赖私有 API。这意味着如果将所有 helper 都设为 internal,AI 提交的代码将无法引用 ObjectsHelperTypeResolver 等查询/序列化工具。

实际表现为运行期编译错误:

'Funplay.Editor.Tools.Helpers.ObjectsHelper' is inaccessible due to its protection level

解决方案是建立明确的暴露边界:

类型 可见性 理由
IFunplayCommand / ExecutionContext public 脚本入口契约
ObjectsHelper / TypeResolver public 高频查询能力
ComponentSerializer / GameObjectSerializer public 结构化读写
Response / EditorReadyHelper internal 框架内部,脚本无需直接调用

这样既保证了 AI 脚本的可表达能力,又控制了向外暴露的 API 表面。

6. 何时优先使用专用工具

execute_code 并非取代专用工具,而是作为其互补存在。下列场景仍应优先使用专用工具:

  • 状态读取get_selectionget_prefab_stageget_tagsget_layersget_build_settings 直接返回结构化 JSON,比脚本中手动序列化更稳。
  • 菜单项执行execute_menu_item 接受单字符串参数即可触发任意菜单项,比包裹 IFunplayCommand 调用 EditorApplication.ExecuteMenuItem 更紧凑。
  • 组件字段写入set_component_property/set_component_properties 基于 SerializedProperty API,能正确处理 [SerializeField] private、Object 引用赋值、prefab 上下文,远比脚本中手写反射稳定。

判断标准为:当前任务能否由单次工具调用完成? 若可以,使用专用工具;若需要在调用之间维护状态或拼接逻辑,使用 execute_code

7. 完整示例:单次调用闭环

回到第 2 节中的需求,使用 execute_code 的实现如下:

using System.Linq;
using UnityEngine;
using UnityEditor;
using Funplay.Editor.Tools.Scripting;

public class CommandScript : IFunplayCommand
{
    public void Execute(ExecutionContext ctx)
    {
        var enemies = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None)
            .Where(g => g.name.StartsWith("Enemy_"))
            .ToList();

        foreach (var e in enemies)
        {
            var col = e.GetComponent<BoxCollider>();
            if (col != null)
            {
                ctx.RegisterObjectModification(col);
                col.isTrigger = true;
            }

            var hit = new GameObject("HitBox");
            hit.transform.SetParent(e.transform, false);
            ctx.RegisterObjectCreation(hit);
        }

        var path = "Assets/Generated/Enemies.prefab";
        System.IO.Directory.CreateDirectory("Assets/Generated");
        var root = new GameObject("Enemies");
        ctx.RegisterObjectCreation(root);
        foreach (var e in enemies) e.transform.SetParent(root.transform, true);
        PrefabUtility.SaveAsPrefabAsset(root, path);

        ctx.Log("Processed {0} enemies → {1}", enemies.Count, path);
        ctx.ReturnValue = new { count = enemies.Count, prefab = path };
    }
}

整个任务在一次 RPC 中完成:一个 Undo group、一次结构化返回({ count, prefab })、一次 ctx.Log 记录。客户端无需在多次工具调用之间传递 instanceId,也不会因中间任一步失败而留下半成品。

8. 在工具集中的定位

v0.3.0 的 core 默认 profile 共暴露 29 个工具,其中 20 个为只读 / 状态读取(get_*list_*),9 个为关键写操作。execute_code 是这 9 个写操作中唯一支持任意逻辑的入口。其余写工具(set_component_propertyadd_componentexecute_menu_item 等)覆盖单步高频操作,组合性需求由 execute_code 承接。

这种结构在工具数量与表达能力之间取得了平衡:客户端默认可见的工具数量保持精简(29 个),但实际可表达的操作空间仍然覆盖整个 Unity Editor API。

9. 总结

为 AI 客户端设计工具与为人类设计 SDK 是两种不同的范式。人类开发者偏好职责清晰、参数明确的 API,而 AI 客户端在面对未预期的组合需求时,需要一个可以现场拼接逻辑的"逃生出口"。

execute_code 正是为此而设计:

  • 内存编译,不写文件,不触发 domain reload
  • IFunplayCommand 统一入口,自动接入 Undo 与结构化返回
  • 与专用工具互补,由调用方依据任务粒度自行选择

仓库地址:FunplayAI/funplay-unity-mcp,MIT 协议。如果你正在为 Unity 项目接入 AI 工作流,欢迎提交 issue 或讨论。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐