dsh-unity-mcp 0.0.5 → 0.0.7

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 能做什么
8
8
 
9
- **不依赖已连接的 Unity MCP:** 在 Windows 上按工程版本匹配并打开编辑器、等待工程就绪、准备 CoderGamester 安装、保存已加载场景和修改资源、先保存再安全关闭编辑器,以及保存 DSH 的工程连接配置。保存、关闭和就绪检查会向工程加入一个小型 Editor 桥接,仍需编辑器正常编译并运行。
9
+ **不依赖已连接的 Unity MCP:** 在 Windows 上查看已打开的工程及 DSH 配置的连接目标,按工程版本匹配并打开编辑器、等待工程就绪、准备 CoderGamester 安装、保存已加载场景和修改资源、先保存再安全关闭编辑器,以及保存 DSH 的工程连接配置。保存、关闭和就绪检查会向工程加入一个小型 Editor 桥接,仍需编辑器正常编译并运行。
10
10
 
11
11
  **依赖引擎端 CoderGamester MCP:** 查询和编辑场景、GameObject、组件、材质与资源,查看控制台,以及调用上游提供的编辑器和测试功能。具体能力随上游版本变化;有工具清单不代表编辑器已经连通。
12
12
 
@@ -28,9 +28,11 @@ dsh-unity-mcp
28
28
 
29
29
  可以直接说:“打开 `C:/Projects/MyGame`,准备 CoderGamester,再连接这个工程。”
30
30
 
31
- Agent 加载工具后,会匹配工程要求的编辑器版本并打开工程,等待编辑器就绪,再准备包依赖和自动启动。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
31
+ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程和 DSH 配置的连接目标,再对明确的工程路径执行 `status`、`open`、`save` 或 `close`。`list` 只读,不会写入桥接或启动工程;工程路径无法读取的编辑器进程也会列出,并标为未知。`status` 会检查就绪但不启动编辑器,`open` 则在需要时按版本匹配并启动。
32
32
 
33
- **打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`editor_loading` 表示仍未确认就绪,应再次调用打开工具继续等待,不要重复启动或直接假设工程已经加载完成。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
33
+ 安装 CoderGamester 和保存 DSH 连接分别由独立工具处理,避免把打开工程、修改包和切换所有会话的连接混为一次操作。工程处于关闭状态时,也可以先离线准备安装,再打开编辑器。包依赖固定在已核验的上游 commit,已有不同的 CoderGamester 依赖时不会擅自替换;其他包会保留。
34
+
35
+ **打开工程需要时间。** 工具会等待编辑器完成当前导入、编译并回应新鲜握手:返回 `editor_ready` / `editorReady: true` 才代表这次编辑器就绪检查通过。`list` 中的 `running_not_verified` 只表示发现了进程。`status` 返回 `readiness_unconfirmed` 或 `open` 返回 `editor_loading` 时,应继续检查或等待,不要重复启动或假设工程已经加载完成。运行或启动中的工程,安装工具也会在修改依赖前执行就绪检查;等待失败不会改写依赖。关闭工程的离线安装只代表文件准备完成,不代表工程已打开。
34
36
 
35
37
  编辑器就绪仍不等于 MCP 连通。Package Manager 还需下载和编译引擎包,服务需要成功启动;Node 服务器缺少构建文件时,还需在其 `Server~` 目录执行 `npm install` 和 `npm run build`。安装工具不会代为下载或构建 Node 服务器,也不自动改变 DSH 连接。
36
38
 
@@ -48,7 +50,13 @@ Agent 加载工具后,会匹配工程要求的编辑器版本并打开工程
48
50
 
49
51
  ## 使用与安全边界
50
52
 
51
- 每个 Agent 初始只看到 `unity_load_tools`,调用后才获得本地工程管理和已发现的上游工具;其他 Agent 不受影响。未连接 MCP 时仍可加载本地功能。服务器重连、工具更新和移除会同步到已加载的 Agent。需要立即暴露全部工具时,可将 `lazyTools` 设为 `false`。
53
+ 每个 Agent 初始只看到 `unity_load_tools`,调用后才获得本地工程管理和已发现的引擎工具;其他 Agent 不受影响。未连接 MCP 时仍可加载本地功能。服务器重连、工具更新和移除会同步到已加载的 Agent。需要立即暴露全部工具时,可将 `lazyTools` 设为 `false`,参数检查仍然有效。
54
+
55
+ 引擎操作按领域组织:`unity_transform` 处理位置、旋转和缩放,`unity_scene` 管理场景,`unity_material` 管理共享材质与绑定,`unity_play` 查询或控制播放状态。其他操作保留上游名称。目标对象只能指定一个 `instanceId` 或 `objectPath`;对象详情默认只读自身且不展开组件属性,核对变换或组件值时需显式设置 `includeComponentProperties: true`。控制台默认返回 20 条且不带堆栈。
56
+
57
+ 场景操作沿用上游副作用:创建场景使用 Single 模式;非 additive 加载会先尝试保存再替换已打开场景;删除场景不保存脏修改。场景 `save` 的 `scenePath` 是当前活动场景的另存目标,必须配合 `saveAs: true`,不能用来选择待保存场景。工程级安全保存请用 `unity_project`。材质修改会保存共享资源;未知 shader 属性可能被上游忽略,写后应回读。播放 `pause` 是切换操作,失败或超时后先查状态,不要盲目重试。
58
+
59
+ 适配层会拒绝容易误操作的参数,包括冲突目标、缺省父级、越界 layer、非有限数值及不受上游支持的材质路径。组件更新可能添加组件或留下部分修改,批量调用也不保证所有操作可回滚;工具成功回执不能替代回读验证。
52
60
 
53
61
  打开工程可能导入资源、运行工程内的编辑器脚本;安装包也会执行第三方代码。请只操作可信且已备份的工程。工具不会用不匹配的编辑器版本自动升级工程;自定义编辑器位置或多个匹配时,需要提供确切的 `editorPath`。
54
62
 
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 { openProjectTool } from './lib/open-project.mjs';
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, [configurationTool(ctx), openProjectTool(), ...lifecycleTools(), installationTool()]);
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;
@@ -0,0 +1,150 @@
1
+ const PREFIX = 'mcp__unity__';
2
+ const object = properties => ({ type: 'object', properties, additionalProperties: false });
3
+ const text = value => [{ type: 'text', text: JSON.stringify(value) }];
4
+ const has = (o, k) => Object.hasOwn(o, k) && o[k] !== undefined;
5
+ function target(args) {
6
+ const id = has(args, 'instanceId'), p = has(args, 'objectPath');
7
+ if (id === p) throw Error('Provide exactly one target: instanceId or objectPath. Use an instanceId from a recent read for inactive or ambiguous objects.');
8
+ if (id && (!Number.isInteger(args.instanceId) || args.instanceId === 0)) throw Error('instanceId must be a nonzero integer.');
9
+ if (p) {
10
+ if (typeof args.objectPath !== 'string' || !args.objectPath.trim()) throw Error('objectPath must be nonempty.');
11
+ args.objectPath = args.objectPath.replace(/^\/+/, '');
12
+ if (!args.objectPath.trim()) throw Error('objectPath must identify an object, not the hierarchy root.');
13
+ }
14
+ }
15
+ export function normalizeEngineArgs(name, input) {
16
+ const args = { ...input };
17
+ const finite = value => { if (typeof value === 'number' && !Number.isFinite(value)) throw Error('Numeric values must be finite.'); if (value && typeof value === 'object') for (const item of Object.values(value)) finite(item); };
18
+ finite(args);
19
+ if (['move_gameobject', 'rotate_gameobject', 'scale_gameobject', 'set_transform', 'update_gameobject', 'delete_gameobject', 'duplicate_gameobject', 'reparent_gameobject', 'update_component', 'assign_material', 'select_gameobject'].includes(name)) {
20
+ if (name === 'select_gameobject' && args.objectName && !args.objectPath) { args.objectPath = args.objectName; delete args.objectName; }
21
+ target(args);
22
+ }
23
+ if (name === 'get_gameobject') {
24
+ if (typeof args.idOrName !== 'string' || !args.idOrName.trim()) throw Error('idOrName must be a nonempty ID or hierarchy path.');
25
+ args.idOrName = args.idOrName.replace(/^\/+/, '');
26
+ args.maxDepth ??= 0; args.includeComponents ??= true; args.includeComponentProperties ??= false;
27
+ }
28
+ if (name === 'get_console_logs') { args.includeStackTrace ??= false; args.limit ??= 20; args.offset ??= 0; }
29
+ if (name === 'reparent_gameobject') {
30
+ if (!has(args, 'newParent') && !has(args, 'newParentId')) throw Error('Specify newParent or newParentId explicitly; use newParent:null to move to the scene root.');
31
+ if (has(args, 'newParent') && has(args, 'newParentId')) throw Error('Provide only one new parent selector.');
32
+ if (args.worldPositionStays === false) throw Error('Upstream false resets local position, rotation AND scale. Reparent with worldPositionStays:true, then set explicit local TRS.');
33
+ args.worldPositionStays = true;
34
+ }
35
+ if (name === 'update_gameobject') {
36
+ const data = args.gameObjectData;
37
+ if (!data || !Object.keys(data).length) throw Error('gameObjectData must contain a change.');
38
+ if (has(data, 'layer') && (!Number.isInteger(data.layer) || data.layer < 0 || data.layer > 31)) throw Error('layer must be an integer from 0 to 31.');
39
+ }
40
+ if (name === 'set_transform' && !['position', 'rotation', 'scale'].some(k => has(args, k))) throw Error('Supply at least one of position, rotation or scale.');
41
+ if (name === 'update_component' && args.componentData && Object.values(args.componentData).some(v => typeof v === 'number' && !Number.isFinite(v))) throw Error('Component values must be finite.');
42
+ if (name === 'save_scene' && args.saveAs && !args.scenePath) throw Error('scenePath is required for Save As.');
43
+ if (name === 'save_scene' && args.scenePath && args.saveAs !== true) throw Error('scenePath always saves the ACTIVE scene to a destination; set saveAs:true explicitly. It is not a scene selector.');
44
+ if (name === 'modify_material' && (!args.properties || !Object.keys(args.properties).length)) throw Error('properties must contain at least one shader property.');
45
+ if (['load_scene', 'unload_scene', 'delete_scene'].includes(name) && !args.scenePath && !args.sceneName) throw Error('Provide scenePath or sceneName. Prefer scenePath for an unambiguous scene.');
46
+ if (['create_material', 'modify_material', 'get_material_info', 'assign_material'].includes(name)) {
47
+ const key = name === 'create_material' ? 'savePath' : 'materialPath';
48
+ if (typeof args[key] !== 'string' || !/^Assets\/.+\.mat$/i.test(args[key]) || args[key].split('/').includes('..')) throw Error(key + ' must be an explicit Assets/.../*.mat path; Packages paths are not supported by this upstream implementation.');
49
+ if (args.properties && Object.values(args.properties).some(v => v === null)) throw Error('Upstream cannot clear textures with null. Use an explicit supported shader value.');
50
+ }
51
+ return args;
52
+ }
53
+ const notes = {
54
+ update_gameobject: ' Updates may create missing paths. Prefer instanceId to update existing objects. Invalid tags can be silently ignored upstream; read back after writing.',
55
+ update_component: ' May add a missing component; failed updates may leave partial changes. Enum integers are indices, not underlying values; prefer names. References accept assets, not scene instance IDs. Read back after writing.',
56
+ duplicate_gameobject: ' Upstream returns only an acknowledgement: query the new hierarchy for IDs before further edits.',
57
+ reparent_gameobject: ' Parent must be explicit; only preserving world TRS is supported here.',
58
+ batch_execute: ' Upstream batch is not an adapter transaction. Tool names/params use raw upstream contracts; do not use batch to bypass safer domain selectors or assume every write is atomic.',
59
+ };
60
+ function validate(schema, value, path = 'arguments') {
61
+ if (schema.oneOf) {
62
+ let matches = 0;
63
+ for (const branch of schema.oneOf) { try { validate(branch, value, path); matches++; } catch {} }
64
+ if (matches !== 1) throw Error(path + ' must match exactly one supported shape.');
65
+ }
66
+ const type = schema.type;
67
+ if (type === 'object') {
68
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error(path + ' must be an object.');
69
+ for (const key of schema.required ?? []) if (!has(value, key)) throw Error(path + '.' + key + ' is required.');
70
+ for (const [key, item] of Object.entries(value)) {
71
+ const child = schema.properties?.[key];
72
+ if (child) validate(child, item, path + '.' + key);
73
+ else if (schema.additionalProperties === false) throw Error(path + '.' + key + ' is not supported.');
74
+ }
75
+ } else if (type === 'array') {
76
+ if (!Array.isArray(value)) throw Error(path + ' must be an array.');
77
+ if (schema.items) value.forEach((v, i) => validate(schema.items, v, path + '[' + i + ']'));
78
+ } else if (type === 'null' ? value !== null : type === 'integer' ? !Number.isInteger(value) : type && typeof value !== type) throw Error(path + ' must be ' + type + '.');
79
+ if (schema.enum && !schema.enum.includes(value)) throw Error(path + ' is not a supported value.');
80
+ if (has(schema, 'const') && value !== schema.const) throw Error(path + ' does not match the required value.');
81
+ if (typeof value === 'number' && (!Number.isFinite(value) || has(schema, 'minimum') && value < schema.minimum || has(schema, 'maximum') && value > schema.maximum)) throw Error(path + ' is outside the supported numeric range.');
82
+ }
83
+ export function createEngineTools(rawTools) {
84
+ const raw = new Map(rawTools.map(t => [t.name.slice(PREFIX.length), t]));
85
+ const invoke = async (name, input, exec) => {
86
+ const definition = raw.get(name);
87
+ if (!definition) throw Error('Unity tool is unavailable: ' + name + '. Check the connection and reload tools.');
88
+ const args = normalizeEngineArgs(name, input);
89
+ validate(definition.parameters, args);
90
+ return definition.execute(args, exec);
91
+ };
92
+ const result = [], consumed = new Set();
93
+ function group(name, description, actions) {
94
+ const entries = Object.entries(actions).filter(([, upstream]) => raw.has(upstream));
95
+ if (!entries.length) return;
96
+ const branches = entries.map(([action, upstream]) => {
97
+ consumed.add(upstream);
98
+ const schema = structuredClone(raw.get(upstream).parameters);
99
+ delete schema.$schema;
100
+ schema.properties = { action: { type: 'string', const: action }, ...schema.properties };
101
+ schema.required = ['action', ...(schema.required ?? [])];
102
+ return schema;
103
+ });
104
+ const properties = { action: { type: 'string', enum: entries.map(([a]) => a) } };
105
+ for (const branch of branches) for (const [key, value] of Object.entries(branch.properties)) if (key !== 'action') properties[key] ??= value;
106
+ for (const [key, schema] of Object.entries(properties)) {
107
+ if (key === 'action') continue;
108
+ const used = entries.filter(([, upstream]) => has(raw.get(upstream).parameters.properties ?? {}, key)).map(([a]) => a);
109
+ const required = entries.filter(([, upstream]) => raw.get(upstream).parameters.required?.includes(key)).map(([a]) => a);
110
+ properties[key] = { ...schema, description: (schema.description ? schema.description + ' ' : '') + 'Actions: ' + used.join(', ') + '.' + (required.length ? ' Required for: ' + required.join(', ') + '.' : '') };
111
+ }
112
+ result.push({ name, description, parameters: { ...object(properties), required: ['action'] },
113
+ output: { schema: {}, render: (args, value) => { const { action, ...input } = args; return raw.get(actions[action]).output.render(input, value); } },
114
+ projectContent: (exec, value) => raw.get(actions[exec.arguments.action])?.projectContent?.(exec, value),
115
+ execute: async (input, exec) => {
116
+ const upstream = actions[input.action];
117
+ if (!entries.some(([a]) => a === input.action)) throw Error('Unsupported action: ' + input.action);
118
+ const { action, ...args } = input;
119
+ const schema = raw.get(upstream).parameters;
120
+ for (const key of Object.keys(args)) if (!has(schema.properties ?? {}, key)) throw Error(key + ' is not valid for action ' + action);
121
+ for (const key of schema.required ?? []) if (!has(args, key)) throw Error(key + ' is required for action ' + action);
122
+ return invoke(upstream, args, exec);
123
+ } });
124
+ }
125
+ group('unity_transform', 'Set position/rotation/local scale, translate, rotate or multiply local scale. Targets require exactly one instanceId or objectPath. Relative rotation composes rotations; local translation adds parent-local coordinates. Writes return upstream acknowledgement, not verified actual TRS; read the object to verify.', { set: 'set_transform', translate: 'move_gameobject', rotate: 'rotate_gameobject', scale: 'scale_gameobject' });
126
+ group('unity_scene', 'Inspect or manage scenes. create uses Single mode even with makeActive:false and can replace open scenes. load defaults to saving then replacing open scenes (save failure is not checked upstream); additive:true keeps them. unload defaults to saveIfDirty:true but does not verify save success; delete closes without saving. Prefer scenePath over ambiguous names. save targets the ACTIVE scene; scenePath is a Save As destination, never a selector. For verified all-scene save use unity_project save.', { info: 'get_scene_info', create: 'create_scene', load: 'load_scene', save: 'save_scene', unload: 'unload_scene', delete: 'delete_scene' });
127
+ group('unity_material', 'Inspect/create/modify a material asset or bind an existing Renderer slot. Modification affects every shared reference and saves the asset. Creation may replace an existing asset: choose a new path. Unknown/invalid properties may be ignored upstream; read info after writes. Texture clearing is unsupported.', { info: 'get_material_info', create: 'create_material', modify: 'modify_material', assign: 'assign_material' });
128
+ // Play control already uses action, so expose direct state actions rather than nesting it.
129
+ if (raw.has('get_play_mode_status') && raw.has('set_play_mode_status')) {
130
+ consumed.add('get_play_mode_status'); consumed.add('set_play_mode_status');
131
+ result.push({ name: 'unity_play', description: 'Read or control Play Mode. pause toggles pause; play starts/unpauses; stop exits; step advances a paused frame. Play Mode changes can be discarded on stop.', parameters: { ...object({ action: { type: 'string', enum: ['status', 'play', 'pause', 'stop', 'step'] } }), required: ['action'] }, output: { schema: {}, render: (args, value) => raw.get(args.action === 'status' ? 'get_play_mode_status' : 'set_play_mode_status').output.render(args.action === 'status' ? {} : args, value) }, projectContent: (exec, value) => raw.get(exec.arguments.action === 'status' ? 'get_play_mode_status' : 'set_play_mode_status')?.projectContent?.(exec, value), execute: (input, exec) => {
132
+ if (Object.keys(input).some(key => key !== 'action')) throw Error('Play accepts only action.');
133
+ if (!['status', 'play', 'pause', 'stop', 'step'].includes(input.action)) throw Error('Unsupported Play action.');
134
+ return invoke(input.action === 'status' ? 'get_play_mode_status' : 'set_play_mode_status', input.action === 'status' ? {} : { action: input.action }, exec);
135
+ } });
136
+ }
137
+ for (const [name, tool] of raw) if (!consumed.has(name)) {
138
+ const parameters = structuredClone(tool.parameters);
139
+ if (name === 'get_gameobject') {
140
+ parameters.properties.maxDepth = { ...parameters.properties.maxDepth, default: 0, description: 'Child depth; default 0. Query children directly when truncated.' };
141
+ parameters.properties.includeComponentProperties = { type: 'boolean', default: false, description: 'Include serialized component values; default false.' };
142
+ }
143
+ if (name === 'get_console_logs') {
144
+ parameters.properties.includeStackTrace = { type: 'boolean', default: false, description: 'Include stack traces; default false.' };
145
+ parameters.properties.limit = { ...parameters.properties.limit, default: 20, description: 'Page size; default 20, maximum 500.' };
146
+ }
147
+ result.push({ ...tool, parameters, description: tool.description + (notes[name] ?? ''), execute: (args, exec) => invoke(name, args, exec) });
148
+ }
149
+ return result;
150
+ }
@@ -17,7 +17,7 @@ export async function installCoderGamester(input, deps = {}) {
17
17
  const lock = await stat(path.join(root, 'Temp/UnityLockfile')).catch(e => { if (e.code !== 'ENOENT') throw e; });
18
18
  if (editor || launchPending(root) || lock) {
19
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); }
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
21
  }
22
22
  const manifest = path.join(root, 'Packages/manifest.json');
23
23
  const original = await readFile(manifest, 'utf8');
@@ -1,20 +1,23 @@
1
+ import { createEngineTools } from './engine-tools.mjs';
1
2
  const PREFIX = 'mcp__unity__';
2
3
  const empty = { type: 'object', properties: {}, additionalProperties: false };
3
4
  export function installLazyTools(ctx, lazy = true, setupTools = []) {
4
5
  const states = new Map();
5
- const definitions = () => [...setupTools, ...ctx.tools.schemas().filter(tool => tool.name.startsWith(PREFIX)).map(tool => ctx.tools.get(tool.name)).filter(Boolean)];
6
- if (!lazy) for (const tool of setupTools) ctx.tools.register(tool);
6
+ const rawDefinitions = () => ctx.tools.schemas().filter(tool => tool.name.startsWith(PREFIX)).map(tool => ctx.tools.get(tool.name)).filter(Boolean);
7
+ const definitions = () => [...setupTools, ...createEngineTools(rawDefinitions())];
7
8
  function attach(agent) {
8
9
  if (states.has(agent)) return;
9
10
  const state = { loaded: !lazy, signature: '', disposers: [], tools: [] };
10
11
  const refresh = () => {
11
- const defs = definitions(), signature = JSON.stringify(defs.map(({ name, description, parameters }) => ({ name, description, parameters })));
12
- if (signature === state.signature) return;
12
+ const raw = rawDefinitions();
13
+ const defs = [...setupTools, ...createEngineTools(raw)], signature = JSON.stringify(defs.map(({ name, description, parameters }) => ({ name, description, parameters })));
14
+ if (signature === state.signature && raw.length === state.raw?.length && raw.every((tool, i) => tool === state.raw[i])) return;
15
+ state.raw = raw;
13
16
  for (const dispose of state.tools.splice(0).reverse()) dispose();
14
17
  if (state.mask) { state.mask(); state.mask = undefined; }
15
- const engineNames = defs.filter(tool => tool.name.startsWith(PREFIX)).map(tool => tool.name);
16
- if (lazy && engineNames.length) state.mask = agent.ctx.tools.restrict({ deny: engineNames });
17
- if (state.loaded && lazy) {
18
+ const engineNames = rawDefinitions().map(tool => tool.name);
19
+ if (engineNames.length) state.mask = agent.ctx.tools.restrict({ deny: engineNames });
20
+ if (state.loaded) {
18
21
  const installed = [];
19
22
  try { for (const definition of defs) installed.push(agent.ctx.tools.register({ ...definition })); }
20
23
  catch (error) { for (const dispose of installed.reverse()) dispose(); throw error; }
@@ -32,7 +35,7 @@ export function installLazyTools(ctx, lazy = true, setupTools = []) {
32
35
  states.set(agent, state);
33
36
  if (lazy) state.disposers.push(agent.ctx.tools.register({
34
37
  name: 'unity_load_tools',
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.',
38
+ 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
39
  parameters: empty,
37
40
  output: { schema: { type: 'object', properties: { tools: { type: 'array', items: { type: 'string' } } }, required: ['tools'], additionalProperties: false }, render: (_, value) => [{ type: 'text', text: JSON.stringify(value) }] },
38
41
  execute: async () => {
@@ -21,7 +21,7 @@ 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: '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.' };
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
25
  }
26
26
  }
27
27
  async function powershell(script) {
@@ -97,8 +97,3 @@ async function launchEditor(editor, project) {
97
97
  child.once('spawn', () => { child.unref(); resolve(child.pid); });
98
98
  });
99
99
  }
100
- export function openProjectTool() {
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) };
104
- }
@@ -75,9 +75,3 @@ export async function requestProject(input, action, deps = {}) {
75
75
  throw Error('Editor response timed out. Operation may have begun; inspect the editor before retrying. No force kill was attempted.');
76
76
  } finally { if (request) await unlink(request).catch(() => {}); pending.delete(root); }
77
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
- }
@@ -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.5",
3
+ "version": "0.0.7",
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/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"