在 Unity Editor 里跑 HTTP MCP server:主线程边界与请求 marshal 的实现要点

Unity 编辑器进程的一切------GameObject.CreatePrimitiveAssetDatabase.SaveAssetsEditorWindow 操作------都被严格限定在主线程。但要把它暴露成一个能被外部 AI 客户端通过 HTTP 调用的 MCP server,绕不开"HttpListener 必须在后台线程接 socket"的事实。

这两条约束的撞车点是所有"在 Unity 内嵌 HTTP 服务"项目的核心技术难题。本文记录 Funplay Unity MCP 怎么处理这道边界------从后台线程接到的 JSON-RPC 请求,怎么走到主线程上执行 Unity API,再怎么把结果同步回去返回 HTTP 响应。

1. 为什么不能"全跑后台线程"

直观的想法是:让 HttpListener 收到请求后直接在后台线程里解析 + 执行 + 返回。但任何调用 Unity API 的尝试都会立刻撞墙:

复制代码
UnityException: get_isLoaded can only be called from the main thread.

Unity 内部的对象生命周期、序列化、GUI 系统都基于"单线程访问"假设。从其他线程调用 GameObject.FindAssetDatabase.LoadAssetAtPath、甚至读 Selection.activeGameObject,都会抛 UnityException

这条约束没有例外,没有"小心一点就可以"的灰色地带。所有暴露 Unity 操作的工具方法,最终必须在 Editor 主线程的某个 tick 里被调用

2. 为什么 HttpListener 不能"也跑主线程"

反过来想,能不能把 HttpListener 也搬到主线程?答案是不行------HttpListener.GetContext() 是阻塞调用,主线程一旦阻塞,Editor 就卡死。GetContextAsync() 看似异步,但底层仍依赖 OS 完成端口/select,需要让出执行权给 IO 调度器。

主流做法是:

csharp 复制代码
private void StartListener()
{
    _listener = new HttpListener();
    _listener.Prefixes.Add($"http://127.0.0.1:{_port}/");
    _listener.Start();

    // 后台线程循环接 socket
    _listenerThread = new Thread(ListenLoop) { IsBackground = true };
    _listenerThread.Start();
}

private void ListenLoop()
{
    while (_listener.IsListening)
    {
        try
        {
            var ctx = _listener.GetContext();           // 阻塞
            ThreadPool.QueueUserWorkItem(_ => HandleRequest(ctx));
        }
        catch (HttpListenerException) { break; }
    }
}

ListenLoop 跑在后台线程,每个请求再扔进 ThreadPool 处理。但 HandleRequest 里只要碰 Unity API,仍然需要 marshal 回主线程。

3. Marshal 策略对比

把"后台收到的请求"转交到"主线程执行"有 3 种常见路径:
HTTP 请求

后台线程
Marshal 策略
EditorApplication.update

每 tick 轮询 queue
EditorApplication.delayCall

单次主线程回调
SynchronizationContext.Post

Unity 主线程 sync ctx
频率: ~60Hz
延迟: <16ms
必须自管 queue + lock
频率: 一次性触发
延迟: 不确定(下个 main loop)
语义最干净
频率: 异步触发
依赖 Unity 是否设置 SyncContext
Unity 版本差异大

三者的取舍:

策略 优势 劣势
EditorApplication.update 轮询 控制权高、可批处理 必须维护线程安全 queue 与 lock
EditorApplication.delayCall 一行触发,无需 queue 多次触发会"批合并",不适合 1:1 请求
SynchronizationContext 标准 .NET 模式 Unity 不同版本对 Editor 主线程 SyncContext 的设置不一致

Funplay Unity MCP 采用主路径 EditorApplication.update 轮询,配 TaskCompletionSource 桥接 async/await 的组合。这种方案在 Unity 2022.3 / 2023.x / 6000.x 全版本一致工作,且能精确表达"每个 HTTP 请求等一个主线程执行结果"的 1:1 语义。

4. Funplay 的实现:MCPExecutionBridge

简化版的 marshal 桥:

csharp 复制代码
internal sealed class MCPExecutionBridge
{
    private readonly ConcurrentQueue<Action> _mainThreadActions = new();
    private readonly IEditorThreadHelper _threadHelper;

    public MCPExecutionBridge(IEditorThreadHelper threadHelper)
    {
        _threadHelper = threadHelper;
        EditorApplication.update += DrainQueue;
    }

    public Task<TResult> RunOnMainThread<TResult>(Func<TResult> work)
    {
        // 已经在主线程则直接跑
        if (_threadHelper.IsMainThread)
            return Task.FromResult(work());

        var tcs = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
        _mainThreadActions.Enqueue(() =>
        {
            try { tcs.SetResult(work()); }
            catch (Exception e) { tcs.SetException(e); }
        });
        return tcs.Task;
    }

    private void DrainQueue()
    {
        while (_mainThreadActions.TryDequeue(out var action))
        {
            try { action(); }
            catch (Exception e) { Debug.LogException(e); }
        }
    }
}

调用方在后台线程上写的代码是同步的:

csharp 复制代码
// 在后台 HTTP 线程上
var result = await _bridge.RunOnMainThread(() =>
{
    return ExecuteToolMethod(toolName, arguments);  // 在主线程跑
});
await context.Response.WriteAsync(JsonConvert.SerializeObject(result));

TaskCompletionSourceEditorApplication.update 的 tick-based 模型桥接到 async/await。整个 marshal 对调用方是透明的------后台 HTTP 线程 await 一个 Task,主线程完成工作后 set result,await 在原线程上恢复。

ConcurrentQueue 保证多线程安全入队;DrainQueueEditorApplication.update 每帧调用,一次性把所有积压请求执行完。在 60Hz 的 update 频率下,请求平均延迟 < 16ms,对 MCP 工具调用是完全可接受的开销。

5. 异步工具方法的协调

部分 MCP 工具本身就是异步的------enter_play_mode 要等 Unity 真正进入 PlayMode,execute_code 要等代码编译完成。这些操作本身不可能在单个 update tick 里同步完成。

Funplay 的处理是允许工具方法返回 Task<object>

csharp 复制代码
[ToolProvider("PlayMode")]
internal static class PlayModeFunctions
{
    public static async Task<object> EnterPlayMode(int timeoutSeconds = 10)
    {
        if (EditorApplication.isPlaying)
            return Response.Success("Already in PlayMode");

        EditorApplication.EnterPlaymode();
        var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
        while (DateTime.UtcNow < deadline)
        {
            if (EditorApplication.isPlaying && !EditorApplication.isPlayingOrWillChangePlaymode)
                return Response.Success("Entered PlayMode");
            await Task.Delay(100);
        }
        return Response.Error("PLAYMODE_TIMEOUT", "Failed to enter PlayMode");
    }
}

Task.Delay 释放主线程,让出执行权给 Unity 主循环;await 之后回到主线程继续轮询 isPlaying。整个过程对 HTTP 客户端是同步------HTTP 响应在 Task 完成后才发回,客户端按 HTTP 请求超时计算。

这种 async 模式让"PlayMode 闭环"、"等编译完成"、"等域重载完成"这类需要时间等待的工具能在标准 MCP 调用接口下落地。

6. 域重载窗口的请求处理

Unity 域重载发生时,托管脚本域整个重启------HttpListener socket 会被 OS 关闭、MCPServerService 实例被销毁、所有 inflight 请求被中止。

Funplay 的处理策略是主动 reject 而非 buffer
Domain Reload MCP Server AI 客户端 Domain Reload MCP Server AI 客户端 触发域重载 重载完成 → 新实例启动 tools/call execute_code 执行中... 连接中断 / 500 reload-interrupted tools/call get_reload_recovery_status { wasInterrupted: true, toolName: execute_code, ... } tools/call execute_code(重试) 正常返回

beforeAssemblyReload 事件触发时主动关闭 HttpListener、写入 SessionState 中断态、向所有 inflight TaskCompletionSource setException:

csharp 复制代码
private void OnBeforeReload()
{
    DomainReloadHandler.RecordInterruption(_currentTool, _currentRequestId, "reload");
    _listener?.Stop();
    _listener?.Close();

    // 给所有 inflight 请求一个明确的失败信号
    foreach (var tcs in _pendingTasks.Values)
    {
        tcs.TrySetException(new OperationCanceledException("Domain reload interrupted"));
    }
}

客户端收到连接中断或 500 错误,next call 可以 get_reload_recovery_status 拿到结构化的中断摘要。这种透明性比"静默卡死"或"重载后偷偷继续旧请求"都更可控。

7. 端口配置变更的安全重启

用户在 Funplay → MCP Server 窗口里修改端口时,会触发 settings changed 事件。响应这个事件不能直接 _listener.Stop() + new HttpListener()------事件回调可能跑在 ThreadPool 线程,主线程 socket 操作必须 marshal:

csharp 复制代码
private void OnSettingsChanged(FunplayMcpSettings newSettings)
{
    if (newSettings.Port == _currentPort) return;
    // 重启走 delayCall 强制 marshal 回主线程,不能直接在事件线程操作 socket
    EditorApplication.delayCall += ScheduleRestart;
}

private void ScheduleRestart()
{
    try
    {
        _listener?.Stop();
        _listener?.Close();
        _currentPort = _settings.Port;
        StartListener();
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}

EditorApplication.delayCall 在这里有两个作用------延迟(避免阻塞设置 UI 的事件处理)+ 强制主线程(marshal 到 Editor 主循环)。如果没有这一步,曾经出过 SocketException 偶发崩 Editor 的 bug。

8. 完整请求生命周期

把上面所有片段串起来,一次 MCP 工具调用的完整生命周期:
工具方法 EditorApplication.update 主线程 MCPExecutionBridge HttpListener 后台线程 AI 客户端 工具方法 EditorApplication.update 主线程 MCPExecutionBridge HttpListener 后台线程 AI 客户端 POST /mcp tools/call RunOnMainThread(work) 入 ConcurrentQueue DrainQueue tick 执行工具方法 调 Unity API 结果 TaskCompletionSource.SetResult await 恢复 200 application/json

关键点:

  • 后台线程接 HTTP,不会阻塞主线程的 Editor 渲染
  • 工具方法在主线程上执行,能无障碍调用所有 Unity API
  • await + TaskCompletionSource 把 tick-based 主线程模型桥接到标准 async/await
  • 单次请求的端到端延迟 = HTTP 解析 + 主线程 queue 等待(<16ms)+ 工具实际执行时间

9. 与 PlayMode 工具的协同

上一篇记录的 PlayMode 视觉闭环------enter_play_mode / simulate_mouse_click / capture_game_view------全部依赖这条 marshal 通道。enter_play_modeTask.Delay 轮询、capture_game_viewScreenCapture.CaptureScreenshotAsTexture() 主线程调用、Console 日志读取的反射 API,每一个都需要在主线程被执行。

如果没有可靠的 marshal 桥,PlayMode 工具要么会因为"在错的线程"抛 UnityException 崩掉,要么会因为"占着主线程不让出"卡死 Editor。MCPExecutionBridge 的 tick 轮询 + await 桥接是让所有这些工具能落地的底层基础设施。

10. 实测延迟与性能

在一台 M2 Pro Mac 上的实测数据(Unity 2022.3.62f2):

操作 端到端延迟
空 tool call(仅 ping) ~5 ms
get_scene_info(读 active scene 状态) ~8 ms
find_game_objects(场景 ~100 个对象) ~15 ms
execute_code(30 行简单逻辑) ~120 ms(含内存编译)
enter_play_mode(轻量项目) ~600 ms(不可压缩,等 Unity)
capture_game_view(1920×1080) ~50 ms

主线程 marshal 本身的额外开销在 5-15ms 之间,相对工具实际执行成本可忽略。HttpListener 后台线程模型也没有明显瓶颈------在 10 RPS 的并发压力下,主线程 queue 长度始终 < 3。

11. 写在最后

把 HTTP server 跑进 Unity Editor 不是把通用 HTTP 服务搬进来------所有"Unity API 只能主线程调用"的约束、"域重载会清空一切"的现实、"PlayMode 进出有异步状态"的复杂性,都需要在 marshal 层一并处理。MCPExecutionBridge + EditorApplication.update + TaskCompletionSource + 域重载钩子,是这套组合的最小可靠形态。

完整实现在 FunplayAI/funplay-unity-mcp,源码主要在 Editor/MCP/Server/MCPExecutionBridge.csEditor/MCP/Server/HttpMCPTransport.csEditor/State/MCPServerService.cs。MIT 协议。任何想自己往 Unity Editor 里塞 HTTP / WebSocket / gRPC 服务的项目,主线程边界处理这一层都能直接参考。

相关推荐
RxGc2 小时前
MCP生态爆发:Anthropic的协议野心与开发者的真实机会
人工智能·mcp
加号34 小时前
【Python】 实现 HTTP 网络请求功能入门指南
网络·python·http
阿松爱学习5 小时前
【Unity开发】Rigidbody中Body Type属性
unity·游戏引擎·unity开发
Chen--Xing5 小时前
Python -- 并发编程
python·多线程·并发编程
winlife_5 小时前
AI 怎么验证 Unity PlayMode 行为:截图 + 输入模拟的完整闭环
人工智能·unity·游戏引擎·ai编程·claude·playmode
相思难忘成疾6 小时前
Nginx 子目录多站点配置实验(HTTP/HTTPS 分离部署)
linux·运维·nginx·http·https·vim
CandyU28 小时前
Cursor AI Unity
unity
LF男男8 小时前
Bullect.cs(bullet)——子弹基类
unity
JiaWen技术圈8 小时前
HTTP/3 协议基础
网络·网络协议·http