☰
在 Unity Editor 里跑 HTTP MCP server:主线程边界与请求 marshal 的实现要点(TaoToken 配置骨架)
2026/9/27 22:19:27 网站建设 项目流程

1. 为什么 Unity Editor 里跑 HTTP MCP server 会卡在“线程边界”上

在 Unity Editor 里内嵌一个 HTTP MCP server,听起来像是“起个 HttpListener,收 JSON-RPC,调工具方法,返回结果”这么简单。但只要真正动手,第一道墙就会撞上来:Unity 的绝大多数 API 只能在主线程调用。GameObject.CreatePrimitive、AssetDatabase.SaveAssets、Selection.activeGameObject、EditorWindow.Repaint,甚至读一个isLoaded属性,只要你在后台线程碰它们,立刻抛UnityException: ... can only be called from the main thread。

而 HTTP server 的接收端又天然不能跑在主线程。HttpListener.GetContext()是阻塞调用,放在主线程会让 Editor 直接卡死;即使用GetContextAsync(),底层依然依赖 IO 调度,需要让出执行权。于是两条硬约束正面相撞:socket 必须在后台线程接,Unity API 必须在主线程跑。中间那层“把后台请求搬到主线程执行、再把结果同步回后台线程”的机制,就是请求 marshal。

这篇面向需要在编辑器内暴露 MCP 能力的工具开发者,目标是把最小可验证链路跑通:从HttpListener回调线程接到请求,经队列调度到 Editor 主线程执行,再原路返回 HTTP 响应。同时给出 TaoToken 统一 Key/API 通道的config.toml与settings.json可复制骨架,以及 Cline / CC Switch 侧接入配置,让外部 AI 客户端能真正调进来。

适合谁:正在做 Unity 编辑器工具、想把编辑器能力暴露给 AI 客户端、或者单纯想搞清楚“后台线程怎么安全调 Unity API”的开发者。下面所有代码都可以直接抄进工程验证。

2. TaoToken 前置:统一 Key 与 API 通道配置骨架

在写 marshal 逻辑之前,先把外部调用通道配好。TaoToken 提供统一的 Key 与 API 入口,模型对话、编码计划、控制台、API Keys 都在同一套账号体系下。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api (不加 UTM)。

先拿 Key:进入控制台创建 API Key,路径是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。拿到形如sk-...的 Key 后,写进本地配置。

config.toml骨架(放在项目根或用户配置目录,按你的加载逻辑读取):

# TaoToken 统一通道配置 [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" # 从环境变量读,避免硬编码 default_model = "claude-sonnet-4-20250514" timeout_seconds = 60 [mcp] # 本地 Unity Editor 内嵌 MCP server 的监听地址 host = "127.0.0.1" port = 17890 path = "/mcp" transport = "http" [logging] level = "info" # 观察 marshal 延迟时把这里调成 debug

settings.json骨架(给 Cline / CC Switch 这类客户端用):

{ "mcpServers": { "unity-editor": { "type": "http", "url": "http://127.0.0.1:17890/mcp", "headers": { "Authorization": "Bearer ${TAOTOKEN_API_KEY}" }, "timeout": 60000 } } }

注意:api_key_env和${TAOTOKEN_API_KEY}都指向环境变量,不要把 Key 明文提交进仓库。本地调试可以先export TAOTOKEN_API_KEY=sk-...。

Cline 侧接入:在 Cline 的 MCP 配置里粘贴上面的settings.json片段,保存后它会尝试连接http://127.0.0.1:17890/mcp。CC Switch 侧同理,把 server 类型设为http,URL 指向本地端口。模型对话验证可以走 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,长期编码或 Agent 场景建议用 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

3. 可复制配置:HttpListener 后台线程 + 主线程 marshal 桥

核心实现分三块:后台监听循环、主线程执行桥、请求处理入口。先看监听部分。

using System; using System.Net; using System.Threading; using System.Threading.Tasks; internal sealed class HttpMCPTransport { private HttpListener _listener; private Thread _listenThread; private int _port = 17890; public void Start() { _listener = new HttpListener(); _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); _listener.Start(); // 后台线程接 socket,绝不阻塞主线程 _listenThread = new Thread(ListenLoop) { IsBackground = true, Name = "MCP-HttpListener" }; _listenThread.Start(); } private void ListenLoop() { while (_listener != null && _listener.IsListening) { try { var ctx = _listener.GetContext(); // 阻塞,但只在后台线程 ThreadPool.QueueUserWorkItem(_ => HandleRequest(ctx)); } catch (HttpListenerException) { break; // Stop/Close 时正常退出 } catch (ObjectDisposedException) { break; } } } private async void HandleRequest(HttpListenerContext ctx) { // 这里仍在后台线程,不能碰 Unity API var result = await MCPExecutionBridge.Instance.RunOnMainThread(() => { // 主线程执行区:可以安全调用 Unity API return DispatchToolCall(ctx); }); var bytes = System.Text.Encoding.UTF8.GetBytes(result); ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length); ctx.Response.Close(); } }

主线程执行桥是整套机制的心脏。用ConcurrentQueue收任务,用EditorApplication.update每帧排空,用TaskCompletionSource把 tick 模型桥接到async/await:

using System; using System.Collections.Concurrent; using System.Threading.Tasks; using UnityEditor; using UnityEngine; internal sealed class MCPExecutionBridge { public static readonly MCPExecutionBridge Instance = new MCPExecutionBridge(); private readonly ConcurrentQueue<Action> _mainThreadActions = new(); private readonly int _mainThreadId; private MCPExecutionBridge() { _mainThreadId = System.Threading.Thread.CurrentThread.ManagedThreadId; EditorApplication.update += DrainQueue; } public bool IsMainThread => System.Threading.Thread.CurrentThread.ManagedThreadId == _mainThreadId; public Task<TResult> RunOnMainThread<TResult>(Func<TResult> work) { if (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); } } } }

三种 marshal 策略的取舍,实测下来EditorApplication.update轮询最稳:

策略频率延迟代价
EditorApplication.update轮询~60Hz<16ms需自管线程安全 queue
EditorApplication.delayCall一次性不确定多次触发会批合并,不适合 1:1 请求
SynchronizationContext.Post异步版本差异大Unity 各版本设置不一致

ConcurrentQueue保证多线程安全入队,DrainQueue在每帧 update 里把所有积压任务执行完。60Hz 下平均延迟低于 16ms,对 MCP 工具调用完全够用。

4. 验证请求:从 HttpListener 到主线程执行的完整链路

配置写好后,跑一次最小验证。启动 Editor,确认 MCP server 已监听,然后用 curl 发一个tools/call:

curl -X POST http://127.0.0.1:17890/mcp \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_scene_info", "arguments": {} } }'

预期返回类似:

{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"activeScene\":\"SampleScene\",\"rootCount\":12}" } ] } }

日志观察点,按顺序打点确认链路:

// 1. 后台线程收到请求 Debug.Log($"[MCP] recv on thread {Thread.CurrentThread.ManagedThreadId}"); // 2. 入队 Debug.Log($"[MCP] enqueue, queue={_mainThreadActions.Count}"); // 3. 主线程排空执行 Debug.Log($"[MCP] drain on main thread, isMain={IsMainThread}"); // 4. 结果回写 Debug.Log($"[MCP] response written, elapsed={sw.ElapsedMilliseconds}ms");

关键验证点:第 1 步的线程 ID 不等于主线程 ID,第 3 步的线程 ID 必须等于主线程 ID。如果第 3 步仍在后台线程,说明 marshal 没生效,工具方法里调 Unity API 会立刻抛异常。

一次请求的端到端延迟构成:HTTP 解析 + 主线程 queue 等待(<16ms)+ 工具实际执行时间。在 M2 Pro 上实测,空 tool call 约 5ms,get_scene_info约 8ms,find_game_objects(约 100 个对象)约 15ms,execute_code(30 行)约 120ms(含内存编译)。主线程 marshal 本身的开销在 5–15ms,相对工具执行成本可忽略。

5. 本篇常见错排查

报错一:UnityException: get_isLoaded can only be called from the main thread

这是最典型的信号,说明工具方法在后台线程被直接调用了,marshal 没接上。检查HandleRequest里是否真的走了RunOnMainThread,以及DispatchToolCall是否在 lambda 内部执行。常见坑是把DispatchToolCall(ctx)写在await外面,那样它仍在后台线程跑。

报错二:Editor 卡死,UI 无响应

多半是GetContext()被放到了主线程,或者DrainQueue里执行了阻塞操作(比如同步等待网络、Thread.Sleep)。GetContext必须在后台线程;DrainQueue里只做轻量调度,重活交给async工具方法。

报错三:域重载后请求静默卡死

Unity 域重载会重启托管脚本域,HttpListenersocket 被 OS 关闭,所有 inflight 请求被中止。正确做法是主动 reject 而非 buffer:在beforeAssemblyReload里关闭 listener,并给所有 pending 的TaskCompletionSource设置异常。

private void OnBeforeReload() { _listener?.Stop(); _listener?.Close(); foreach (var tcs in _pendingTasks.Values) tcs.TrySetException(new OperationCanceledException("Domain reload interrupted")); }

客户端收到连接中断或 500 后,下一次调用可以拿结构化的中断摘要,比静默卡死可控得多。

报错四:改端口时偶发SocketException崩 Editor

设置变更事件可能跑在 ThreadPool 线程,直接_listener.Stop()会出问题。用EditorApplication.delayCall强制 marshal 回主线程再重启:

private void OnSettingsChanged(int newPort) { if (newPort == _currentPort) return; EditorApplication.delayCall += () => { _listener?.Stop(); _listener?.Close(); _currentPort = newPort; Start(); }; }

报错五:异步工具方法(如 enter_play_mode)超时

这类工具本身要等 Unity 状态变化,不能在单个 tick 里同步完成。让工具方法返回Task<object>,内部用await Task.Delay(100)轮询EditorApplication.isPlaying,Task.Delay会释放主线程让出执行权。HTTP 响应在 Task 完成后才发回,客户端按 HTTP 超时计算。

6. 接入与排障的下一步

把上面链路跑通后,外部 AI 客户端就能通过 TaoToken 统一通道调进 Unity Editor。接入相关的 Key 与文档入口:API Keys 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果只是先验证模型能不能正常对话,走 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 最快;长期做编码或 Agent 编排,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

排障时优先看两个点:一是日志里主线程 ID 是否匹配,二是域重载后 pending 任务是否被正确 reject。这两处稳了,marshal 层基本就不会再出幺蛾子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询