dsh-unity-mcp 0.0.2 → 0.0.4
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 +25 -3
- package/engine/Editor/ProjectBridge.cs +66 -0
- package/index.mjs +8 -2
- package/lib/install-codergamester.mjs +49 -0
- package/lib/lazy-tools.mjs +1 -1
- package/lib/open-project.mjs +81 -0
- package/lib/project-lifecycle.mjs +78 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
把 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 的 Unity / 团结引擎编辑器工具接入 DSH,默认按 Agent 懒加载。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**控制编辑器前,必须在 Unity 或 Tuanjie(团结引擎)项目中安装 CoderGamester 的 MCP Unity Server,并启动引擎端服务。** 0.0.4 起可让 Agent 用 `unity_install_codergamester` 准备安装,也可手动安装。本项目是 DSH 适配插件;上游编辑器工具由 [CoderGamester/mcp-unity](https://github.com/CoderGamester/mcp-unity) 提供,保存与关闭使用本插件附带的独立 Editor 桥接。
|
|
8
8
|
|
|
9
9
|
## 安装与连接
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
```text
|
|
15
15
|
https://github.com/CoderGamester/mcp-unity.git
|
|
16
16
|
```
|
|
17
|
-
2. 打开 **Tools → MCP Unity → Server Window**,按照上游说明启动服务。找到该项目安装的 `Server~/build/index.js`。如果没有构建文件,在对应的 `Server~` 目录运行 `npm install` 和 `npm run build
|
|
17
|
+
2. 打开 **Tools → MCP Unity → Server Window**,按照上游说明启动服务。找到该项目安装的 `Server~/build/index.js`。如果没有构建文件,在对应的 `Server~` 目录运行 `npm install` 和 `npm run build`。需要自动准备引擎依赖时,使用下文的安装工具;它仍不会下载或构建 Node 服务器。
|
|
18
18
|
3. 在 DSH 插件管理中安装 npm 包:
|
|
19
19
|
```text
|
|
20
20
|
dsh-unity-mcp
|
|
@@ -42,9 +42,31 @@
|
|
|
42
42
|
|
|
43
43
|
工具不会安装引擎包、构建服务器或启动编辑器。也可以继续在插件配置中手工设置路径。
|
|
44
44
|
|
|
45
|
+
## 打开工程(0.0.3 起,Windows)
|
|
46
|
+
|
|
47
|
+
调用 `unity_load_tools` 后可使用 `unity_open_project`,传入 `projectPath`。不依赖引擎端 MCP 在线,也不要求工程已经安装 MCP 包。
|
|
48
|
+
|
|
49
|
+
工具读取 `ProjectSettings/ProjectVersion.txt`,在标准 Unity / Tuanjie Hub 安装目录中寻找 ProductVersion 完全匹配的编辑器(允许版本后的 `_revision`)。自定义安装位置或多个匹配时,需传入 `editorPath`,指向对应的 `Unity.exe` / `Tuanjie.exe`。不自动安装编辑器,不允许用不同版本升级工程。当前不支持 macOS / Linux。
|
|
50
|
+
|
|
51
|
+
已检测到该工程的编辑器进程时返回 `already_open`。存在工程锁但无法确认进程时拒绝启动,不删除锁;同一进程内的并发请求和 60 秒内重复启动也会被拦截。其他 DSH 进程或外部程序同时启动时,仍需依靠编辑器自己的工程锁。
|
|
52
|
+
|
|
53
|
+
**打开工程可能触发资源导入和工程内编辑器脚本。请只打开可信工程。** 返回 `launch_requested` 仅表示系统接受启动请求,不表示编辑器已加载完成或 MCP 已连接。此工具不修改 DSH 连接配置;随后仍需用 `unity_configure_project` 配置连接,确认引擎服务启动后做只读查询。
|
|
54
|
+
|
|
55
|
+
## 保存、关闭与安装(0.0.4 起,Windows)
|
|
56
|
+
|
|
57
|
+
这些工具都不要求已有 MCP 连接。首次调用会在目标工程的 `Assets/Editor/DshUnityMcp/ProjectBridge.cs` 写入本插件的 Editor 桥接,可能触发编译;同路径已有不同内容时拒绝覆盖。请只在可信且已备份的工程中操作。
|
|
58
|
+
|
|
59
|
+
- `unity_save_project`:保存所有已加载且有路径的场景及修改过的资源。Play Mode、Prefab Mode、未命名场景会阻止操作;未命名场景需先在编辑器中选择保存路径。工具不替用户命名或另存。
|
|
60
|
+
- `unity_close_project`:先执行上述保存,成功后请求编辑器正常退出,并检查指定工程的进程已退出。保存失败不关闭;不强杀、不提供丢弃修改选项。超时不代表已经关闭,重试前检查编辑器。
|
|
61
|
+
- `unity_install_codergamester`:向 `Packages/manifest.json` 加入固定到上游 commit `382a43a30f4dab4c7f02d770b4ec7d084813bbe4` 的依赖,设置 `AutoStartServer` 并通过桥接请求启动。修改前保留 `.dsh-backup`,保留其他依赖;已有不同的 CoderGamester 依赖时拒绝替换。它不打开或关闭编辑器,不更改 DSH 连接,也不下载或构建 Node 服务器。
|
|
62
|
+
|
|
63
|
+
安装工具返回的 `pending_editor_start`、`start_requested` 只描述准备和启动请求;`ready` 始终为 `false`,不能当作已连接。随后打开或刷新编辑器,等待 Package Manager 解析、编译,构建缺失的 `Server~`,再用 `unity_configure_project` 配置连接并做只读查询。网络、Git、包解析或编译失败需在编辑器中处理;工具不会自动移除其他 MCP 包。
|
|
64
|
+
|
|
65
|
+
独立团结测试工程已验证场景修改写入磁盘、保存后正常关闭。安装依赖与实际引擎服务连接分开验收;其他 Unity / Tuanjie 版本仍需自行验证。
|
|
66
|
+
|
|
45
67
|
## 工具懒加载
|
|
46
68
|
|
|
47
|
-
每个 Agent 初始只看到 `unity_load_tools
|
|
69
|
+
每个 Agent 初始只看到 `unity_load_tools`。调用后获得 `unity_configure_project`、`unity_open_project`、`unity_save_project`、`unity_close_project`、`unity_install_codergamester`,并加载当前服务器提供的 `mcp__unity__*` 工具;未连接时也有这五个本地工具。其他 Agent 仍保持未加载。场景、GameObject、组件、材质、资源、控制台和测试等工具的参数与结果沿用上游,插件不维护另一套工具清单。
|
|
48
70
|
|
|
49
71
|
加载动作不会修改工程。**加载后的编辑器操作可能创建、删除资源,修改场景、执行菜单或安装包。请先提交或备份工程,并检查每次调用的范围。** 如需立即暴露全部工具,把 `lazyTools` 设为 `false`。实际工具数量随上游版本变化。
|
|
50
72
|
|
|
@@ -0,0 +1,66 @@
|
|
|
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 ProjectBridge() { EditorApplication.update += Update; }
|
|
16
|
+
static void Update() {
|
|
17
|
+
if (EditorApplication.timeSinceStartup < next || EditorApplication.isCompiling || EditorApplication.isUpdating) return;
|
|
18
|
+
next = EditorApplication.timeSinceStartup + .25;
|
|
19
|
+
if (!Directory.Exists(Root)) return;
|
|
20
|
+
foreach (var file in Directory.GetFiles(Root, "*.request.json")) {
|
|
21
|
+
Request request = null;
|
|
22
|
+
try {
|
|
23
|
+
request = JsonUtility.FromJson<Request>(File.ReadAllText(file));
|
|
24
|
+
File.Delete(file);
|
|
25
|
+
if (request == null || !Guid.TryParseExact(request.id, "N", out _) || Path.GetFileName(file) != request.id + ".request.json") continue;
|
|
26
|
+
if (request.expires < DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) continue;
|
|
27
|
+
bool close = request.action == "close";
|
|
28
|
+
if (request.action == "save" || close) {
|
|
29
|
+
if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Stop Play mode before saving or closing.");
|
|
30
|
+
for (int i = 0; i < EditorSceneManager.sceneCount; i++) {
|
|
31
|
+
var scene = EditorSceneManager.GetSceneAt(i);
|
|
32
|
+
if (scene.isLoaded && string.IsNullOrEmpty(scene.path)) throw new Exception("Unnamed scene: save it to an explicit path in the editor first.");
|
|
33
|
+
}
|
|
34
|
+
if (PrefabStageUtility.GetCurrentPrefabStage() != null) throw new Exception("Exit Prefab Mode before saving or closing.");
|
|
35
|
+
if (!EditorSceneManager.SaveOpenScenes()) throw new Exception("Saving scenes failed; editor remains open.");
|
|
36
|
+
AssetDatabase.SaveAssets();
|
|
37
|
+
foreach (var asset in Resources.FindObjectsOfTypeAll<UnityEngine.Object>())
|
|
38
|
+
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.");
|
|
39
|
+
for (int i = 0; i < EditorSceneManager.sceneCount; i++) if (EditorSceneManager.GetSceneAt(i).isDirty) throw new Exception("A scene remains dirty; editor remains open.");
|
|
40
|
+
} else if (request.action == "enable") {
|
|
41
|
+
Type server = null;
|
|
42
|
+
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { server = assembly.GetType("McpUnity.Unity.McpUnityServer"); if (server != null) break; }
|
|
43
|
+
if (server == null) throw new Exception("CoderGamester is not compiled yet.");
|
|
44
|
+
var settings = server.Assembly.GetType("McpUnity.Unity.McpUnitySettings");
|
|
45
|
+
var settingsInstance = settings.GetProperty("Instance").GetValue(null);
|
|
46
|
+
settings.GetMethod("LoadSettings").Invoke(settingsInstance, null);
|
|
47
|
+
var instance = server.GetProperty("Instance").GetValue(null);
|
|
48
|
+
server.GetMethod("StartServer").Invoke(instance, null);
|
|
49
|
+
} else throw new Exception("Unsupported request.");
|
|
50
|
+
Write(request.id, true, close ? "Saved; normal exit requested." : "Operation completed.");
|
|
51
|
+
if (close) { EditorApplication.delayCall += () => {
|
|
52
|
+
if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling) return;
|
|
53
|
+
for (int i = 0; i < EditorSceneManager.sceneCount; i++) if (EditorSceneManager.GetSceneAt(i).isDirty) return;
|
|
54
|
+
AssetDatabase.SaveAssets();
|
|
55
|
+
EditorApplication.Exit(0);
|
|
56
|
+
}; return; }
|
|
57
|
+
} catch (Exception error) { if (request != null && Guid.TryParseExact(request.id, "N", out _)) Write(request.id, false, error.Message); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
static void Write(string id, bool success, string message) {
|
|
61
|
+
var target = Path.Combine(Root, id + ".response.json");
|
|
62
|
+
File.WriteAllText(target + ".tmp", JsonUtility.ToJson(new Reply { id = id, success = success, message = message, pid = System.Diagnostics.Process.GetCurrentProcess().Id }));
|
|
63
|
+
File.Move(target + ".tmp", target);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/index.mjs
CHANGED
|
@@ -2,6 +2,9 @@ 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 { openProjectTool } from './lib/open-project.mjs';
|
|
6
|
+
import { lifecycleTools } from './lib/project-lifecycle.mjs';
|
|
7
|
+
import { installationTool } from './lib/install-codergamester.mjs';
|
|
5
8
|
import { configurationTool } from './lib/configure-project.mjs';
|
|
6
9
|
export const name = 'dsh-unity-mcp';
|
|
7
10
|
export const inject = ['tools', 'agents', 'configEditor'];
|
|
@@ -16,10 +19,13 @@ export const Config = z.object({
|
|
|
16
19
|
});
|
|
17
20
|
export async function apply(ctx, config) {
|
|
18
21
|
// Install the loader even before configuration, so discovery explains setup.
|
|
19
|
-
installLazyTools(ctx, config.lazyTools, [configurationTool(ctx)]);
|
|
22
|
+
installLazyTools(ctx, config.lazyTools, [configurationTool(ctx), openProjectTool(), ...lifecycleTools(), installationTool()]);
|
|
20
23
|
if (!config.projectPath && !config.serverPath) {
|
|
21
24
|
ctx.logger.info('Unity MCP: configure projectPath or serverPath to connect CoderGamester/mcp-unity.');
|
|
22
25
|
return;
|
|
23
26
|
}
|
|
24
|
-
|
|
27
|
+
let connection;
|
|
28
|
+
try { connection = await connectionConfig(config); }
|
|
29
|
+
catch (error) { ctx.logger.warn('Unity MCP connection needs configuration: ' + error.message); return; }
|
|
30
|
+
await mcp.apply(ctx, mcp.Config(connection));
|
|
25
31
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, writeFile, mkdir, copyFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { projectRoot, ensureBridge, matchingProcess, requestProject } from './project-lifecycle.mjs';
|
|
4
|
+
import { resolveServer } from './connection.mjs';
|
|
5
|
+
const PACKAGE = 'com.gamelovers.mcp-unity';
|
|
6
|
+
export const UPSTREAM = 'https://github.com/CoderGamester/mcp-unity.git#382a43a30f4dab4c7f02d770b4ec7d084813bbe4';
|
|
7
|
+
const installing = new Set();
|
|
8
|
+
export async function installCoderGamester(input, deps = {}) {
|
|
9
|
+
if ((deps.platform ?? process.platform) !== 'win32') throw Error('Installation tool currently supports Windows only.');
|
|
10
|
+
const root = await projectRoot(input.projectPath);
|
|
11
|
+
if (installing.has(root)) throw Error('Installation is in progress for this project.');
|
|
12
|
+
installing.add(root);
|
|
13
|
+
try {
|
|
14
|
+
const manifest = path.join(root, 'Packages/manifest.json');
|
|
15
|
+
const original = await readFile(manifest, 'utf8');
|
|
16
|
+
const parsed = JSON.parse(original);
|
|
17
|
+
if (!parsed.dependencies || typeof parsed.dependencies !== 'object' || Array.isArray(parsed.dependencies)) throw Error('Invalid package manifest dependencies.');
|
|
18
|
+
const current = parsed.dependencies[PACKAGE];
|
|
19
|
+
if (current && current !== UPSTREAM) throw Error('CoderGamester already has a different dependency. Refusing to replace it: ' + current);
|
|
20
|
+
await ensureBridge(root);
|
|
21
|
+
const settings = path.join(root, 'ProjectSettings/McpUnitySettings.json');
|
|
22
|
+
let text; try { text = await readFile(settings, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
23
|
+
const config = text ? JSON.parse(text) : {};
|
|
24
|
+
config.AutoStartServer = true;
|
|
25
|
+
if (text && JSON.parse(text).AutoStartServer !== true) await copyFile(settings, settings + '.dsh-backup', 1);
|
|
26
|
+
if (!current) {
|
|
27
|
+
const backup = manifest + '.dsh-backup';
|
|
28
|
+
await copyFile(manifest, backup, 1);
|
|
29
|
+
if (await readFile(manifest, 'utf8') !== original) throw Error('Manifest changed during preparation; retry.');
|
|
30
|
+
parsed.dependencies[PACKAGE] = UPSTREAM;
|
|
31
|
+
await writeFile(manifest, JSON.stringify(parsed, null, 2) + '\n');
|
|
32
|
+
}
|
|
33
|
+
await writeFile(settings, JSON.stringify(config, null, 2) + '\n');
|
|
34
|
+
let enableStatus = 'pending_editor_start';
|
|
35
|
+
if (await matchingProcess(root, deps.processes)) {
|
|
36
|
+
try { await (deps.request ?? requestProject)({ projectPath: root, timeoutMs: input.timeoutMs ?? 120000 }, 'enable', deps); enableStatus = 'start_requested'; }
|
|
37
|
+
catch (error) { enableStatus = 'pending: ' + error.message; }
|
|
38
|
+
}
|
|
39
|
+
let serverPath = '';
|
|
40
|
+
try { serverPath = await resolveServer({ projectPath: root }); } catch {}
|
|
41
|
+
return { projectPath: root, dependency: UPSTREAM, dependencyAdded: !current, enableStatus, serverPath, ready: false,
|
|
42
|
+
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.' };
|
|
43
|
+
} finally { installing.delete(root); }
|
|
44
|
+
}
|
|
45
|
+
export function installationTool() {
|
|
46
|
+
return { name: 'unity_install_codergamester', description: 'Install pinned CoderGamester/mcp-unity dependency and DSH Editor bridge into a Windows Unity/Tuanjie project without an existing MCP connection; enable auto-start and request start if open. Changes Packages/manifest.json, project settings and Assets/Editor; editor package resolution executes third-party code. Preserves other packages; refuses replacing a different existing CoderGamester dependency. Does not download/build the Node server, launch/close editors or change DSH connection. Returns pending states, not proof of connectivity.',
|
|
47
|
+
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
|
+
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
|
+
}
|
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, including
|
|
35
|
+
description: 'Load Unity/Tuanjie tools for this agent, including opening projects and configuring connections even when disconnected. Editor tools cover scenes, GameObjects, assets and console; they require CoderGamester/mcp-unity in the engine. Loading itself changes no configuration or project.',
|
|
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 () => {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, stat, realpath, readdir } from 'node:fs/promises';
|
|
3
|
+
import { execFile, spawn } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
const recentLaunches = new Map();
|
|
8
|
+
async function powershell(script) {
|
|
9
|
+
const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
10
|
+
return JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || 'null');
|
|
11
|
+
}
|
|
12
|
+
export function projectFromCommandLine(command = '') {
|
|
13
|
+
return /(?:^|\s)-projectpath\s+(?:"([^"]+)"|([^\s]+))/i.exec(command)?.slice(1).find(Boolean);
|
|
14
|
+
}
|
|
15
|
+
export async function windowsProcesses() {
|
|
16
|
+
return await powershell("@(Get-CimInstance Win32_Process -Filter \"Name='Unity.exe' OR Name='Tuanjie.exe'\" | Select-Object ProcessId,ExecutablePath,CommandLine) | ConvertTo-Json -Compress") ?? [];
|
|
17
|
+
}
|
|
18
|
+
async function editorVersion(file) {
|
|
19
|
+
const quoted = file.replaceAll("'", "''");
|
|
20
|
+
return powershell(`(Get-Item -LiteralPath '${quoted}').VersionInfo.ProductVersion | ConvertTo-Json -Compress`);
|
|
21
|
+
}
|
|
22
|
+
async function discoverEditors() {
|
|
23
|
+
const roots = [path.join(process.env.ProgramFiles || 'C:/Program Files', 'Unity/Hub/Editor'), path.join(process.env.ProgramFiles || 'C:/Program Files', 'Tuanjie/Hub/Editor')];
|
|
24
|
+
const candidates = [];
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
let entries; try { entries = await readdir(root); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
27
|
+
for (const entry of entries) for (const name of ['Unity.exe', 'Tuanjie.exe']) candidates.push(path.join(root, entry, 'Editor', name));
|
|
28
|
+
for (const name of ['Unity.exe', 'Tuanjie.exe']) candidates.push(path.join(root, name));
|
|
29
|
+
}
|
|
30
|
+
return candidates;
|
|
31
|
+
}
|
|
32
|
+
export async function openProject(input, deps = {}) {
|
|
33
|
+
const { platform = process.platform, processes = windowsProcesses, versionOf = editorVersion, discover = discoverEditors, launch = launchEditor } = deps;
|
|
34
|
+
if (platform !== 'win32') throw Error('unity_open_project currently supports Windows only.');
|
|
35
|
+
if (!path.isAbsolute(input.projectPath || '')) throw Error('projectPath must be absolute.');
|
|
36
|
+
const projectPath = await realpath(input.projectPath);
|
|
37
|
+
for (const dir of ['Assets', 'ProjectSettings']) if (!(await stat(path.join(projectPath, dir))).isDirectory()) throw Error('Not a Unity/Tuanjie project: ' + dir);
|
|
38
|
+
const text = await readFile(path.join(projectPath, 'ProjectSettings/ProjectVersion.txt'), 'utf8');
|
|
39
|
+
const version = /^m_EditorVersion:\s*(\S+)/m.exec(text)?.[1];
|
|
40
|
+
if (!version || !/^\d+\.\d+\.\d+[a-z]\d+$/i.test(version)) throw Error('Cannot determine exact editor version from ProjectVersion.txt.');
|
|
41
|
+
const key = projectPath.toLowerCase();
|
|
42
|
+
if (pending.has(key)) throw Error('An open request for this project is already in progress.');
|
|
43
|
+
pending.add(key);
|
|
44
|
+
try {
|
|
45
|
+
const rows = await processes();
|
|
46
|
+
for (const row of Array.isArray(rows) ? rows : [rows]) {
|
|
47
|
+
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 };
|
|
49
|
+
}
|
|
50
|
+
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
|
+
const lock = await stat(path.join(projectPath, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
52
|
+
if (lock) throw Error('UnityLockfile exists but no matching editor could be confirmed. Refusing duplicate launch; check the editor/lock manually.');
|
|
53
|
+
const explicit = input.editorPath;
|
|
54
|
+
if (explicit && !path.isAbsolute(explicit)) throw Error('editorPath must be absolute.');
|
|
55
|
+
const matches = [];
|
|
56
|
+
for (const candidate of explicit ? [explicit] : await discover()) {
|
|
57
|
+
if (!/^(Unity|Tuanjie)\.exe$/i.test(path.basename(candidate))) continue;
|
|
58
|
+
let info; try { info = await stat(candidate); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
59
|
+
if (!info.isFile()) continue;
|
|
60
|
+
const actual = await versionOf(candidate);
|
|
61
|
+
if (typeof actual === 'string' && (actual === version || actual.startsWith(version + '_'))) matches.push(candidate);
|
|
62
|
+
}
|
|
63
|
+
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.`);
|
|
64
|
+
const pid = await launch(matches[0], projectPath);
|
|
65
|
+
for (const [name, time] of recentLaunches) if (Date.now() - time >= 60000) recentLaunches.delete(name);
|
|
66
|
+
recentLaunches.set(key, Date.now());
|
|
67
|
+
return { status: 'launch_requested', projectPath, editorPath: matches[0], version, pid };
|
|
68
|
+
} finally { pending.delete(key); }
|
|
69
|
+
}
|
|
70
|
+
async function launchEditor(editor, project) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const child = spawn(editor, ['-projectPath', project], { detached: true, stdio: 'ignore', shell: false });
|
|
73
|
+
child.once('error', reject);
|
|
74
|
+
child.once('spawn', () => { child.unref(); resolve(child.pid); });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile, writeFile, mkdir, stat, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } 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) throw Error('Existing DSH bridge differs. Review/remove it explicitly before installing this version.');
|
|
30
|
+
if (current !== source) await writeFile(target, source, { flag: 'wx' });
|
|
31
|
+
}
|
|
32
|
+
export async function requestProject(input, action, deps = {}) {
|
|
33
|
+
if ((deps.platform ?? process.platform) !== 'win32') throw Error('Project lifecycle tools currently support Windows only.');
|
|
34
|
+
const root = await projectRoot(input.projectPath);
|
|
35
|
+
if (pending.has(root)) throw Error('A lifecycle request for this project is in progress.');
|
|
36
|
+
pending.add(root);
|
|
37
|
+
const processes = deps.processes ?? windowsProcesses;
|
|
38
|
+
let request;
|
|
39
|
+
try {
|
|
40
|
+
const editor = await matchingProcess(root, processes);
|
|
41
|
+
if (!editor) throw Error('No running editor for the requested project.');
|
|
42
|
+
await ensureBridge(root);
|
|
43
|
+
const directory = path.join(root, 'Library/DshUnityMcp'); await mkdir(directory, { recursive: true });
|
|
44
|
+
const id = randomUUID().replaceAll('-', ''), timeout = input.timeoutMs ?? 60000;
|
|
45
|
+
request = path.join(directory, id + '.request.json');
|
|
46
|
+
const response = path.join(directory, id + '.response.json');
|
|
47
|
+
await writeFile(request + '.tmp', JSON.stringify({ id, action, expires: Date.now() + timeout }));
|
|
48
|
+
await rename(request + '.tmp', request);
|
|
49
|
+
const deadline = Date.now() + timeout;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
let reply;
|
|
52
|
+
try { reply = JSON.parse(await readFile(response, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
53
|
+
if (reply) {
|
|
54
|
+
await unlink(response);
|
|
55
|
+
if (reply.id !== id || reply.pid !== editor.ProcessId) throw Error('Editor response identity mismatch.');
|
|
56
|
+
if (!reply.success) throw Error(reply.message);
|
|
57
|
+
if (action === 'close') {
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
const active = await matchingProcess(root, processes);
|
|
60
|
+
if (!active) return { status: 'closed', projectPath: root, pid: editor.ProcessId };
|
|
61
|
+
if (active.ProcessId !== editor.ProcessId) throw Error('Project reopened in a different process; not closing it.');
|
|
62
|
+
await delay(500);
|
|
63
|
+
}
|
|
64
|
+
throw Error('Save acknowledged but normal exit not confirmed. No force kill was attempted.');
|
|
65
|
+
}
|
|
66
|
+
return { status: action === 'save' ? 'saved' : 'enable_requested', projectPath: root, pid: editor.ProcessId };
|
|
67
|
+
}
|
|
68
|
+
await delay(250);
|
|
69
|
+
}
|
|
70
|
+
throw Error('Editor response timed out. Operation may have begun; inspect the editor before retrying. No force kill was attempted.');
|
|
71
|
+
} finally { if (request) await unlink(request).catch(() => {}); pending.delete(root); }
|
|
72
|
+
}
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unity-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
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",
|
|
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"
|