dsh-unity-mcp 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,86 +2,94 @@
2
2
 
3
3
  <img src="assets/icon_00001_.png" width="128" alt="Unity MCP: white and blue cubes" />
4
4
 
5
- 把 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 的 Unity / 团结引擎编辑器工具接入 DSH,默认按 Agent 懒加载。
5
+ 把 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 接入 DSH,让 Agent 操作 Unity / Tuanjie(团结引擎)工程。工具默认按 Agent 懒加载,平时只提供一个加载入口。
6
6
 
7
- **必须先在 Unity 或 Tuanjie(团结引擎)项目中安装 CoderGamester 的 MCP Unity Server,并启动引擎端服务。只安装本 npm 插件无法控制编辑器。** 本项目是 DSH 适配插件;引擎端与实际编辑器工具由上游提供。上游项目、安装说明及许可证见 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity)。
7
+ ## 能做什么
8
8
 
9
- ## 安装与连接
9
+ **不依赖已连接的 Unity MCP:** 在 Windows 上按工程版本匹配并打开编辑器、等待工程就绪、准备 CoderGamester 安装、保存已加载场景和修改资源、先保存再安全关闭编辑器,以及保存 DSH 的工程连接配置。保存、关闭和就绪检查会向工程加入一个小型 Editor 桥接,仍需编辑器正常编译并运行。
10
10
 
11
- 需要 Node.js 22+、DSH `>=0.2.1-rc.3 <0.3.0`。上游当前声明 Unity 2022.3+;Tuanjie 的兼容性取决于所用引擎版本和上游包,不能保证所有版本都适用。
11
+ **依赖引擎端 CoderGamester MCP:** 查询和编辑场景、GameObject、组件、材质与资源,查看控制台,以及调用上游提供的编辑器和测试功能。具体能力随上游版本变化;有工具清单不代表编辑器已经连通。
12
12
 
13
- 1. 在 Unity / Tuanjie 的 Package Manager 中选择 **Add package from git URL**,填入:
14
- ```text
15
- https://github.com/CoderGamester/mcp-unity.git
16
- ```
17
- 2. 打开 **Tools → MCP Unity → Server Window**,按照上游说明启动服务。找到该项目安装的 `Server~/build/index.js`。如果没有构建文件,在对应的 `Server~` 目录运行 `npm install` 和 `npm run build`。DSH 插件不会自动下载、安装或构建引擎包。
18
- 3. 在 DSH 插件管理中安装 npm 包:
19
- ```text
20
- dsh-unity-mcp
21
- ```
22
- 4. 在插件配置中设置 `projectPath` 为引擎项目绝对路径。插件会查找 `Packages/com.gamelovers.mcp-unity/Server~/build/index.js` 以及 `Library/PackageCache` 中的构建文件,也兼容 `com.codergamester.mcp-unity` 目录名。存在多个缓存版本时,必须明确设置 `serverPath`。
23
- 5. 找不到构建文件时,把 `serverPath` 设置为引擎 Server Window 给出的服务器入口绝对路径。可以使用单独克隆并构建的上游服务器;此时仍建议填写 `projectPath`,以读取正确的项目端口和认证设置。
13
+ 本项目是 DSH 适配插件,不是 Unity、团结引擎或 CoderGamester 官方产品。控制编辑器内容前,必须在目标工程中安装并启动 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity);可以自己安装,也可以让 Agent 准备安装。
24
14
 
25
- 配置示例(请替换路径):
15
+ ## 安装 DSH 插件
26
16
 
27
- ```json
28
- {
29
- "projectPath": "C:/Projects/MyUnityProject",
30
- "serverPath": "C:/Tools/mcp-unity/Server~/build/index.js",
31
- "lazyTools": true
32
- }
17
+ 需要 Node.js 22+、DSH `>=0.2.1-rc.3 <0.3.0`。在 DSH 插件管理中安装:
18
+
19
+ ```text
20
+ dsh-unity-mcp
33
21
  ```
34
22
 
35
- 配置为空时插件仅提供加载入口,并提示如何连接。它不会启动 Unity / Tuanjie 编辑器。连接配置完成后,DSH 启动上游 Node stdio 桥接进程,由桥接进程连接引擎端服务。
23
+ 本地工程管理目前只支持 Windows。上游声明 Unity 2022.3+;本插件已在独立团结工程中验证,但不能保证所有 Unity / Tuanjie 版本都兼容。
24
+
25
+ ## 连接你的工程
26
+
27
+ ### 让 Agent 安装并连接
36
28
 
37
- ## 通过对话配置工程(0.0.2 起)
29
+ 可以直接说:“打开 `C:/Projects/MyGame`,准备 CoderGamester,再连接这个工程。”
38
30
 
39
- 可以直接告诉 Agent:“把 Unity MCP 连接到 `C:/Projects/MyUnityProject`。”Agent 先调用 `unity_load_tools`,再调用 `unity_configure_project`,传入工程绝对路径 `projectPath`;只有自动发现失败时才需要补充 `serverPath`。未连接引擎时也能加载配置工具。
31
+ Agent 加载工具后,会匹配工程要求的编辑器版本并打开工程,等待编辑器就绪,再准备包依赖和自动启动。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
40
32
 
41
- 工具检查工程的 `Assets`、`ProjectSettings` 和已构建的 CoderGamester 服务器入口,再通过 DSH 官方配置服务保存并重载插件。**保存影响当前 profile 的所有会话;会清空旧的 host、port、authTokenPath 覆盖项,改用新工程设置。** 其他配置保留。路径校验失败不会保存;并发修改连接字段时会要求重试。保存成功仅表示配置已应用,仍需重新加载工具并做只读场景查询,确认引擎在线。
33
+ **打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`editor_loading` 表示仍未确认就绪,应再次调用打开工具继续等待,不要重复启动或直接假设工程已经加载完成。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
42
34
 
43
- 工具不会安装引擎包、构建服务器或启动编辑器。也可以继续在插件配置中手工设置路径。
35
+ 编辑器就绪仍不等于 MCP 连通。Package Manager 还需下载和编译引擎包,服务需要成功启动;Node 服务器缺少构建文件时,还需在其 `Server~` 目录执行 `npm install` 和 `npm run build`。安装工具不会代为下载或构建 Node 服务器,也不自动改变 DSH 连接。
44
36
 
45
- ## 打开工程(0.0.3 起,Windows)
37
+ 最后让 Agent 保存工程连接配置,并做一次只读场景查询。只有查询返回目标工程的信息,才能确认这条连接可用。
46
38
 
47
- 调用 `unity_load_tools` 后可使用 `unity_open_project`,传入 `projectPath`。不依赖引擎端 MCP 在线,也不要求工程已经安装 MCP 包。
39
+ ### 自己在工程里安装
48
40
 
49
- 工具读取 `ProjectSettings/ProjectVersion.txt`,在标准 Unity / Tuanjie Hub 安装目录中寻找 ProductVersion 完全匹配的编辑器(允许版本后的 `_revision`)。自定义安装位置或多个匹配时,需传入 `editorPath`,指向对应的 `Unity.exe` / `Tuanjie.exe`。不自动安装编辑器,不允许用不同版本升级工程。当前不支持 macOS / Linux。
41
+ 1. 在 Unity / Tuanjie 的 Package Manager 中选择 **Add package from git URL**,按上游安装说明添加:
42
+ ```text
43
+ https://github.com/CoderGamester/mcp-unity.git
44
+ ```
45
+ 2. 等待包解析和编译,在 **Tools → MCP Unity → Server Window** 启动服务。找到对应的 `Server~/build/index.js`;缺少时在 `Server~` 中运行 `npm install` 和 `npm run build`。
46
+ 3. 告诉 Agent:“把 Unity MCP 连接到 `C:/Projects/MyGame`。”也可以在 DSH 插件配置中手工填写 `projectPath`。自动发现失败时再指定已构建的 `serverPath`。
47
+ 4. 做一次只读场景查询,核对工程和场景,而不只是确认工具已加载。
50
48
 
51
- 已检测到该工程的编辑器进程时返回 `already_open`。存在工程锁但无法确认进程时拒绝启动,不删除锁;同一进程内的并发请求和 60 秒内重复启动也会被拦截。其他 DSH 进程或外部程序同时启动时,仍需依靠编辑器自己的工程锁。
49
+ ## 使用与安全边界
52
50
 
53
- **打开工程可能触发资源导入和工程内编辑器脚本。请只打开可信工程。** 返回 `launch_requested` 仅表示系统接受启动请求,不表示编辑器已加载完成或 MCP 已连接。此工具不修改 DSH 连接配置;随后仍需用 `unity_configure_project` 配置连接,确认引擎服务启动后做只读查询。
51
+ 每个 Agent 初始只看到 `unity_load_tools`,调用后才获得本地工程管理和已发现的上游工具;其他 Agent 不受影响。未连接 MCP 时仍可加载本地功能。服务器重连、工具更新和移除会同步到已加载的 Agent。需要立即暴露全部工具时,可将 `lazyTools` 设为 `false`。
54
52
 
55
- ## 工具懒加载
53
+ 打开工程可能导入资源、运行工程内的编辑器脚本;安装包也会执行第三方代码。请只操作可信且已备份的工程。工具不会用不匹配的编辑器版本自动升级工程;自定义编辑器位置或多个匹配时,需要提供确切的 `editorPath`。
56
54
 
57
- 每个 Agent 初始只看到 `unity_load_tools`。调用后获得 `unity_configure_project` 和 `unity_open_project`,并加载当前服务器提供的 `mcp__unity__*` 工具;未连接时也有这两个本地工具。其他 Agent 仍保持未加载。场景、GameObject、组件、材质、资源、控制台和测试等工具的参数与结果沿用上游,插件不维护另一套工具清单。
55
+ 保存会处理所有已加载的具名场景和修改资源;未命名场景需先在编辑器中选择保存路径。Play Mode 和 Prefab Mode 会阻止保存与关闭。关闭只有在保存成功后才请求正常退出,并确认目标工程进程结束;不强杀,不提供丢弃修改选项。超时可能意味着操作已开始,重试前先检查编辑器。
58
56
 
59
- 加载动作不会修改工程。**加载后的编辑器操作可能创建、删除资源,修改场景、执行菜单或安装包。请先提交或备份工程,并检查每次调用的范围。** 如需立即暴露全部工具,把 `lazyTools` 设为 `false`。实际工具数量随上游版本变化。
57
+ 就绪、保存、关闭和安装使用本插件分发的 Editor 桥接。首次使用会在 `Assets/Editor/DshUnityMcp` 写入脚本,可能触发编译;官方未改动的 0.0.4 桥接会备份后升级,自定义或未知内容不覆盖。安装修改包依赖或已有自动启动设置前也会保留 `.dsh-backup`。
60
58
 
61
- 插件在每个 Agent 步骤前同步工具列表;服务器重新注册或移除工具后,会更新已加载 Agent 的可见工具。插件卸载时清理所属加载入口、局部工具与限制。
59
+ **保存 DSH 连接配置会影响当前 profile 的所有会话。** 配置工具会校验路径,清空旧的 host、port、authTokenPath 覆盖项,再保存并重载插件。打开工程本身不会切换连接。多个工程同时运行时,要为它们安排不同的 MCP 端口,避免服务冲突。
62
60
 
63
61
  ## 配置
64
62
 
63
+ ```json
64
+ {
65
+ "projectPath": "C:/Projects/MyGame",
66
+ "lazyTools": true
67
+ }
68
+ ```
69
+
65
70
  | 字段 | 默认值 | 用途 |
66
71
  | --- | --- | --- |
67
- | `projectPath` | 空 | Unity / Tuanjie 项目绝对路径;用于发现服务器与读取项目设置 |
68
- | `serverPath` | 空 | 已构建的上游服务器 JS 入口绝对路径;优先于自动发现 |
69
- | `lazyTools` | `true` | 按 Agent 加载编辑器工具 |
72
+ | `projectPath` | 空 | 工程绝对路径;发现服务器并读取该工程设置 |
73
+ | `serverPath` | 空 | 已构建的上游 `Server~/build/index.js` 绝对路径;优先于自动发现 |
74
+ | `lazyTools` | `true` | 按 Agent 加载工具 |
70
75
  | `host` | 空 | 覆盖 `UNITY_HOST`;留空沿用上游设置 |
71
76
  | `port` | `0` | 覆盖 `UNITY_PORT`;0 沿用项目设置 |
72
- | `authTokenPath` | 空 | 可选 token 文件绝对路径,配置中不保存 token 内容 |
73
- | `toolCallTimeoutMs` | `120000` | 单次 MCP 工具超时,范围 1000–600000 毫秒 |
77
+ | `authTokenPath` | 空 | 可选认证文件绝对路径;不在配置中存 token |
78
+ | `toolCallTimeoutMs` | `120000` | 上游 MCP 调用超时,1000–600000 毫秒;不控制本地工程打开的等待时间 |
74
79
 
75
- 填写 `projectPath` 后,桥接进程使用项目的 `ProjectSettings/McpUnitySettings.json`。认证方式由上游控制;不要把 token 提交到 Git。更换项目时同步修改项目路径、服务器入口及端口,避免连接错误的编辑器。已有其他 Unity MCP 连接时,应避免重复注册相同 `unity` 命名空间。
80
+ 插件从工程的 `Packages` 或 `Library/PackageCache` 发现服务器,兼容 `com.gamelovers.mcp-unity` 和 `com.codergamester.mcp-unity` 目录名。存在多个缓存版本时需明确指定 `serverPath`,也可以使用单独克隆并构建的上游服务器。
81
+
82
+ 桥接进程使用工程的 `ProjectSettings/McpUnitySettings.json`。认证由上游控制,不要把 token 提交到 Git。已有其他 Unity MCP 连接时,避免重复注册相同的 `unity` 命名空间。
76
83
 
77
84
  ## 排障
78
85
 
79
- - **Built CoderGamester server not found**:确认引擎包已经安装并构建,或者手动填写 `serverPath`。
80
- - **Multiple cached CoderGamester servers found**:缓存中存在多个构建版本,明确选择 `serverPath`。
81
- - **Unity MCP is not connected**:检查插件配置、DSH MCP 日志,以及引擎 Server Window 中的运行状态、端口和认证设置。
82
- - **工具已加载但调用失败**:加载工具清单不等于引擎在线。确认项目仍然打开、服务仍在运行。
86
+ - **工程一直是 `editor_loading`**:检查编辑器中的包解析、编译错误、模态窗口或自动刷新;修复后继续等待。不要因为超时就再次启动编辑器。
87
+ - **安装等待失败**:运行中的工程尚未回应就绪检查,依赖未修改。先让工程完成加载;若修改过桥接源码,需人工处理冲突。
88
+ - **Built CoderGamester server not found**:确认引擎包已经安装且 Node 服务器已构建,或手工设置 `serverPath`。
89
+ - **Multiple cached CoderGamester servers found**:明确选择 `serverPath`。
90
+ - **端口占用、认证失败或工具调用失败**:检查目标工程的 Server Window、端口、认证文件和 DSH MCP 日志。安装准备成功、编辑器就绪、加载工具清单,都不能替代真实场景查询。
83
91
 
84
- ## 开发
92
+ ## 开发与来源
85
93
 
86
94
  ```sh
87
95
  npm install
@@ -90,10 +98,6 @@ npm run build
90
98
  npm run pack:check
91
99
  ```
92
100
 
93
- 测试覆盖服务器发现、配置边界、Agent 隔离、延迟发现、工具更新与清理。测试中的上下文大小比较使用 UTF-8 schema 字节数,不把字节数当作模型 token 数。
94
-
95
- ## 来源与许可
96
-
97
- 本适配插件采用 MIT 许可证。实际 Unity MCP 服务来自 **CoderGamester/mcp-unity**,上游采用 MIT 许可证。本插件不打包上游引擎或服务器源码,也不代表 Unity Technologies、团结引擎或 CoderGamester 官方产品。请遵守上游安装要求及许可。
101
+ 测试覆盖服务器发现、配置边界、就绪等待、安装保护、保存关闭和 Agent 隔离。上下文大小测试比较 UTF-8 schema 字节数,不把字节数当作模型 token 数。
98
102
 
99
- 图标由 Krea 2 Turbo 生成,是本插件的独立图案,不使用 Unity 官方标志。相关说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
103
+ 本适配插件采用 MIT 许可证。上游 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 也采用 MIT 许可证;本包不分发上游引擎或服务器源码。图标由 Krea 2 Turbo 生成,不使用 Unity 官方标志。许可与素材说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -0,0 +1,71 @@
1
+ using System;
2
+ using System.IO;
3
+ using System.Reflection;
4
+ using UnityEditor;
5
+ using UnityEditor.SceneManagement;
6
+ using UnityEngine;
7
+
8
+ namespace DshUnityMcp {
9
+ [InitializeOnLoad]
10
+ public static class ProjectBridge {
11
+ [Serializable] class Request { public string id; public string action; public long expires; }
12
+ [Serializable] class Reply { public string id; public bool success; public string message; public int pid; }
13
+ static readonly string Root = Path.GetFullPath(Path.Combine(Application.dataPath, "../Library/DshUnityMcp"));
14
+ static double next;
15
+ static double stableSince;
16
+ static ProjectBridge() { EditorApplication.update += Update; }
17
+ static void Update() {
18
+ if (EditorApplication.isCompiling || EditorApplication.isUpdating) { stableSince = 0; return; }
19
+ if (stableSince == 0) stableSince = EditorApplication.timeSinceStartup;
20
+ if (EditorApplication.timeSinceStartup < next) return;
21
+ next = EditorApplication.timeSinceStartup + .25;
22
+ if (!Directory.Exists(Root)) return;
23
+ foreach (var file in Directory.GetFiles(Root, "*.request.json")) {
24
+ Request request = null;
25
+ try {
26
+ request = JsonUtility.FromJson<Request>(File.ReadAllText(file));
27
+ if (request != null && request.action == "ready" && EditorApplication.timeSinceStartup - stableSince < 2) continue;
28
+ File.Delete(file);
29
+ if (request == null || !Guid.TryParseExact(request.id, "N", out _) || Path.GetFileName(file) != request.id + ".request.json") continue;
30
+ if (request.expires < DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) continue;
31
+ if (request.action == "ready") { Write(request.id, true, "Editor update loop ready; MCP connectivity not checked."); continue; }
32
+ bool close = request.action == "close";
33
+ if (request.action == "save" || close) {
34
+ if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Stop Play mode before saving or closing.");
35
+ for (int i = 0; i < EditorSceneManager.sceneCount; i++) {
36
+ var scene = EditorSceneManager.GetSceneAt(i);
37
+ if (scene.isLoaded && string.IsNullOrEmpty(scene.path)) throw new Exception("Unnamed scene: save it to an explicit path in the editor first.");
38
+ }
39
+ if (PrefabStageUtility.GetCurrentPrefabStage() != null) throw new Exception("Exit Prefab Mode before saving or closing.");
40
+ if (!EditorSceneManager.SaveOpenScenes()) throw new Exception("Saving scenes failed; editor remains open.");
41
+ AssetDatabase.SaveAssets();
42
+ foreach (var asset in Resources.FindObjectsOfTypeAll<UnityEngine.Object>())
43
+ if (asset != null && EditorUtility.IsPersistent(asset) && EditorUtility.IsDirty(asset) && AssetDatabase.GetAssetPath(asset).StartsWith("Assets/", StringComparison.Ordinal)) throw new Exception("An asset remains dirty: " + AssetDatabase.GetAssetPath(asset) + " (" + asset.name + "); editor remains open.");
44
+ for (int i = 0; i < EditorSceneManager.sceneCount; i++) if (EditorSceneManager.GetSceneAt(i).isDirty) throw new Exception("A scene remains dirty; editor remains open.");
45
+ } else if (request.action == "enable") {
46
+ Type server = null;
47
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { server = assembly.GetType("McpUnity.Unity.McpUnityServer"); if (server != null) break; }
48
+ if (server == null) throw new Exception("CoderGamester is not compiled yet.");
49
+ var settings = server.Assembly.GetType("McpUnity.Unity.McpUnitySettings");
50
+ var settingsInstance = settings.GetProperty("Instance").GetValue(null);
51
+ settings.GetMethod("LoadSettings").Invoke(settingsInstance, null);
52
+ var instance = server.GetProperty("Instance").GetValue(null);
53
+ server.GetMethod("StartServer").Invoke(instance, null);
54
+ } else throw new Exception("Unsupported request.");
55
+ Write(request.id, true, close ? "Saved; normal exit requested." : "Operation completed.");
56
+ if (close) { EditorApplication.delayCall += () => {
57
+ if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling) return;
58
+ for (int i = 0; i < EditorSceneManager.sceneCount; i++) if (EditorSceneManager.GetSceneAt(i).isDirty) return;
59
+ AssetDatabase.SaveAssets();
60
+ EditorApplication.Exit(0);
61
+ }; return; }
62
+ } catch (Exception error) { if (request != null && Guid.TryParseExact(request.id, "N", out _)) Write(request.id, false, error.Message); }
63
+ }
64
+ }
65
+ static void Write(string id, bool success, string message) {
66
+ var target = Path.Combine(Root, id + ".response.json");
67
+ File.WriteAllText(target + ".tmp", JsonUtility.ToJson(new Reply { id = id, success = success, message = message, pid = System.Diagnostics.Process.GetCurrentProcess().Id }));
68
+ File.Move(target + ".tmp", target);
69
+ }
70
+ }
71
+ }
package/index.mjs CHANGED
@@ -3,6 +3,8 @@ import * as mcp from '@deepseek-ai/dsh-mcp-client';
3
3
  import { connectionConfig } from './lib/connection.mjs';
4
4
  import { installLazyTools } from './lib/lazy-tools.mjs';
5
5
  import { openProjectTool } from './lib/open-project.mjs';
6
+ import { lifecycleTools } from './lib/project-lifecycle.mjs';
7
+ import { installationTool } from './lib/install-codergamester.mjs';
6
8
  import { configurationTool } from './lib/configure-project.mjs';
7
9
  export const name = 'dsh-unity-mcp';
8
10
  export const inject = ['tools', 'agents', 'configEditor'];
@@ -17,7 +19,7 @@ export const Config = z.object({
17
19
  });
18
20
  export async function apply(ctx, config) {
19
21
  // Install the loader even before configuration, so discovery explains setup.
20
- installLazyTools(ctx, config.lazyTools, [configurationTool(ctx), openProjectTool()]);
22
+ installLazyTools(ctx, config.lazyTools, [configurationTool(ctx), openProjectTool(), ...lifecycleTools(), installationTool()]);
21
23
  if (!config.projectPath && !config.serverPath) {
22
24
  ctx.logger.info('Unity MCP: configure projectPath or serverPath to connect CoderGamester/mcp-unity.');
23
25
  return;
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ import { readFile, writeFile, copyFile, stat } from 'node:fs/promises';
3
+ import { projectRoot, ensureBridge, matchingProcess, requestProject } from './project-lifecycle.mjs';
4
+ import { resolveServer } from './connection.mjs';
5
+ import { launchPending, waitForEditor } from './open-project.mjs';
6
+ const PACKAGE = 'com.gamelovers.mcp-unity';
7
+ export const UPSTREAM = 'https://github.com/CoderGamester/mcp-unity.git#382a43a30f4dab4c7f02d770b4ec7d084813bbe4';
8
+ const installing = new Set();
9
+ export async function installCoderGamester(input, deps = {}) {
10
+ if ((deps.platform ?? process.platform) !== 'win32') throw Error('Installation tool currently supports Windows only.');
11
+ const root = await projectRoot(input.projectPath);
12
+ if (installing.has(root)) throw Error('Installation is in progress for this project.');
13
+ installing.add(root);
14
+ try {
15
+ const deadline = Date.now() + (input.timeoutMs ?? 120000);
16
+ const editor = await matchingProcess(root, deps.processes);
17
+ const lock = await stat(path.join(root, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
18
+ if (editor || launchPending(root) || lock) {
19
+ try { await (deps.waitReady ?? waitForEditor)(root, Math.max(1, deadline - Date.now()), deps); }
20
+ catch (error) { throw Error('Editor not ready; package dependencies were not changed. Wait with unity_open_project before retrying installation. ' + error.message); }
21
+ }
22
+ const manifest = path.join(root, 'Packages/manifest.json');
23
+ const original = await readFile(manifest, 'utf8');
24
+ const parsed = JSON.parse(original);
25
+ if (!parsed.dependencies || typeof parsed.dependencies !== 'object' || Array.isArray(parsed.dependencies)) throw Error('Invalid package manifest dependencies.');
26
+ const current = parsed.dependencies[PACKAGE];
27
+ if (current && current !== UPSTREAM) throw Error('CoderGamester already has a different dependency. Refusing to replace it: ' + current);
28
+ await ensureBridge(root);
29
+ const settings = path.join(root, 'ProjectSettings/McpUnitySettings.json');
30
+ let text; try { text = await readFile(settings, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
31
+ const config = text ? JSON.parse(text) : {};
32
+ config.AutoStartServer = true;
33
+ if (text && JSON.parse(text).AutoStartServer !== true) await copyFile(settings, settings + '.dsh-backup', 1);
34
+ if (!current) {
35
+ const backup = manifest + '.dsh-backup';
36
+ await copyFile(manifest, backup, 1);
37
+ if (await readFile(manifest, 'utf8') !== original) throw Error('Manifest changed during preparation; retry.');
38
+ parsed.dependencies[PACKAGE] = UPSTREAM;
39
+ await writeFile(manifest, JSON.stringify(parsed, null, 2) + '\n');
40
+ }
41
+ await writeFile(settings, JSON.stringify(config, null, 2) + '\n');
42
+ let enableStatus = 'pending_editor_start';
43
+ if (await matchingProcess(root, deps.processes)) {
44
+ try { await (deps.request ?? requestProject)({ projectPath: root, timeoutMs: Math.max(1, deadline - Date.now()) }, 'enable', deps); enableStatus = 'start_requested'; }
45
+ catch (error) { enableStatus = 'pending: ' + error.message; }
46
+ }
47
+ let serverPath = '';
48
+ try { serverPath = await resolveServer({ projectPath: root }); } catch {}
49
+ return { projectPath: root, dependency: UPSTREAM, dependencyAdded: !current, enableStatus, serverPath, ready: false,
50
+ message: 'Dependency and Editor bridge prepared; package resolution, compilation and service connectivity are separate. Open/refresh the editor to resolve the pinned package. If serverPath is empty, build its Server~ with npm install and npm run build. Then configure the DSH connection and verify a read-only editor call. No other MCP packages were removed.' };
51
+ } finally { installing.delete(root); }
52
+ }
53
+ export function installationTool() {
54
+ return { name: 'unity_install_codergamester', description: 'Install pinned CoderGamester/mcp-unity without an existing MCP connection. For a running/starting Windows editor, wait for readiness before changing dependencies; timeout leaves dependencies unchanged. For a closed project, prepare files offline; this does not open it. Enable auto-start and request start if open. Preserves other packages; refuses replacing a different existing CoderGamester dependency. Does not build the Node server or configure/verify MCP.',
55
+ parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute project directory with Packages/manifest.json.' }, timeoutMs: { type: 'integer', minimum: 1000, maximum: 180000, description: 'Wait for compilation/start request, default 120000 ms.' } }, required: ['projectPath'], additionalProperties: false },
56
+ output: { schema: { type: 'object', properties: { projectPath: { type: 'string' }, dependency: { type: 'string' }, dependencyAdded: { type: 'boolean' }, enableStatus: { type: 'string' }, serverPath: { type: 'string' }, ready: { type: 'boolean' }, message: { type: 'string' } }, required: ['projectPath', 'dependency', 'dependencyAdded', 'enableStatus', 'serverPath', 'ready', 'message'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] }, execute: input => installCoderGamester(input) };
57
+ }
@@ -5,6 +5,25 @@ import { promisify } from 'node:util';
5
5
  const exec = promisify(execFile);
6
6
  const pending = new Set();
7
7
  const recentLaunches = new Map();
8
+ export function launchPending(root) { return pending.has(root.toLowerCase()) || Date.now() - (recentLaunches.get(root.toLowerCase()) ?? 0) < 60000; }
9
+ export async function waitForEditor(root, timeoutMs, deps = {}) {
10
+ const { setTimeout: delay } = await import('node:timers/promises');
11
+ const { requestProject, matchingProcess } = await import('./project-lifecycle.mjs');
12
+ const deadline = Date.now() + timeoutMs;
13
+ while (Date.now() < deadline) {
14
+ if (await matchingProcess(root, deps.processes)) return await requestProject({ projectPath: root, timeoutMs: Math.max(1, deadline - Date.now()) }, 'ready', deps);
15
+ await delay(250);
16
+ }
17
+ throw Error('Editor process did not become available within the wait budget.');
18
+ }
19
+ async function finishOpen(result, input, deps) {
20
+ try {
21
+ await (deps.waitReady ?? waitForEditor)(result.projectPath, input.timeoutMs ?? 120000, deps);
22
+ return { ...result, status: 'editor_ready', editorReady: true, message: 'Editor responded after import/compilation settled. MCP connectivity is not verified.' };
23
+ } catch (error) {
24
+ return { ...result, status: 'editor_loading', editorReady: false, message: 'Not ready: ' + error.message + ' Do not assume loading finished. Retry unity_open_project to wait again; do not relaunch or configure MCP yet.' };
25
+ }
26
+ }
8
27
  async function powershell(script) {
9
28
  const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
10
29
  return JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || 'null');
@@ -45,7 +64,7 @@ export async function openProject(input, deps = {}) {
45
64
  const rows = await processes();
46
65
  for (const row of Array.isArray(rows) ? rows : [rows]) {
47
66
  const target = projectFromCommandLine(row.CommandLine);
48
- if (target && await realpath(target).then(p => p.toLowerCase() === key, () => false)) return { status: 'already_open', projectPath, editorPath: row.ExecutablePath || '', version, pid: row.ProcessId };
67
+ if (target && await realpath(target).then(p => p.toLowerCase() === key, () => false)) return await finishOpen({ status: 'already_open', projectPath, editorPath: row.ExecutablePath || '', version, pid: row.ProcessId }, input, deps);
49
68
  }
50
69
  if (Date.now() - (recentLaunches.get(key) ?? 0) < 60000) throw Error('A launch was requested within the last 60 seconds; wait for the editor before retrying.');
51
70
  const lock = await stat(path.join(projectPath, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
@@ -61,21 +80,25 @@ export async function openProject(input, deps = {}) {
61
80
  if (typeof actual === 'string' && (actual === version || actual.startsWith(version + '_'))) matches.push(candidate);
62
81
  }
63
82
  if (matches.length !== 1) throw Error(`Expected one editor matching ${version}; found ${matches.length}. Provide editorPath to the matching Unity.exe/Tuanjie.exe. No version upgrade is allowed.`);
83
+ const { ensureBridge } = await import('./project-lifecycle.mjs');
84
+ await ensureBridge(projectPath);
64
85
  const pid = await launch(matches[0], projectPath);
65
86
  for (const [name, time] of recentLaunches) if (Date.now() - time >= 60000) recentLaunches.delete(name);
66
87
  recentLaunches.set(key, Date.now());
67
- return { status: 'launch_requested', projectPath, editorPath: matches[0], version, pid };
88
+ return await finishOpen({ status: 'launch_requested', projectPath, editorPath: matches[0], version, pid }, input, deps);
68
89
  } finally { pending.delete(key); }
69
90
  }
70
91
  async function launchEditor(editor, project) {
71
92
  return new Promise((resolve, reject) => {
72
- const child = spawn(editor, ['-projectPath', project], { detached: true, stdio: 'ignore', shell: false });
93
+ const env = {}, keys = new Set();
94
+ for (const [key, value] of Object.entries(process.env)) if (!keys.has(key.toLowerCase())) { keys.add(key.toLowerCase()); env[key] = value; }
95
+ const child = spawn(editor, ['-projectPath', project], { detached: true, stdio: 'ignore', shell: false, env });
73
96
  child.once('error', reject);
74
97
  child.once('spawn', () => { child.unref(); resolve(child.pid); });
75
98
  });
76
99
  }
77
100
  export function openProjectTool() {
78
- return { name: 'unity_open_project', description: 'Open a local Unity/Tuanjie project in its exact matching Windows editor, or report already open. Launching may import assets and execute project editor scripts. Does not save MCP configuration or prove MCP readiness.',
79
- parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute existing project directory.' }, editorPath: { type: 'string', description: 'Optional absolute matching Unity.exe/Tuanjie.exe; omit to search standard Hub install locations.' } }, required: ['projectPath'], additionalProperties: false },
80
- output: { schema: { type: 'object', properties: { status: { type: 'string' }, projectPath: { type: 'string' }, editorPath: { type: 'string' }, version: { type: 'string' }, pid: { type: 'integer' } }, required: ['status', 'projectPath', 'editorPath', 'version', 'pid'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] }, execute: input => openProject(input) };
101
+ return { name: 'unity_open_project', description: 'Open a Unity/Tuanjie project in its exact matching Windows editor and wait for an Editor readiness handshake, including when already running. May import assets and run project scripts; installs/updates the DSH Editor bridge. editor_loading means not ready: retry this tool to wait again, without relaunching. Does not configure or verify MCP.',
102
+ parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute existing project directory.' }, editorPath: { type: 'string', description: 'Optional absolute matching Unity.exe/Tuanjie.exe; omit to search standard Hub install locations.' }, timeoutMs: { type: 'integer', minimum: 1000, maximum: 180000, description: 'Editor readiness wait budget; default 120000 ms. Timeout returns editor_loading; retry to wait longer.' } }, required: ['projectPath'], additionalProperties: false },
103
+ output: { schema: { type: 'object', properties: { status: { type: 'string' }, projectPath: { type: 'string' }, editorPath: { type: 'string' }, version: { type: 'string' }, pid: { type: 'integer' }, editorReady: { type: 'boolean' }, message: { type: 'string' } }, required: ['status', 'projectPath', 'editorPath', 'version', 'pid', 'editorReady', 'message'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] }, execute: input => openProject(input) };
81
104
  }
@@ -0,0 +1,83 @@
1
+ import path from 'node:path';
2
+ import { readFile, writeFile, mkdir, stat, realpath, rename, unlink } from 'node:fs/promises';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { windowsProcesses, projectFromCommandLine } from './open-project.mjs';
6
+ const pending = new Set();
7
+ export async function projectRoot(value) {
8
+ if (!path.isAbsolute(value || '')) throw Error('projectPath must be absolute.');
9
+ const root = await realpath(value);
10
+ for (const dir of ['Assets', 'ProjectSettings']) if (!(await stat(path.join(root, dir))).isDirectory()) throw Error('Not a Unity/Tuanjie project: ' + dir);
11
+ return root;
12
+ }
13
+ export async function matchingProcess(root, processes = windowsProcesses) {
14
+ const rows = await processes();
15
+ const matches = [];
16
+ for (const row of Array.isArray(rows) ? rows : [rows]) {
17
+ const target = projectFromCommandLine(row?.CommandLine);
18
+ if (target && await realpath(target).then(p => p.toLowerCase() === root.toLowerCase(), () => false)) matches.push(row);
19
+ }
20
+ if (matches.length > 1) throw Error('Multiple editor processes match this project.');
21
+ return matches[0];
22
+ }
23
+ export async function ensureBridge(root) {
24
+ const source = await readFile(new URL('../engine/Editor/ProjectBridge.cs', import.meta.url), 'utf8');
25
+ const directory = path.join(root, 'Assets/Editor/DshUnityMcp');
26
+ await mkdir(directory, { recursive: true });
27
+ const target = path.join(directory, 'ProjectBridge.cs');
28
+ let current; try { current = await readFile(target, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
29
+ if (current && current !== source) {
30
+ const hash = createHash('sha256').update(current.replaceAll('\r\n', '\n')).digest('hex');
31
+ if (hash !== '19352eb60addf7ea12b9f5a55fbb1b5e3c33b273bfeb1fda7814cba1a3bd6915') throw Error('Existing DSH bridge differs. Review/remove it explicitly before installing this version.');
32
+ await writeFile(target + '.v0.0.4.backup', current, { flag: 'wx' });
33
+ if (await readFile(target, 'utf8') !== current) throw Error('Bridge changed during upgrade; retry.');
34
+ await writeFile(target, source);
35
+ } else if (current !== source) await writeFile(target, source, { flag: 'wx' });
36
+ }
37
+ export async function requestProject(input, action, deps = {}) {
38
+ if ((deps.platform ?? process.platform) !== 'win32') throw Error('Project lifecycle tools currently support Windows only.');
39
+ const root = await projectRoot(input.projectPath);
40
+ if (pending.has(root)) throw Error('A lifecycle request for this project is in progress.');
41
+ pending.add(root);
42
+ const processes = deps.processes ?? windowsProcesses;
43
+ let request;
44
+ try {
45
+ const editor = await matchingProcess(root, processes);
46
+ if (!editor) throw Error('No running editor for the requested project.');
47
+ await ensureBridge(root);
48
+ const directory = path.join(root, 'Library/DshUnityMcp'); await mkdir(directory, { recursive: true });
49
+ const id = randomUUID().replaceAll('-', ''), timeout = input.timeoutMs ?? 60000;
50
+ request = path.join(directory, id + '.request.json');
51
+ const response = path.join(directory, id + '.response.json');
52
+ await writeFile(request + '.tmp', JSON.stringify({ id, action, expires: Date.now() + timeout }));
53
+ await rename(request + '.tmp', request);
54
+ const deadline = Date.now() + timeout;
55
+ while (Date.now() < deadline) {
56
+ let reply;
57
+ try { reply = JSON.parse(await readFile(response, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
58
+ if (reply) {
59
+ await unlink(response);
60
+ if (reply.id !== id || reply.pid !== editor.ProcessId) throw Error('Editor response identity mismatch.');
61
+ if (!reply.success) throw Error(reply.message);
62
+ if (action === 'close') {
63
+ while (Date.now() < deadline) {
64
+ const active = await matchingProcess(root, processes);
65
+ if (!active) return { status: 'closed', projectPath: root, pid: editor.ProcessId };
66
+ if (active.ProcessId !== editor.ProcessId) throw Error('Project reopened in a different process; not closing it.');
67
+ await delay(500);
68
+ }
69
+ throw Error('Save acknowledged but normal exit not confirmed. No force kill was attempted.');
70
+ }
71
+ return { status: action === 'save' ? 'saved' : action === 'ready' ? 'editor_ready' : 'enable_requested', projectPath: root, pid: editor.ProcessId };
72
+ }
73
+ await delay(250);
74
+ }
75
+ throw Error('Editor response timed out. Operation may have begun; inspect the editor before retrying. No force kill was attempted.');
76
+ } finally { if (request) await unlink(request).catch(() => {}); pending.delete(root); }
77
+ }
78
+ export function lifecycleTools() {
79
+ return ['save', 'close'].map(action => ({ name: `unity_${action}_project`,
80
+ description: action === 'save' ? 'Save all named loaded scenes and dirty assets in a running Windows Unity/Tuanjie project. Installs a small Editor bridge in Assets/Editor/DshUnityMcp (may trigger compilation); no existing MCP connection required. Rejects Play mode, Prefab Mode and unnamed scenes.' : 'Save scenes/assets then request normal exit for the specified Windows Unity/Tuanjie project and verify process exit. Installs an Editor bridge if absent. Save failure prevents exit; never force-kills or discards changes. Timeout is not proof of exit.',
81
+ parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute running project directory.' }, timeoutMs: { type: 'integer', minimum: 1000, maximum: 180000, description: 'Wait budget including bridge compilation and exit; default 60000 ms.' } }, required: ['projectPath'], additionalProperties: false },
82
+ output: { schema: { type: 'object', properties: { status: { type: 'string' }, projectPath: { type: 'string' }, pid: { type: 'integer' } }, required: ['status', 'projectPath', 'pid'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] }, execute: input => requestProject(input, action) }));
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-unity-mcp",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Unity and Tuanjie editor tools for DSH, powered by CoderGamester/mcp-unity with per-agent lazy loading.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "index.mjs",
27
27
  "lib",
28
+ "engine",
28
29
  "cordis.patch.yml",
29
30
  "assets",
30
31
  "locale",
@@ -38,7 +39,7 @@
38
39
  }
39
40
  },
40
41
  "scripts": {
41
- "check": "node --check index.mjs && node --check lib/connection.mjs && node --check lib/lazy-tools.mjs && node --check lib/configure-project.mjs && node --check lib/open-project.mjs",
42
+ "check": "node --check index.mjs && node --check lib/connection.mjs && node --check lib/lazy-tools.mjs && node --check lib/configure-project.mjs && node --check lib/open-project.mjs && node --check lib/project-lifecycle.mjs && node --check lib/install-codergamester.mjs",
42
43
  "test": "node --test test/*.test.mjs",
43
44
  "build": "npm run check",
44
45
  "pack:check": "npm pack --dry-run --json"