dsh-unity-mcp 0.0.4 → 0.0.6
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 +56 -62
- package/engine/Editor/ProjectBridge.cs +6 -1
- package/index.mjs +2 -3
- package/lib/install-codergamester.mjs +11 -3
- package/lib/lazy-tools.mjs +1 -1
- package/lib/open-project.mjs +26 -8
- package/lib/project-lifecycle.mjs +9 -10
- package/lib/project.mjs +64 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,98 +2,96 @@
|
|
|
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)
|
|
5
|
+
把 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 接入 DSH,让 Agent 操作 Unity / Tuanjie(团结引擎)工程。工具默认按 Agent 懒加载,平时只提供一个加载入口。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 能做什么
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**不依赖已连接的 Unity MCP:** 在 Windows 上查看已打开的工程及 DSH 配置的连接目标,按工程版本匹配并打开编辑器、等待工程就绪、准备 CoderGamester 安装、保存已加载场景和修改资源、先保存再安全关闭编辑器,以及保存 DSH 的工程连接配置。保存、关闭和就绪检查会向工程加入一个小型 Editor 桥接,仍需编辑器正常编译并运行。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**依赖引擎端 CoderGamester MCP:** 查询和编辑场景、GameObject、组件、材质与资源,查看控制台,以及调用上游提供的编辑器和测试功能。具体能力随上游版本变化;有工具清单不代表编辑器已经连通。
|
|
12
12
|
|
|
13
|
-
|
|
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`。需要自动准备引擎依赖时,使用下文的安装工具;它仍不会下载或构建 Node 服务器。
|
|
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`,以读取正确的项目端口和认证设置。
|
|
24
|
-
|
|
25
|
-
配置示例(请替换路径):
|
|
13
|
+
本项目是 DSH 适配插件,不是 Unity、团结引擎或 CoderGamester 官方产品。控制编辑器内容前,必须在目标工程中安装并启动 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity);可以自己安装,也可以让 Agent 准备安装。
|
|
26
14
|
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
"projectPath": "C:/Projects/MyUnityProject",
|
|
30
|
-
"serverPath": "C:/Tools/mcp-unity/Server~/build/index.js",
|
|
31
|
-
"lazyTools": true
|
|
32
|
-
}
|
|
33
|
-
```
|
|
15
|
+
## 安装 DSH 插件
|
|
34
16
|
|
|
35
|
-
|
|
17
|
+
需要 Node.js 22+、DSH `>=0.2.1-rc.3 <0.3.0`。在 DSH 插件管理中安装:
|
|
36
18
|
|
|
37
|
-
|
|
19
|
+
```text
|
|
20
|
+
dsh-unity-mcp
|
|
21
|
+
```
|
|
38
22
|
|
|
39
|
-
|
|
23
|
+
本地工程管理目前只支持 Windows。上游声明 Unity 2022.3+;本插件已在独立团结工程中验证,但不能保证所有 Unity / Tuanjie 版本都兼容。
|
|
40
24
|
|
|
41
|
-
|
|
25
|
+
## 连接你的工程
|
|
42
26
|
|
|
43
|
-
|
|
27
|
+
### 让 Agent 安装并连接
|
|
44
28
|
|
|
45
|
-
|
|
29
|
+
可以直接说:“打开 `C:/Projects/MyGame`,准备 CoderGamester,再连接这个工程。”
|
|
46
30
|
|
|
47
|
-
|
|
31
|
+
Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程和 DSH 配置的连接目标,再对明确的工程路径执行 `status`、`open`、`save` 或 `close`。`list` 只读,不会写入桥接或启动工程;工程路径无法读取的编辑器进程也会列出,并标为未知。`status` 会检查就绪但不启动编辑器,`open` 则在需要时按版本匹配并启动。
|
|
48
32
|
|
|
49
|
-
|
|
33
|
+
安装 CoderGamester 和保存 DSH 连接分别由独立工具处理,避免把打开工程、修改包和切换所有会话的连接混为一次操作。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
|
|
50
34
|
|
|
51
|
-
|
|
35
|
+
**打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 返回 `readiness_unconfirmed` 或 `open` 返回 `editor_loading` 时,应继续检查或等待,不要重复启动或假设工程已经加载完成。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
|
|
52
36
|
|
|
53
|
-
|
|
37
|
+
编辑器就绪仍不等于 MCP 连通。Package Manager 还需下载和编译引擎包,服务需要成功启动;Node 服务器缺少构建文件时,还需在其 `Server~` 目录执行 `npm install` 和 `npm run build`。安装工具不会代为下载或构建 Node 服务器,也不自动改变 DSH 连接。
|
|
54
38
|
|
|
55
|
-
|
|
39
|
+
最后让 Agent 保存工程连接配置,并做一次只读场景查询。只有查询返回目标工程的信息,才能确认这条连接可用。
|
|
56
40
|
|
|
57
|
-
|
|
41
|
+
### 自己在工程里安装
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
43
|
+
1. 在 Unity / Tuanjie 的 Package Manager 中选择 **Add package from git URL**,按上游安装说明添加:
|
|
44
|
+
```text
|
|
45
|
+
https://github.com/CoderGamester/mcp-unity.git
|
|
46
|
+
```
|
|
47
|
+
2. 等待包解析和编译,在 **Tools → MCP Unity → Server Window** 启动服务。找到对应的 `Server~/build/index.js`;缺少时在 `Server~` 中运行 `npm install` 和 `npm run build`。
|
|
48
|
+
3. 告诉 Agent:“把 Unity MCP 连接到 `C:/Projects/MyGame`。”也可以在 DSH 插件配置中手工填写 `projectPath`。自动发现失败时再指定已构建的 `serverPath`。
|
|
49
|
+
4. 做一次只读场景查询,核对工程和场景,而不只是确认工具已加载。
|
|
62
50
|
|
|
63
|
-
|
|
51
|
+
## 使用与安全边界
|
|
64
52
|
|
|
65
|
-
|
|
53
|
+
每个 Agent 初始只看到 `unity_load_tools`,调用后才获得本地工程管理和已发现的上游工具;其他 Agent 不受影响。未连接 MCP 时仍可加载本地功能。服务器重连、工具更新和移除会同步到已加载的 Agent。需要立即暴露全部工具时,可将 `lazyTools` 设为 `false`。
|
|
66
54
|
|
|
67
|
-
|
|
55
|
+
打开工程可能导入资源、运行工程内的编辑器脚本;安装包也会执行第三方代码。请只操作可信且已备份的工程。工具不会用不匹配的编辑器版本自动升级工程;自定义编辑器位置或多个匹配时,需要提供确切的 `editorPath`。
|
|
68
56
|
|
|
69
|
-
|
|
57
|
+
保存会处理所有已加载的具名场景和修改资源;未命名场景需先在编辑器中选择保存路径。Play Mode 和 Prefab Mode 会阻止保存与关闭。关闭只有在保存成功后才请求正常退出,并确认目标工程进程结束;不强杀,不提供丢弃修改选项。超时可能意味着操作已开始,重试前先检查编辑器。
|
|
70
58
|
|
|
71
|
-
|
|
59
|
+
就绪、保存、关闭和安装使用本插件分发的 Editor 桥接。首次使用会在 `Assets/Editor/DshUnityMcp` 写入脚本,可能触发编译;官方未改动的 0.0.4 桥接会备份后升级,自定义或未知内容不覆盖。安装修改包依赖或已有自动启动设置前也会保留 `.dsh-backup`。
|
|
72
60
|
|
|
73
|
-
|
|
61
|
+
**保存 DSH 连接配置会影响当前 profile 的所有会话。** 配置工具会校验路径,清空旧的 host、port、authTokenPath 覆盖项,再保存并重载插件。打开工程本身不会切换连接。多个工程同时运行时,要为它们安排不同的 MCP 端口,避免服务冲突。
|
|
74
62
|
|
|
75
63
|
## 配置
|
|
76
64
|
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"projectPath": "C:/Projects/MyGame",
|
|
68
|
+
"lazyTools": true
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
77
72
|
| 字段 | 默认值 | 用途 |
|
|
78
73
|
| --- | --- | --- |
|
|
79
|
-
| `projectPath` | 空 |
|
|
80
|
-
| `serverPath` | 空 |
|
|
81
|
-
| `lazyTools` | `true` | 按 Agent
|
|
74
|
+
| `projectPath` | 空 | 工程绝对路径;发现服务器并读取该工程设置 |
|
|
75
|
+
| `serverPath` | 空 | 已构建的上游 `Server~/build/index.js` 绝对路径;优先于自动发现 |
|
|
76
|
+
| `lazyTools` | `true` | 按 Agent 加载工具 |
|
|
82
77
|
| `host` | 空 | 覆盖 `UNITY_HOST`;留空沿用上游设置 |
|
|
83
78
|
| `port` | `0` | 覆盖 `UNITY_PORT`;0 沿用项目设置 |
|
|
84
|
-
| `authTokenPath` | 空 |
|
|
85
|
-
| `toolCallTimeoutMs` | `120000` |
|
|
79
|
+
| `authTokenPath` | 空 | 可选认证文件绝对路径;不在配置中存 token |
|
|
80
|
+
| `toolCallTimeoutMs` | `120000` | 上游 MCP 调用超时,1000–600000 毫秒;不控制本地工程打开的等待时间 |
|
|
86
81
|
|
|
87
|
-
|
|
82
|
+
插件从工程的 `Packages` 或 `Library/PackageCache` 发现服务器,兼容 `com.gamelovers.mcp-unity` 和 `com.codergamester.mcp-unity` 目录名。存在多个缓存版本时需明确指定 `serverPath`,也可以使用单独克隆并构建的上游服务器。
|
|
83
|
+
|
|
84
|
+
桥接进程使用工程的 `ProjectSettings/McpUnitySettings.json`。认证由上游控制,不要把 token 提交到 Git。已有其他 Unity MCP 连接时,避免重复注册相同的 `unity` 命名空间。
|
|
88
85
|
|
|
89
86
|
## 排障
|
|
90
87
|
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
- **
|
|
94
|
-
-
|
|
88
|
+
- **工程一直是 `editor_loading`**:检查编辑器中的包解析、编译错误、模态窗口或自动刷新;修复后继续等待。不要因为超时就再次启动编辑器。
|
|
89
|
+
- **安装等待失败**:运行中的工程尚未回应就绪检查,依赖未修改。先让工程完成加载;若修改过桥接源码,需人工处理冲突。
|
|
90
|
+
- **Built CoderGamester server not found**:确认引擎包已经安装且 Node 服务器已构建,或手工设置 `serverPath`。
|
|
91
|
+
- **Multiple cached CoderGamester servers found**:明确选择 `serverPath`。
|
|
92
|
+
- **端口占用、认证失败或工具调用失败**:检查目标工程的 Server Window、端口、认证文件和 DSH MCP 日志。安装准备成功、编辑器就绪、加载工具清单,都不能替代真实场景查询。
|
|
95
93
|
|
|
96
|
-
##
|
|
94
|
+
## 开发与来源
|
|
97
95
|
|
|
98
96
|
```sh
|
|
99
97
|
npm install
|
|
@@ -102,10 +100,6 @@ npm run build
|
|
|
102
100
|
npm run pack:check
|
|
103
101
|
```
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
## 来源与许可
|
|
108
|
-
|
|
109
|
-
本适配插件采用 MIT 许可证。实际 Unity MCP 服务来自 **CoderGamester/mcp-unity**,上游采用 MIT 许可证。本插件不打包上游引擎或服务器源码,也不代表 Unity Technologies、团结引擎或 CoderGamester 官方产品。请遵守上游安装要求及许可。
|
|
103
|
+
测试覆盖服务器发现、配置边界、就绪等待、安装保护、保存关闭和 Agent 隔离。上下文大小测试比较 UTF-8 schema 字节数,不把字节数当作模型 token 数。
|
|
110
104
|
|
|
111
|
-
|
|
105
|
+
本适配插件采用 MIT 许可证。上游 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 也采用 MIT 许可证;本包不分发上游引擎或服务器源码。图标由 Krea 2 Turbo 生成,不使用 Unity 官方标志。许可与素材说明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
|
@@ -12,18 +12,23 @@ public static class ProjectBridge {
|
|
|
12
12
|
[Serializable] class Reply { public string id; public bool success; public string message; public int pid; }
|
|
13
13
|
static readonly string Root = Path.GetFullPath(Path.Combine(Application.dataPath, "../Library/DshUnityMcp"));
|
|
14
14
|
static double next;
|
|
15
|
+
static double stableSince;
|
|
15
16
|
static ProjectBridge() { EditorApplication.update += Update; }
|
|
16
17
|
static void Update() {
|
|
17
|
-
if (EditorApplication.
|
|
18
|
+
if (EditorApplication.isCompiling || EditorApplication.isUpdating) { stableSince = 0; return; }
|
|
19
|
+
if (stableSince == 0) stableSince = EditorApplication.timeSinceStartup;
|
|
20
|
+
if (EditorApplication.timeSinceStartup < next) return;
|
|
18
21
|
next = EditorApplication.timeSinceStartup + .25;
|
|
19
22
|
if (!Directory.Exists(Root)) return;
|
|
20
23
|
foreach (var file in Directory.GetFiles(Root, "*.request.json")) {
|
|
21
24
|
Request request = null;
|
|
22
25
|
try {
|
|
23
26
|
request = JsonUtility.FromJson<Request>(File.ReadAllText(file));
|
|
27
|
+
if (request != null && request.action == "ready" && EditorApplication.timeSinceStartup - stableSince < 2) continue;
|
|
24
28
|
File.Delete(file);
|
|
25
29
|
if (request == null || !Guid.TryParseExact(request.id, "N", out _) || Path.GetFileName(file) != request.id + ".request.json") continue;
|
|
26
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; }
|
|
27
32
|
bool close = request.action == "close";
|
|
28
33
|
if (request.action == "save" || close) {
|
|
29
34
|
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Stop Play mode before saving or closing.");
|
package/index.mjs
CHANGED
|
@@ -2,8 +2,7 @@ import z from '@deepseek-ai/schemastery';
|
|
|
2
2
|
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
|
-
import {
|
|
6
|
-
import { lifecycleTools } from './lib/project-lifecycle.mjs';
|
|
5
|
+
import { projectTool } from './lib/project.mjs';
|
|
7
6
|
import { installationTool } from './lib/install-codergamester.mjs';
|
|
8
7
|
import { configurationTool } from './lib/configure-project.mjs';
|
|
9
8
|
export const name = 'dsh-unity-mcp';
|
|
@@ -19,7 +18,7 @@ export const Config = z.object({
|
|
|
19
18
|
});
|
|
20
19
|
export async function apply(ctx, config) {
|
|
21
20
|
// Install the loader even before configuration, so discovery explains setup.
|
|
22
|
-
installLazyTools(ctx, config.lazyTools, [
|
|
21
|
+
installLazyTools(ctx, config.lazyTools, [projectTool(ctx), installationTool(), configurationTool(ctx)]);
|
|
23
22
|
if (!config.projectPath && !config.serverPath) {
|
|
24
23
|
ctx.logger.info('Unity MCP: configure projectPath or serverPath to connect CoderGamester/mcp-unity.');
|
|
25
24
|
return;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { readFile, writeFile,
|
|
2
|
+
import { readFile, writeFile, copyFile, stat } from 'node:fs/promises';
|
|
3
3
|
import { projectRoot, ensureBridge, matchingProcess, requestProject } from './project-lifecycle.mjs';
|
|
4
4
|
import { resolveServer } from './connection.mjs';
|
|
5
|
+
import { launchPending, waitForEditor } from './open-project.mjs';
|
|
5
6
|
const PACKAGE = 'com.gamelovers.mcp-unity';
|
|
6
7
|
export const UPSTREAM = 'https://github.com/CoderGamester/mcp-unity.git#382a43a30f4dab4c7f02d770b4ec7d084813bbe4';
|
|
7
8
|
const installing = new Set();
|
|
@@ -11,6 +12,13 @@ export async function installCoderGamester(input, deps = {}) {
|
|
|
11
12
|
if (installing.has(root)) throw Error('Installation is in progress for this project.');
|
|
12
13
|
installing.add(root);
|
|
13
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_project action=status or open before retrying installation. ' + error.message); }
|
|
21
|
+
}
|
|
14
22
|
const manifest = path.join(root, 'Packages/manifest.json');
|
|
15
23
|
const original = await readFile(manifest, 'utf8');
|
|
16
24
|
const parsed = JSON.parse(original);
|
|
@@ -33,7 +41,7 @@ export async function installCoderGamester(input, deps = {}) {
|
|
|
33
41
|
await writeFile(settings, JSON.stringify(config, null, 2) + '\n');
|
|
34
42
|
let enableStatus = 'pending_editor_start';
|
|
35
43
|
if (await matchingProcess(root, deps.processes)) {
|
|
36
|
-
try { await (deps.request ?? requestProject)({ projectPath: root, timeoutMs:
|
|
44
|
+
try { await (deps.request ?? requestProject)({ projectPath: root, timeoutMs: Math.max(1, deadline - Date.now()) }, 'enable', deps); enableStatus = 'start_requested'; }
|
|
37
45
|
catch (error) { enableStatus = 'pending: ' + error.message; }
|
|
38
46
|
}
|
|
39
47
|
let serverPath = '';
|
|
@@ -43,7 +51,7 @@ export async function installCoderGamester(input, deps = {}) {
|
|
|
43
51
|
} finally { installing.delete(root); }
|
|
44
52
|
}
|
|
45
53
|
export function installationTool() {
|
|
46
|
-
return { name: 'unity_install_codergamester', description: 'Install pinned CoderGamester/mcp-unity
|
|
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.',
|
|
47
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 },
|
|
48
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) };
|
|
49
57
|
}
|
package/lib/lazy-tools.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export function installLazyTools(ctx, lazy = true, setupTools = []) {
|
|
|
32
32
|
states.set(agent, state);
|
|
33
33
|
if (lazy) state.disposers.push(agent.ctx.tools.register({
|
|
34
34
|
name: 'unity_load_tools',
|
|
35
|
-
description: 'Load Unity/Tuanjie tools for this agent
|
|
35
|
+
description: 'Load Unity/Tuanjie tools for this agent. Start with unity_project action=list to discover open projects and the configured DSH target; then inspect/open/save/close a chosen project. Local management and package installation work without MCP; scene/object/asset tools require CoderGamester in the selected engine. Loading changes no project or connection.',
|
|
36
36
|
parameters: empty,
|
|
37
37
|
output: { schema: { type: 'object', properties: { tools: { type: 'array', items: { type: 'string' } } }, required: ['tools'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
38
38
|
execute: async () => {
|
package/lib/open-project.mjs
CHANGED
|
@@ -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_project action=status or open 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,20 @@ 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
|
|
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
|
-
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) };
|
|
81
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readFile, writeFile, mkdir, stat, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
import { windowsProcesses, projectFromCommandLine } from './open-project.mjs';
|
|
6
6
|
const pending = new Set();
|
|
@@ -26,8 +26,13 @@ export async function ensureBridge(root) {
|
|
|
26
26
|
await mkdir(directory, { recursive: true });
|
|
27
27
|
const target = path.join(directory, 'ProjectBridge.cs');
|
|
28
28
|
let current; try { current = await readFile(target, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
29
|
-
if (current && current !== source)
|
|
30
|
-
|
|
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' });
|
|
31
36
|
}
|
|
32
37
|
export async function requestProject(input, action, deps = {}) {
|
|
33
38
|
if ((deps.platform ?? process.platform) !== 'win32') throw Error('Project lifecycle tools currently support Windows only.');
|
|
@@ -63,16 +68,10 @@ export async function requestProject(input, action, deps = {}) {
|
|
|
63
68
|
}
|
|
64
69
|
throw Error('Save acknowledged but normal exit not confirmed. No force kill was attempted.');
|
|
65
70
|
}
|
|
66
|
-
return { status: action === 'save' ? 'saved' : 'enable_requested', projectPath: root, pid: editor.ProcessId };
|
|
71
|
+
return { status: action === 'save' ? 'saved' : action === 'ready' ? 'editor_ready' : 'enable_requested', projectPath: root, pid: editor.ProcessId };
|
|
67
72
|
}
|
|
68
73
|
await delay(250);
|
|
69
74
|
}
|
|
70
75
|
throw Error('Editor response timed out. Operation may have begun; inspect the editor before retrying. No force kill was attempted.');
|
|
71
76
|
} finally { if (request) await unlink(request).catch(() => {}); pending.delete(root); }
|
|
72
77
|
}
|
|
73
|
-
export function lifecycleTools() {
|
|
74
|
-
return ['save', 'close'].map(action => ({ name: `unity_${action}_project`,
|
|
75
|
-
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.',
|
|
76
|
-
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 },
|
|
77
|
-
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) }));
|
|
78
|
-
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { windowsProcesses, projectFromCommandLine, openProject } from './open-project.mjs';
|
|
4
|
+
import { requestProject, projectRoot } from './project-lifecycle.mjs';
|
|
5
|
+
|
|
6
|
+
export function connectionTarget(ctx) {
|
|
7
|
+
const rows = ctx.configEditor.configuration().filter(row => row.entry.options.name === 'dsh-unity-mcp');
|
|
8
|
+
if (rows.length !== 1) return { status: 'ambiguous', projectPath: '', serverPath: '' };
|
|
9
|
+
const config = { ...rows[0].inherited, ...rows[0].override };
|
|
10
|
+
return { status: config.projectPath || config.serverPath ? 'configured_not_verified' : 'not_configured', projectPath: config.projectPath || '', serverPath: config.serverPath || '' };
|
|
11
|
+
}
|
|
12
|
+
export async function listProjects(target = {}, deps = {}) {
|
|
13
|
+
if ((deps.platform ?? process.platform) !== 'win32') throw Error('Project discovery currently supports Windows only.');
|
|
14
|
+
const processes = await (deps.processes ?? windowsProcesses)();
|
|
15
|
+
const canonical = async value => value ? realpath(value).catch(() => path.resolve(value)) : '';
|
|
16
|
+
const selected = (await canonical(target.projectPath)).toLowerCase();
|
|
17
|
+
const projects = [];
|
|
18
|
+
for (const row of Array.isArray(processes) ? processes : [processes]) {
|
|
19
|
+
if (!row) continue;
|
|
20
|
+
const raw = projectFromCommandLine(row.CommandLine);
|
|
21
|
+
const projectPath = await canonical(raw);
|
|
22
|
+
let version = '';
|
|
23
|
+
if (projectPath) version = /^m_EditorVersion:\s*(\S+)/m.exec(await readFile(path.join(projectPath, 'ProjectSettings/ProjectVersion.txt'), 'utf8').catch(() => ''))?.[1] || '';
|
|
24
|
+
projects.push({ pid: row.ProcessId, projectPath, editorPath: row.ExecutablePath || '', version, state: projectPath ? 'running_not_verified' : 'project_unknown', configuredTarget: Boolean(selected && selected === projectPath.toLowerCase()) });
|
|
25
|
+
}
|
|
26
|
+
projects.sort((a, b) => a.pid - b.pid);
|
|
27
|
+
return { projects: projects.slice(0, 100), total: projects.length, truncated: projects.length > 100, connection: target, message: 'Process inventory only; readiness and MCP connectivity are not verified. Unknown project paths may reflect inaccessible process arguments. Use status for a selected project. Connection settings affect all profile sessions.' };
|
|
28
|
+
}
|
|
29
|
+
export async function manageProject(input, target = {}, deps = {}) {
|
|
30
|
+
if (input.action === 'list') return listProjects(target, deps);
|
|
31
|
+
if (!input.projectPath) throw Error('projectPath is required for this action. Use action=list to discover open projects.');
|
|
32
|
+
if (input.action === 'open') return (deps.open ?? openProject)(input, deps);
|
|
33
|
+
if (input.action === 'save' || input.action === 'close') return (deps.request ?? requestProject)(input, input.action, deps);
|
|
34
|
+
if (input.action === 'status') {
|
|
35
|
+
const root = await projectRoot(input.projectPath);
|
|
36
|
+
const inventory = await listProjects(target, deps);
|
|
37
|
+
if (inventory.truncated) throw Error('Editor inventory exceeds 100 processes; status cannot safely select a project.');
|
|
38
|
+
const matches = inventory.projects.filter(p => p.projectPath.toLowerCase() === root.toLowerCase());
|
|
39
|
+
if (!matches.length) return { status: 'not_running', projectPath: root, editorReady: false, connection: target, message: 'No matching editor process found; MCP connectivity is not verified.' };
|
|
40
|
+
if (matches.length !== 1) throw Error('Multiple editor processes match this project. Resolve duplicates before acting.');
|
|
41
|
+
try {
|
|
42
|
+
await (deps.request ?? requestProject)({ ...input, timeoutMs: input.timeoutMs ?? 10000 }, 'ready', deps);
|
|
43
|
+
return { ...matches[0], state: 'editor_ready', status: 'editor_ready', editorReady: true, connection: target, message: 'Editor readiness confirmed; MCP connectivity is not verified.' };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { ...matches[0], status: 'readiness_unconfirmed', editorReady: false, connection: target, message: error.message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw Error('Unknown project action.');
|
|
49
|
+
}
|
|
50
|
+
export function projectTool(ctx) {
|
|
51
|
+
return { name: 'unity_project', description: 'Discover and manage Windows Unity/Tuanjie projects without MCP. list is read-only and shows all editor processes plus the configured DSH target; it does not prove readiness. status waits for readiness without launching; open launches if needed and waits. status/open/save/close may install an Editor bridge and trigger compilation. close saves first, verifies normal exit, never force-kills or discards changes. Save/close reject Play Mode, Prefab Mode and unnamed scenes. Does not switch or verify MCP connections.',
|
|
52
|
+
parameters: { type: 'object', properties: {
|
|
53
|
+
action: { type: 'string', enum: ['list', 'status', 'open', 'save', 'close'], description: 'Start with list to choose an explicit project. status checks readiness; open also starts a closed project; save saves all named loaded scenes/assets; close saves then exits.' },
|
|
54
|
+
projectPath: { type: 'string', description: 'Absolute project directory; required except for list. Never inferred from the current connection.' },
|
|
55
|
+
editorPath: { type: 'string', description: 'Only for open: optional absolute exact-version Unity.exe/Tuanjie.exe.' },
|
|
56
|
+
timeoutMs: { type: 'integer', minimum: 1000, maximum: 180000, description: 'Wait budget: status default 10000 ms, open 120000 ms, save/close 60000 ms. Retry status/open after an unconfirmed readiness result; do not assume it is ready.' }
|
|
57
|
+
}, required: ['action'], additionalProperties: false },
|
|
58
|
+
output: { schema: { type: 'object' }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
59
|
+
execute: input => {
|
|
60
|
+
if (input.action === 'list' && (input.projectPath || input.editorPath)) throw Error('list accepts no projectPath or editorPath.');
|
|
61
|
+
if (input.editorPath && input.action !== 'open') throw Error('editorPath is only used by open.');
|
|
62
|
+
return manageProject(input, connectionTarget(ctx));
|
|
63
|
+
} };
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unity-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
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
|
+
"check": "node --check index.mjs && node --check lib/project.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",
|
|
43
43
|
"test": "node --test test/*.test.mjs",
|
|
44
44
|
"build": "npm run check",
|
|
45
45
|
"pack:check": "npm pack --dry-run --json"
|