dsh-unity-mcp 0.0.6 → 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 +7 -1
- package/lib/engine-tools.mjs +150 -0
- package/lib/lazy-tools.mjs +10 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -50,7 +50,13 @@ Agent 加载工具后,先用 `unity_project` 的 `list` 查看打开的工程
|
|
|
50
50
|
|
|
51
51
|
## 使用与安全边界
|
|
52
52
|
|
|
53
|
-
每个 Agent 初始只看到 `unity_load_tools
|
|
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、非有限数值及不受上游支持的材质路径。组件更新可能添加组件或留下部分修改,批量调用也不保证所有操作可回滚;工具成功回执不能替代回读验证。
|
|
54
60
|
|
|
55
61
|
打开工程可能导入资源、运行工程内的编辑器脚本;安装包也会执行第三方代码。请只操作可信且已备份的工程。工具不会用不匹配的编辑器版本自动升级工程;自定义编辑器位置或多个匹配时,需要提供确切的 `editorPath`。
|
|
56
62
|
|
|
@@ -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
|
+
}
|
package/lib/lazy-tools.mjs
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
|
12
|
-
|
|
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 =
|
|
16
|
-
if (
|
|
17
|
-
if (state.loaded
|
|
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; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-unity-mcp",
|
|
3
|
-
"version": "0.0.
|
|
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/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",
|
|
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"
|