dsh-unity-mcp 0.0.7 → 0.0.9
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 +3 -2
- package/index.mjs +3 -2
- package/lib/capture-window.mjs +35 -0
- package/lib/capture-window.ps1 +40 -0
- package/lib/lazy-tools.mjs +15 -0
- package/lib/open-project.mjs +5 -3
- package/lib/project.mjs +5 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程
|
|
|
32
32
|
|
|
33
33
|
安装 CoderGamester 和保存 DSH 连接分别由独立工具处理,避免把打开工程、修改包和切换所有会话的连接混为一次操作。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
|
|
34
34
|
|
|
35
|
-
**打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 返回 `readiness_unconfirmed`
|
|
35
|
+
**打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 或 `open` 返回 `readiness_unconfirmed` 时,`running: true` 表示已发现该工程进程,`editorReady: null` 表示未能确认就绪,不能据此断定仍在加载。`process_unconfirmed` 表示部分进程的工程路径不可读,不能确认该工程是否运行。不要重复启动;可先截图查看编辑器。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
|
|
36
36
|
|
|
37
37
|
编辑器就绪仍不等于 MCP 连通。Package Manager 还需下载和编译引擎包,服务需要成功启动;Node 服务器缺少构建文件时,还需在其 `Server~` 目录执行 `npm install` 和 `npm run build`。安装工具不会代为下载或构建 Node 服务器,也不自动改变 DSH 连接。
|
|
38
38
|
|
|
@@ -91,7 +91,8 @@ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程
|
|
|
91
91
|
|
|
92
92
|
## 排障
|
|
93
93
|
|
|
94
|
-
-
|
|
94
|
+
- **工程就绪检查一直未确认**:进程已发现时不会据此报为“仍在加载”。用 `unity_capture_window` 并指定 `projectPath` 查看编辑器面板,再检查包解析、编译错误、模态窗口或自动刷新;不要因为超时再次启动编辑器。
|
|
95
|
+
- **查看编辑器窗口**:懒加载后可调用 `unity_capture_window({projectPath: "C:/Projects/MyGame"})`,不依赖 MCP 或 Editor 桥接。仅支持 Windows,返回指定工程主窗口的图片附件,不抓桌面、不切换焦点、不还原最小化窗口。最小化、窗口不可用会报错;GPU 视图可能黑屏或未刷新,主窗口截图也不保证包含独立弹窗。截图可能包含工程中的敏感内容。工具仅为截图 PowerShell 子进程指定 `-ExecutionPolicy Bypass`,不修改系统或用户策略。
|
|
95
96
|
- **安装等待失败**:运行中的工程尚未回应就绪检查,依赖未修改。先让工程完成加载;若修改过桥接源码,需人工处理冲突。
|
|
96
97
|
- **Built CoderGamester server not found**:确认引擎包已经安装且 Node 服务器已构建,或手工设置 `serverPath`。
|
|
97
98
|
- **Multiple cached CoderGamester servers found**:明确选择 `serverPath`。
|
package/index.mjs
CHANGED
|
@@ -4,9 +4,10 @@ import { connectionConfig } from './lib/connection.mjs';
|
|
|
4
4
|
import { installLazyTools } from './lib/lazy-tools.mjs';
|
|
5
5
|
import { projectTool } from './lib/project.mjs';
|
|
6
6
|
import { installationTool } from './lib/install-codergamester.mjs';
|
|
7
|
+
import { screenshotTool } from './lib/capture-window.mjs';
|
|
7
8
|
import { configurationTool } from './lib/configure-project.mjs';
|
|
8
9
|
export const name = 'dsh-unity-mcp';
|
|
9
|
-
export const inject = ['tools', 'agents', 'configEditor'];
|
|
10
|
+
export const inject = ['tools', 'agents', 'configEditor', 'attachments'];
|
|
10
11
|
export const Config = z.object({
|
|
11
12
|
projectPath: z.string().default('').description('Unity / Tuanjie 项目绝对路径 / Absolute project directory'),
|
|
12
13
|
serverPath: z.string().default('').description('CoderGamester Server~/build/index.js 绝对路径;留空从项目发现 / Built server path; blank discovers in project'),
|
|
@@ -18,7 +19,7 @@ export const Config = z.object({
|
|
|
18
19
|
});
|
|
19
20
|
export async function apply(ctx, config) {
|
|
20
21
|
// Install the loader even before configuration, so discovery explains setup.
|
|
21
|
-
installLazyTools(ctx, config.lazyTools, [projectTool(ctx), installationTool(), configurationTool(ctx)]);
|
|
22
|
+
installLazyTools(ctx, config.lazyTools, [projectTool(ctx), screenshotTool(ctx), installationTool(), configurationTool(ctx)]);
|
|
22
23
|
if (!config.projectPath && !config.serverPath) {
|
|
23
24
|
ctx.logger.info('Unity MCP: configure projectPath or serverPath to connect CoderGamester/mcp-unity.');
|
|
24
25
|
return;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { matchingProcess, projectRoot } from './project-lifecycle.mjs';
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
export async function captureWindow(pid) {
|
|
7
|
+
const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('./capture-window.ps1', import.meta.url)), '-EditorPid', String(pid)], { windowsHide: true, timeout: 20000, maxBuffer: 12 * 1024 * 1024 });
|
|
8
|
+
return JSON.parse(stdout.replace(/^\uFEFF/, '').trim());
|
|
9
|
+
}
|
|
10
|
+
export async function captureProject(input, deps = {}) {
|
|
11
|
+
if ((deps.platform ?? process.platform) !== 'win32') throw Error('Editor window capture currently supports Windows only.');
|
|
12
|
+
if (!input || Object.keys(input).some(key => key !== 'projectPath')) throw Error('Capture accepts only projectPath.');
|
|
13
|
+
const root = await projectRoot(input.projectPath);
|
|
14
|
+
const editor = await matchingProcess(root, deps.processes);
|
|
15
|
+
if (!editor) throw Error('No matching editor process. Use unity_project action=list to inspect running editors.');
|
|
16
|
+
const image = await (deps.capture ?? captureWindow)(editor.ProcessId);
|
|
17
|
+
if (image.pid !== editor.ProcessId) throw Error('Screenshot process identity mismatch.');
|
|
18
|
+
const after = await matchingProcess(root, deps.processes);
|
|
19
|
+
if (after?.ProcessId !== editor.ProcessId) throw Error('Editor changed during capture; screenshot discarded.');
|
|
20
|
+
const data = Buffer.from(image.data ?? '', 'base64');
|
|
21
|
+
if (data.length < 24 || data.length > 8 * 1024 * 1024 || data.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || data.toString('base64') !== image.data) throw Error('Invalid PNG window capture.');
|
|
22
|
+
if (data.readUInt32BE(16) !== image.width || data.readUInt32BE(20) !== image.height) throw Error('Screenshot dimensions mismatch.');
|
|
23
|
+
return { projectPath: root, pid: editor.ProcessId, title: image.title, width: image.width, height: image.height, method: image.method, data };
|
|
24
|
+
}
|
|
25
|
+
export function screenshotTool(ctx, deps = {}) {
|
|
26
|
+
return { name: 'unity_capture_window', description: 'Capture the main Windows Unity/Tuanjie editor window for an explicit project, including editor panels, without MCP or an Editor bridge. Returns an image. Does not focus/restore windows or capture the desktop. Minimized or unavailable windows fail; GPU-rendered views may be black or stale.',
|
|
27
|
+
parameters: { type: 'object', properties: { projectPath: { type: 'string', description: 'Absolute directory of the open project. Use unity_project action=list to select it.' } }, required: ['projectPath'], additionalProperties: false },
|
|
28
|
+
output: { schema: { type: 'object' }, render: (_, value) => [{ type: 'text', text: JSON.stringify({ ...value, attachment: undefined }) }, { type: 'image', attachment: value.attachment }] },
|
|
29
|
+
execute: async input => {
|
|
30
|
+
const { data, ...metadata } = await captureProject(input, deps);
|
|
31
|
+
const attachment = await ctx.attachments.saveImage({ data, mediaType: 'image/png', name: 'unity-editor.png' });
|
|
32
|
+
return { ...metadata, attachment };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
param([int]$EditorPid)
|
|
2
|
+
$ErrorActionPreference = 'Stop'
|
|
3
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
Add-Type -AssemblyName System.Drawing
|
|
5
|
+
Add-Type @'
|
|
6
|
+
using System;
|
|
7
|
+
using System.Runtime.InteropServices;
|
|
8
|
+
public static class DshEditorCapture {
|
|
9
|
+
[StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
|
|
10
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out Rect r);
|
|
11
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
|
12
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
13
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
14
|
+
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
|
|
15
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
16
|
+
}
|
|
17
|
+
'@
|
|
18
|
+
[void][DshEditorCapture]::SetProcessDPIAware()
|
|
19
|
+
$p = Get-Process -Id $EditorPid -ErrorAction Stop
|
|
20
|
+
$h = $p.MainWindowHandle
|
|
21
|
+
if ($h -eq [IntPtr]::Zero -or -not [DshEditorCapture]::IsWindowVisible($h)) { throw 'Editor has no visible main window yet.' }
|
|
22
|
+
if ([DshEditorCapture]::IsIconic($h)) { throw 'Editor window is minimized. Restore it before capture; no desktop fallback was used.' }
|
|
23
|
+
[uint32]$ownerId = 0
|
|
24
|
+
[void][DshEditorCapture]::GetWindowThreadProcessId($h, [ref]$ownerId)
|
|
25
|
+
if ($ownerId -ne $EditorPid) { throw 'Window process identity changed.' }
|
|
26
|
+
$r = New-Object DshEditorCapture+Rect
|
|
27
|
+
if (-not [DshEditorCapture]::GetWindowRect($h, [ref]$r)) { throw 'Cannot read editor window bounds.' }
|
|
28
|
+
$w = $r.Right - $r.Left; $height = $r.Bottom - $r.Top
|
|
29
|
+
if ($w -lt 1 -or $height -lt 1 -or $w -gt 8192 -or $height -gt 8192 -or [long]$w * $height -gt 24000000) { throw 'Editor window dimensions exceed capture limits.' }
|
|
30
|
+
$bitmap = New-Object System.Drawing.Bitmap($w, $height)
|
|
31
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
32
|
+
$stream = New-Object System.IO.MemoryStream
|
|
33
|
+
try {
|
|
34
|
+
$dc = $graphics.GetHdc()
|
|
35
|
+
try { $ok = [DshEditorCapture]::PrintWindow($h, $dc, 2) } finally { $graphics.ReleaseHdc($dc) }
|
|
36
|
+
if (-not $ok) { throw 'Window capture failed; no desktop fallback was used.' }
|
|
37
|
+
$bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
38
|
+
if ($stream.Length -gt 8388608) { throw 'Screenshot exceeds 8 MiB.' }
|
|
39
|
+
@{ pid=$EditorPid; title=$p.MainWindowTitle; width=$w; height=$height; data=[Convert]::ToBase64String($stream.ToArray()); method='PrintWindow' } | ConvertTo-Json -Compress
|
|
40
|
+
} finally { $graphics.Dispose(); $bitmap.Dispose(); $stream.Dispose() }
|
package/lib/lazy-tools.mjs
CHANGED
|
@@ -9,6 +9,9 @@ export function installLazyTools(ctx, lazy = true, setupTools = []) {
|
|
|
9
9
|
if (states.has(agent)) return;
|
|
10
10
|
const state = { loaded: !lazy, signature: '', disposers: [], tools: [] };
|
|
11
11
|
const refresh = () => {
|
|
12
|
+
if (state.disposed || state.refreshing) return;
|
|
13
|
+
state.refreshing = true;
|
|
14
|
+
try {
|
|
12
15
|
const raw = rawDefinitions();
|
|
13
16
|
const defs = [...setupTools, ...createEngineTools(raw)], signature = JSON.stringify(defs.map(({ name, description, parameters }) => ({ name, description, parameters })));
|
|
14
17
|
if (signature === state.signature && raw.length === state.raw?.length && raw.every((tool, i) => tool === state.raw[i])) return;
|
|
@@ -24,9 +27,12 @@ export function installLazyTools(ctx, lazy = true, setupTools = []) {
|
|
|
24
27
|
state.tools.push(...installed);
|
|
25
28
|
}
|
|
26
29
|
state.signature = signature;
|
|
30
|
+
} finally { state.refreshing = false; }
|
|
27
31
|
};
|
|
28
32
|
state.refresh = refresh;
|
|
29
33
|
state.dispose = agent.ctx.effect(() => () => {
|
|
34
|
+
state.disposed = true;
|
|
35
|
+
states.delete(agent);
|
|
30
36
|
for (const dispose of state.tools.splice(0).reverse()) dispose();
|
|
31
37
|
state.mask?.();
|
|
32
38
|
for (const dispose of state.disposers.splice(0).reverse()) dispose();
|
|
@@ -46,6 +52,15 @@ export function installLazyTools(ctx, lazy = true, setupTools = []) {
|
|
|
46
52
|
}));
|
|
47
53
|
refresh();
|
|
48
54
|
}
|
|
55
|
+
// MCP discovery completes asynchronously, potentially before the first pre-step.
|
|
56
|
+
// Own registrations/restrictions also emit tools/change: suppress nested refreshes.
|
|
57
|
+
let syncing = false;
|
|
58
|
+
ctx.on('tools/change', () => {
|
|
59
|
+
if (syncing) return;
|
|
60
|
+
syncing = true;
|
|
61
|
+
try { for (const state of states.values()) state.refresh(); }
|
|
62
|
+
finally { syncing = false; }
|
|
63
|
+
});
|
|
49
64
|
ctx.on('agent/created', ({ agent }) => { attach(agent); });
|
|
50
65
|
ctx.on('agent/pre-step', ({ agent }, next) => { attach(agent); states.get(agent).refresh(); return next(); });
|
|
51
66
|
for (const agent of ctx.agents.list()) attach(agent);
|
package/lib/open-project.mjs
CHANGED
|
@@ -21,11 +21,12 @@ async function finishOpen(result, input, deps) {
|
|
|
21
21
|
await (deps.waitReady ?? waitForEditor)(result.projectPath, input.timeoutMs ?? 120000, deps);
|
|
22
22
|
return { ...result, status: 'editor_ready', editorReady: true, message: 'Editor responded after import/compilation settled. MCP connectivity is not verified.' };
|
|
23
23
|
} catch (error) {
|
|
24
|
-
return { ...result, status: '
|
|
24
|
+
return { ...result, status: 'readiness_unconfirmed', running: true, editorReady: null, message: 'Editor process was found or launched, but readiness is unconfirmed: ' + error.message + ' This does not prove it is loading or stopped. Inspect the editor window or retry unity_project action=status; do not relaunch.' };
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
async function powershell(script) {
|
|
28
|
-
const
|
|
27
|
+
export async function powershell(script) {
|
|
28
|
+
const prefix = '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $ErrorActionPreference = "Stop"; ';
|
|
29
|
+
const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(prefix + script, 'utf16le').toString('base64')], { windowsHide: true, timeout: 15000, maxBuffer: 12 * 1024 * 1024 });
|
|
29
30
|
return JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || 'null');
|
|
30
31
|
}
|
|
31
32
|
export function projectFromCommandLine(command = '') {
|
|
@@ -66,6 +67,7 @@ export async function openProject(input, deps = {}) {
|
|
|
66
67
|
const target = projectFromCommandLine(row.CommandLine);
|
|
67
68
|
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);
|
|
68
69
|
}
|
|
70
|
+
if ((Array.isArray(rows) ? rows : [rows]).some(row => row && !projectFromCommandLine(row.CommandLine))) throw Error('An editor has inaccessible project arguments; cannot safely rule out a duplicate launch. Inspect running editor windows.');
|
|
69
71
|
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.');
|
|
70
72
|
const lock = await stat(path.join(projectPath, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
71
73
|
if (lock) throw Error('UnityLockfile exists but no matching editor could be confirmed. Refusing duplicate launch; check the editor/lock manually.');
|
package/lib/project.mjs
CHANGED
|
@@ -36,13 +36,16 @@ export async function manageProject(input, target = {}, deps = {}) {
|
|
|
36
36
|
const inventory = await listProjects(target, deps);
|
|
37
37
|
if (inventory.truncated) throw Error('Editor inventory exceeds 100 processes; status cannot safely select a project.');
|
|
38
38
|
const matches = inventory.projects.filter(p => p.projectPath.toLowerCase() === root.toLowerCase());
|
|
39
|
-
if (!matches.length)
|
|
39
|
+
if (!matches.length) {
|
|
40
|
+
const unknown = inventory.projects.some(p => p.state === 'project_unknown');
|
|
41
|
+
return { status: unknown ? 'process_unconfirmed' : 'not_running', projectPath: root, running: unknown ? null : false, editorReady: unknown ? null : false, connection: target, message: unknown ? 'Some editor process arguments are unavailable. Cannot confirm whether this project is running; do not launch a duplicate.' : 'No matching editor process found; MCP connectivity is not verified.' };
|
|
42
|
+
}
|
|
40
43
|
if (matches.length !== 1) throw Error('Multiple editor processes match this project. Resolve duplicates before acting.');
|
|
41
44
|
try {
|
|
42
45
|
await (deps.request ?? requestProject)({ ...input, timeoutMs: input.timeoutMs ?? 10000 }, 'ready', deps);
|
|
43
46
|
return { ...matches[0], state: 'editor_ready', status: 'editor_ready', editorReady: true, connection: target, message: 'Editor readiness confirmed; MCP connectivity is not verified.' };
|
|
44
47
|
} catch (error) {
|
|
45
|
-
return { ...matches[0], status: 'readiness_unconfirmed', editorReady:
|
|
48
|
+
return { ...matches[0], status: 'readiness_unconfirmed', running: true, editorReady: null, connection: target, message: 'Editor process is running; readiness is unknown, not proof of loading. ' + error.message };
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
throw Error('Unknown project action.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unity-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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/project.mjs && node --check lib/connection.mjs && node --check lib/engine-tools.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/capture-window.mjs && node --check lib/project.mjs && node --check lib/connection.mjs && node --check lib/engine-tools.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"
|