@akiojin/unity-mcp-server 5.3.2 → 5.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "5.3.2",
3
+ "version": "5.5.0",
4
4
  "description": "MCP server and Unity Editor bridge — enables AI assistants to control Unity for AI-assisted workflows",
5
5
  "type": "module",
6
6
  "main": "src/core/server.js",
@@ -28,7 +28,7 @@
28
28
  "lru-cache": "^11.0.2"
29
29
  },
30
30
  "engines": {
31
- "node": ">=18 <23"
31
+ "node": ">=18 <25"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -7,7 +7,8 @@ import { createRequire } from 'node:module';
7
7
  const ABI_BY_NODE_MAJOR = new Map([
8
8
  [18, 115],
9
9
  [20, 120],
10
- [22, 131]
10
+ [22, 131],
11
+ [24, 137]
11
12
  ]);
12
13
 
13
14
  export function parseEnvFlag(value) {
@@ -52,6 +52,15 @@ function parseIntEnv(value) {
52
52
  return Number.isFinite(n) ? n : undefined;
53
53
  }
54
54
 
55
+ function parseCsvEnv(value) {
56
+ if (typeof value !== 'string') return undefined;
57
+ const items = value
58
+ .split(',')
59
+ .map(s => s.trim())
60
+ .filter(Boolean);
61
+ return items;
62
+ }
63
+
55
64
  function envString(key) {
56
65
  const raw = process.env[key];
57
66
  if (typeof raw !== 'string') return undefined;
@@ -105,7 +114,8 @@ const baseConfig = {
105
114
  // Project settings (primarily for code index paths)
106
115
  project: {
107
116
  root: null,
108
- codeIndexRoot: null
117
+ codeIndexRoot: null,
118
+ requireClientRoot: false
109
119
  },
110
120
 
111
121
  // Server settings
@@ -142,6 +152,12 @@ const baseConfig = {
142
152
  fields: []
143
153
  },
144
154
 
155
+ // Tool visibility filter
156
+ tools: {
157
+ includeCategories: [],
158
+ excludeCategories: []
159
+ },
160
+
145
161
  // Write queue removed: all edits go through structured Roslyn tools.
146
162
 
147
163
  // Search-related defaults and engine selection
@@ -154,7 +170,8 @@ const baseConfig = {
154
170
  // LSP client defaults
155
171
  lsp: {
156
172
  requestTimeoutMs: 120000,
157
- slowRequestWarnMs: 2000
173
+ slowRequestWarnMs: 2000,
174
+ validationTimeoutMs: 5000
158
175
  },
159
176
 
160
177
  // Indexing (code index) settings
@@ -176,6 +193,7 @@ function loadEnvConfig() {
176
193
  const unityPort = parseIntEnv(process.env.UNITY_MCP_PORT);
177
194
 
178
195
  const projectRoot = envString('UNITY_PROJECT_ROOT');
196
+ const requireProjectRoot = parseBoolEnv(process.env.UNITY_MCP_REQUIRE_PROJECT_ROOT);
179
197
 
180
198
  const logLevel = envString('UNITY_MCP_LOG_LEVEL');
181
199
  const versionMismatch = envString('UNITY_MCP_VERSION_MISMATCH');
@@ -186,6 +204,9 @@ function loadEnvConfig() {
186
204
  const telemetryEnabled = parseBoolEnv(process.env.UNITY_MCP_TELEMETRY_ENABLED);
187
205
  const lspRequestTimeoutMs = parseIntEnv(process.env.UNITY_MCP_LSP_REQUEST_TIMEOUT_MS);
188
206
  const lspSlowRequestWarnMs = parseIntEnv(process.env.UNITY_MCP_LSP_SLOW_REQUEST_WARN_MS);
207
+ const lspValidationTimeoutMs = parseIntEnv(process.env.UNITY_MCP_LSP_VALIDATION_TIMEOUT_MS);
208
+ const includeCategories = parseCsvEnv(process.env.UNITY_MCP_TOOL_INCLUDE_CATEGORIES);
209
+ const excludeCategories = parseCsvEnv(process.env.UNITY_MCP_TOOL_EXCLUDE_CATEGORIES);
189
210
 
190
211
  const out = {};
191
212
 
@@ -201,6 +222,9 @@ function loadEnvConfig() {
201
222
  out.project = {};
202
223
  if (projectRoot) out.project.root = projectRoot;
203
224
  }
225
+ if (requireProjectRoot !== undefined) {
226
+ out.project = { ...(out.project || {}), requireClientRoot: requireProjectRoot };
227
+ }
204
228
 
205
229
  if (logLevel) {
206
230
  out.logging = { level: logLevel };
@@ -220,12 +244,21 @@ function loadEnvConfig() {
220
244
  out.telemetry = { enabled: telemetryEnabled };
221
245
  }
222
246
 
247
+ if (includeCategories !== undefined || excludeCategories !== undefined) {
248
+ out.tools = {};
249
+ if (includeCategories !== undefined) out.tools.includeCategories = includeCategories;
250
+ if (excludeCategories !== undefined) out.tools.excludeCategories = excludeCategories;
251
+ }
252
+
223
253
  if (lspRequestTimeoutMs !== undefined) {
224
254
  out.lsp = { requestTimeoutMs: lspRequestTimeoutMs };
225
255
  }
226
256
  if (lspSlowRequestWarnMs !== undefined) {
227
257
  out.lsp = { ...(out.lsp || {}), slowRequestWarnMs: lspSlowRequestWarnMs };
228
258
  }
259
+ if (lspValidationTimeoutMs !== undefined) {
260
+ out.lsp = { ...(out.lsp || {}), validationTimeoutMs: lspValidationTimeoutMs };
261
+ }
229
262
 
230
263
  return out;
231
264
  }
@@ -296,6 +329,25 @@ function validateAndNormalizeConfig(cfg) {
296
329
  cfg.project.codeIndexRoot = null;
297
330
  }
298
331
 
332
+ if (!cfg.tools || typeof cfg.tools !== 'object') {
333
+ cfg.tools = {};
334
+ }
335
+ if (!Array.isArray(cfg.tools.includeCategories)) {
336
+ cfg.tools.includeCategories = [];
337
+ }
338
+ if (!Array.isArray(cfg.tools.excludeCategories)) {
339
+ cfg.tools.excludeCategories = [];
340
+ }
341
+
342
+ cfg.tools.includeCategories = cfg.tools.includeCategories
343
+ .filter(v => typeof v === 'string')
344
+ .map(v => v.trim())
345
+ .filter(Boolean);
346
+ cfg.tools.excludeCategories = cfg.tools.excludeCategories
347
+ .filter(v => typeof v === 'string')
348
+ .map(v => v.trim())
349
+ .filter(Boolean);
350
+
299
351
  // lsp timeout sanity
300
352
  if (cfg.lsp?.requestTimeoutMs !== undefined) {
301
353
  const t = Number(cfg.lsp.requestTimeoutMs);
@@ -315,6 +367,15 @@ function validateAndNormalizeConfig(cfg) {
315
367
  cfg.lsp.slowRequestWarnMs = 2000;
316
368
  }
317
369
  }
370
+ if (cfg.lsp?.validationTimeoutMs !== undefined) {
371
+ const t = Number(cfg.lsp.validationTimeoutMs);
372
+ if (!Number.isFinite(t) || t < 0) {
373
+ logger.warning(
374
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_LSP_VALIDATION_TIMEOUT_MS (${cfg.lsp.validationTimeoutMs}); using default 5000`
375
+ );
376
+ cfg.lsp.validationTimeoutMs = 5000;
377
+ }
378
+ }
318
379
  }
319
380
 
320
381
  export const config = merge(baseConfig, loadEnvConfig());
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { logger } from './config.js';
3
+ import { createProjectRootGuard } from './projectRootGuard.js';
3
4
 
4
5
  function buildHealthResponse({ startedAt, mode, port, telemetryEnabled }) {
5
6
  return {
@@ -22,10 +23,12 @@ export function createHttpServer({
22
23
  port = 6401,
23
24
  telemetryEnabled = false,
24
25
  healthPath = '/healthz',
25
- allowedHosts = ['localhost', '127.0.0.1']
26
+ allowedHosts = ['localhost', '127.0.0.1'],
27
+ requireClientRoot = false
26
28
  } = {}) {
27
29
  const startedAt = Date.now();
28
30
  let server;
31
+ const projectRootGuard = createProjectRootGuard({ requireClientRoot, logger });
29
32
 
30
33
  const listener = async (req, res) => {
31
34
  try {
@@ -76,6 +79,18 @@ export function createHttpServer({
76
79
  if (method === 'tools/call' || method === 'callTool') {
77
80
  const name = params?.name;
78
81
  const args = params?.arguments || {};
82
+ const guardError = await projectRootGuard(args);
83
+ if (guardError) {
84
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
85
+ res.end(
86
+ JSON.stringify({
87
+ jsonrpc: '2.0',
88
+ id,
89
+ error: { code: -32010, message: guardError }
90
+ })
91
+ );
92
+ return;
93
+ }
79
94
  const handler = handlers.get(name);
80
95
  if (!handler) {
81
96
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -0,0 +1,15 @@
1
+ export function buildProgress({ phase, processed = 0, total = 0, rate = 0 } = {}) {
2
+ const safePhase = phase && String(phase).trim() ? String(phase) : 'index';
3
+ return {
4
+ phase: safePhase,
5
+ processed: Number.isFinite(processed) ? processed : 0,
6
+ total: Number.isFinite(total) ? total : 0,
7
+ rate: Number.isFinite(rate) ? rate : 0
8
+ };
9
+ }
10
+
11
+ export function getReportEvery(total, steps = 20) {
12
+ const safeTotal = Number.isFinite(total) && total > 0 ? total : 0;
13
+ const safeSteps = Number.isFinite(steps) && steps > 0 ? steps : 20;
14
+ return Math.max(1, Math.floor(safeTotal / safeSteps) || 1);
15
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { ProjectInfoProvider } from './projectInfo.js';
3
+
4
+ const normalizeRoot = root => {
5
+ if (!root) return '';
6
+ const resolved = path.resolve(String(root));
7
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/g, '');
8
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
9
+ };
10
+
11
+ export function createProjectRootGuard({
12
+ requireClientRoot = false,
13
+ projectInfoProvider = null,
14
+ logger = null
15
+ } = {}) {
16
+ const provider = projectInfoProvider || new ProjectInfoProvider();
17
+ let cachedRoot = null;
18
+
19
+ const getServerRoot = async () => {
20
+ if (cachedRoot) return cachedRoot;
21
+ try {
22
+ const info = await provider.get();
23
+ cachedRoot = normalizeRoot(info?.projectRoot || '');
24
+ } catch (e) {
25
+ logger?.warning?.(`[unity-mcp-server] project root resolve failed: ${e.message}`);
26
+ }
27
+ return cachedRoot;
28
+ };
29
+
30
+ return async function guard(args = {}) {
31
+ const clientRootRaw = args?.projectRoot;
32
+ if (!requireClientRoot && !clientRootRaw) return null;
33
+
34
+ if (!clientRootRaw) {
35
+ return 'projectRoot is required. Call get_server_info and pass projectRoot with tool arguments.';
36
+ }
37
+
38
+ const clientRoot = normalizeRoot(clientRootRaw);
39
+ const serverRoot = await getServerRoot();
40
+ if (!serverRoot) {
41
+ return 'server projectRoot could not be resolved';
42
+ }
43
+ if (clientRoot !== serverRoot) {
44
+ return `projectRoot mismatch (client=${clientRootRaw}, server=${serverRoot})`;
45
+ }
46
+ return null;
47
+ };
48
+ }
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import fs from 'node:fs';
17
17
  import { StdioRpcServer } from './stdioRpcServer.js';
18
+ import { createProjectRootGuard } from './projectRootGuard.js';
19
+ import { createToolCategoryPolicy, filterToolsByCategory } from './toolCategoryFilter.js';
18
20
 
19
21
  // Deferred state - will be initialized after transport connection
20
22
  let unityConnection = null;
@@ -125,8 +127,50 @@ export async function startServer(options = {}) {
125
127
  ...config,
126
128
  http: { ...config.http, ...(options.http || {}) },
127
129
  telemetry: { ...config.telemetry, ...(options.telemetry || {}) },
130
+ tools: {
131
+ includeCategories: config.tools?.includeCategories || [],
132
+ excludeCategories: config.tools?.excludeCategories || []
133
+ },
128
134
  stdioEnabled: options.stdioEnabled !== undefined ? options.stdioEnabled : true
129
135
  };
136
+ const toolCategoryPolicy = createToolCategoryPolicy(runtimeConfig.tools, logger);
137
+ let publicToolNames = null;
138
+ let toolFilterLogged = false;
139
+
140
+ const applyCategoryFilter = tools => {
141
+ if (!toolCategoryPolicy.isActive) {
142
+ return tools;
143
+ }
144
+
145
+ const filtered = filterToolsByCategory(tools, toolCategoryPolicy);
146
+ publicToolNames = filtered.publicToolNames;
147
+
148
+ if (!toolFilterLogged) {
149
+ const include =
150
+ toolCategoryPolicy.includeList.length > 0
151
+ ? toolCategoryPolicy.includeList.join(', ')
152
+ : '(all)';
153
+ const exclude =
154
+ toolCategoryPolicy.excludeList.length > 0
155
+ ? toolCategoryPolicy.excludeList.join(', ')
156
+ : '(none)';
157
+ logger.info(
158
+ `[MCP] Tool category filter enabled. include=${include}, exclude=${exclude}`
159
+ );
160
+ toolFilterLogged = true;
161
+ }
162
+
163
+ return filtered.tools;
164
+ };
165
+
166
+ const projectInfoProvider =
167
+ deps.projectInfoProvider ||
168
+ (deps.ProjectInfoProvider ? new deps.ProjectInfoProvider() : null);
169
+ const projectRootGuard = createProjectRootGuard({
170
+ requireClientRoot: runtimeConfig.project?.requireClientRoot === true,
171
+ logger,
172
+ projectInfoProvider
173
+ });
130
174
 
131
175
  // Step 2: Create a lightweight stdio MCP server (no TS SDK import)
132
176
  const server =
@@ -180,7 +224,8 @@ export async function startServer(options = {}) {
180
224
  port: runtimeConfig.http.port,
181
225
  telemetryEnabled: runtimeConfig.telemetry.enabled,
182
226
  healthPath: runtimeConfig.http.healthPath,
183
- allowedHosts: runtimeConfig.http.allowedHosts
227
+ allowedHosts: runtimeConfig.http.allowedHosts,
228
+ requireClientRoot: runtimeConfig.project?.requireClientRoot === true
184
229
  });
185
230
  try {
186
231
  await httpServerInstance.start();
@@ -320,14 +365,15 @@ export async function startServer(options = {}) {
320
365
  server?.setRequestHandler('tools/list', async () => {
321
366
  const manifestTools = readToolManifest();
322
367
  if (manifestTools) {
323
- logger.info(`[MCP] Returning ${manifestTools.length} tool definitions`);
368
+ const visibleTools = applyCategoryFilter(manifestTools);
369
+ logger.info(`[MCP] Returning ${visibleTools.length} tool definitions`);
324
370
  requestPostInit();
325
- return { tools: manifestTools };
371
+ return { tools: visibleTools };
326
372
  }
327
373
 
328
374
  await ensureInitialized(deps);
329
375
 
330
- const tools = Array.from(handlers.values())
376
+ const allTools = Array.from(handlers.values())
331
377
  .map((handler, index) => {
332
378
  try {
333
379
  const definition = handler.getDefinition();
@@ -345,6 +391,7 @@ export async function startServer(options = {}) {
345
391
  })
346
392
  .filter(tool => tool !== null);
347
393
 
394
+ const tools = applyCategoryFilter(allTools);
348
395
  logger.info(`[MCP] Returning ${tools.length} tool definitions`);
349
396
  requestPostInit();
350
397
  return { tools };
@@ -362,6 +409,30 @@ export async function startServer(options = {}) {
362
409
  { args }
363
410
  );
364
411
 
412
+ if (toolCategoryPolicy.isActive) {
413
+ if (!publicToolNames) {
414
+ const handlerTools = Array.from(handlers.values()).map(handler => ({ name: handler.name }));
415
+ publicToolNames = filterToolsByCategory(handlerTools, toolCategoryPolicy).publicToolNames;
416
+ }
417
+ if (!publicToolNames.has(name)) {
418
+ logger.error(`[MCP] Tool not found (filtered): ${name}`);
419
+ throw new Error(`Tool not found: ${name}`);
420
+ }
421
+ }
422
+
423
+ const guardError = await projectRootGuard(args || {});
424
+ if (guardError) {
425
+ logger.error(`[MCP] projectRoot guard failed: ${guardError}`);
426
+ return {
427
+ content: [
428
+ {
429
+ type: 'text',
430
+ text: `Error: ${guardError}\nCode: PROJECT_ROOT_MISMATCH`
431
+ }
432
+ ]
433
+ };
434
+ }
435
+
365
436
  const handler = handlers.get(name);
366
437
  if (!handler) {
367
438
  logger.error(`[MCP] Tool not found: ${name}`);
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Tool category filtering for tools/list and tools/call visibility control.
3
+ */
4
+
5
+ const CATEGORY_NAMES = Object.freeze([
6
+ 'system',
7
+ 'gameobject',
8
+ 'scene',
9
+ 'analysis',
10
+ 'playmode',
11
+ 'ui',
12
+ 'input',
13
+ 'asset',
14
+ 'prefab',
15
+ 'material',
16
+ 'addressables',
17
+ 'menu',
18
+ 'console',
19
+ 'screenshot',
20
+ 'video',
21
+ 'component',
22
+ 'compilation',
23
+ 'test',
24
+ 'editor',
25
+ 'settings',
26
+ 'package',
27
+ 'script',
28
+ 'profiler',
29
+ 'general'
30
+ ]);
31
+
32
+ function toCategoryKey(value) {
33
+ if (typeof value !== 'string') return '';
34
+ return value
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[\s_-]+/g, '');
38
+ }
39
+
40
+ const CATEGORY_KEY_TO_NAME = new Map(CATEGORY_NAMES.map(name => [toCategoryKey(name), name]));
41
+
42
+ // User-friendly aliases
43
+ CATEGORY_KEY_TO_NAME.set('addressable', 'addressables');
44
+ CATEGORY_KEY_TO_NAME.set('gameobj', 'gameobject');
45
+ CATEGORY_KEY_TO_NAME.set('play', 'playmode');
46
+ CATEGORY_KEY_TO_NAME.set('playmode', 'playmode');
47
+ CATEGORY_KEY_TO_NAME.set('uitoolkit', 'ui');
48
+ CATEGORY_KEY_TO_NAME.set('ugui', 'ui');
49
+ CATEGORY_KEY_TO_NAME.set('imgui', 'ui');
50
+
51
+ const SYSTEM_TOOLS = new Set([
52
+ 'ping',
53
+ 'refresh_assets',
54
+ 'get_command_stats',
55
+ 'get_server_info',
56
+ 'search_tools'
57
+ ]);
58
+
59
+ const GAMEOBJECT_TOOLS = new Set([
60
+ 'create_gameobject',
61
+ 'find_gameobject',
62
+ 'modify_gameobject',
63
+ 'delete_gameobject',
64
+ 'get_hierarchy'
65
+ ]);
66
+
67
+ const SCENE_TOOLS = new Set(['create_scene', 'load_scene', 'save_scene', 'list_scenes', 'get_scene_info']);
68
+
69
+ const ANALYSIS_TOOLS = new Set([
70
+ 'get_gameobject_details',
71
+ 'analyze_scene_contents',
72
+ 'get_component_values',
73
+ 'find_by_component',
74
+ 'get_object_references',
75
+ 'get_animator_state',
76
+ 'get_animator_runtime_info',
77
+ 'get_input_actions_state',
78
+ 'analyze_input_actions_asset'
79
+ ]);
80
+
81
+ const PLAYMODE_TOOLS = new Set(['play_game', 'pause_game', 'stop_game', 'playmode_wait_for_state']);
82
+
83
+ const UI_TOOLS = new Set([
84
+ 'find_ui_elements',
85
+ 'click_ui_element',
86
+ 'get_ui_element_state',
87
+ 'set_ui_element_value',
88
+ 'simulate_ui_input'
89
+ ]);
90
+
91
+ const INPUT_TOOLS = new Set([
92
+ 'input_system_control',
93
+ 'input_keyboard',
94
+ 'input_mouse',
95
+ 'input_gamepad',
96
+ 'input_touch',
97
+ 'create_action_map',
98
+ 'remove_action_map',
99
+ 'add_input_action',
100
+ 'remove_input_action',
101
+ 'add_input_binding',
102
+ 'remove_input_binding',
103
+ 'remove_all_bindings',
104
+ 'create_composite_binding',
105
+ 'manage_control_schemes'
106
+ ]);
107
+
108
+ const ASSET_TOOLS = new Set([
109
+ 'manage_asset_database',
110
+ 'manage_asset_import_settings',
111
+ 'analyze_asset_dependencies'
112
+ ]);
113
+
114
+ const PREFAB_TOOLS = new Set([
115
+ 'create_prefab',
116
+ 'modify_prefab',
117
+ 'instantiate_prefab',
118
+ 'open_prefab',
119
+ 'exit_prefab_mode',
120
+ 'save_prefab'
121
+ ]);
122
+
123
+ const MATERIAL_TOOLS = new Set(['create_material', 'modify_material']);
124
+
125
+ const MENU_TOOLS = new Set(['execute_menu_item']);
126
+ const CONSOLE_TOOLS = new Set(['clear_console', 'read_console']);
127
+ const SCREENSHOT_TOOLS = new Set(['capture_screenshot', 'analyze_screenshot']);
128
+ const VIDEO_TOOLS = new Set([
129
+ 'capture_video_start',
130
+ 'capture_video_stop',
131
+ 'capture_video_status',
132
+ 'video_capture_for'
133
+ ]);
134
+ const COMPONENT_TOOLS = new Set([
135
+ 'add_component',
136
+ 'remove_component',
137
+ 'modify_component',
138
+ 'list_components',
139
+ 'get_component_types',
140
+ 'set_component_field'
141
+ ]);
142
+ const COMPILATION_TOOLS = new Set(['get_compilation_state']);
143
+ const TEST_TOOLS = new Set(['run_tests', 'get_test_status']);
144
+ const EDITOR_TOOLS = new Set([
145
+ 'manage_tags',
146
+ 'manage_layers',
147
+ 'manage_selection',
148
+ 'manage_windows',
149
+ 'manage_tools',
150
+ 'get_editor_state',
151
+ 'quit_editor'
152
+ ]);
153
+ const SETTINGS_TOOLS = new Set(['get_project_settings', 'update_project_settings']);
154
+ const PACKAGE_TOOLS = new Set(['package_manager', 'registry_config', 'list_packages']);
155
+ const SCRIPT_TOOLS = new Set([
156
+ 'read',
157
+ 'search',
158
+ 'edit_structured',
159
+ 'edit_snippet',
160
+ 'get_symbols',
161
+ 'find_symbol',
162
+ 'find_refs',
163
+ 'build_index',
164
+ 'update_index',
165
+ 'get_index_status',
166
+ 'rename_symbol',
167
+ 'create_class',
168
+ 'remove_symbol'
169
+ ]);
170
+
171
+ const TOOL_SETS = [
172
+ ['system', SYSTEM_TOOLS],
173
+ ['gameobject', GAMEOBJECT_TOOLS],
174
+ ['scene', SCENE_TOOLS],
175
+ ['analysis', ANALYSIS_TOOLS],
176
+ ['playmode', PLAYMODE_TOOLS],
177
+ ['ui', UI_TOOLS],
178
+ ['input', INPUT_TOOLS],
179
+ ['asset', ASSET_TOOLS],
180
+ ['prefab', PREFAB_TOOLS],
181
+ ['material', MATERIAL_TOOLS],
182
+ ['menu', MENU_TOOLS],
183
+ ['console', CONSOLE_TOOLS],
184
+ ['screenshot', SCREENSHOT_TOOLS],
185
+ ['video', VIDEO_TOOLS],
186
+ ['component', COMPONENT_TOOLS],
187
+ ['compilation', COMPILATION_TOOLS],
188
+ ['test', TEST_TOOLS],
189
+ ['editor', EDITOR_TOOLS],
190
+ ['settings', SETTINGS_TOOLS],
191
+ ['package', PACKAGE_TOOLS],
192
+ ['script', SCRIPT_TOOLS]
193
+ ];
194
+
195
+ function logWarning(logger, message) {
196
+ if (!logger) return;
197
+ if (typeof logger.warning === 'function') {
198
+ logger.warning(message);
199
+ return;
200
+ }
201
+ if (typeof logger.warn === 'function') {
202
+ logger.warn(message);
203
+ return;
204
+ }
205
+ if (typeof logger.info === 'function') {
206
+ logger.info(message);
207
+ }
208
+ }
209
+
210
+ function normalizeCategoryList(rawValues = []) {
211
+ const values = Array.isArray(rawValues) ? rawValues : [];
212
+ const normalized = [];
213
+ const unknown = [];
214
+ const seen = new Set();
215
+
216
+ for (const value of values) {
217
+ const key = toCategoryKey(value);
218
+ if (!key) continue;
219
+ const category = CATEGORY_KEY_TO_NAME.get(key);
220
+ if (!category) {
221
+ unknown.push(String(value).trim());
222
+ continue;
223
+ }
224
+ if (seen.has(category)) continue;
225
+ seen.add(category);
226
+ normalized.push(category);
227
+ }
228
+
229
+ return { normalized, unknown };
230
+ }
231
+
232
+ export function getToolCategory(toolName) {
233
+ const name = typeof toolName === 'string' ? toolName : '';
234
+ if (!name) return 'general';
235
+ if (name.startsWith('addressables_')) return 'addressables';
236
+ if (name.startsWith('profiler_')) return 'profiler';
237
+
238
+ for (const [category, set] of TOOL_SETS) {
239
+ if (set.has(name)) return category;
240
+ }
241
+
242
+ return 'general';
243
+ }
244
+
245
+ export function createToolCategoryPolicy(config = {}, logger) {
246
+ const includeResult = normalizeCategoryList(config.includeCategories);
247
+ const excludeResult = normalizeCategoryList(config.excludeCategories);
248
+
249
+ if (includeResult.unknown.length > 0) {
250
+ logWarning(
251
+ logger,
252
+ `[tool-filter] Ignoring unknown include categories: ${includeResult.unknown.join(', ')}`
253
+ );
254
+ }
255
+ if (excludeResult.unknown.length > 0) {
256
+ logWarning(
257
+ logger,
258
+ `[tool-filter] Ignoring unknown exclude categories: ${excludeResult.unknown.join(', ')}`
259
+ );
260
+ }
261
+
262
+ return {
263
+ includeCategories: new Set(includeResult.normalized),
264
+ excludeCategories: new Set(excludeResult.normalized),
265
+ includeList: includeResult.normalized,
266
+ excludeList: excludeResult.normalized,
267
+ isActive: includeResult.normalized.length > 0 || excludeResult.normalized.length > 0
268
+ };
269
+ }
270
+
271
+ export function filterToolsByCategory(tools, policy) {
272
+ const list = Array.isArray(tools) ? tools : [];
273
+ const publicTools = [];
274
+ const hiddenToolNames = new Set();
275
+ const publicToolNames = new Set();
276
+ const includeCategories = policy?.includeCategories ?? new Set();
277
+ const excludeCategories = policy?.excludeCategories ?? new Set();
278
+ const includeEnabled = includeCategories.size > 0;
279
+ const excludeEnabled = excludeCategories.size > 0;
280
+
281
+ for (const tool of list) {
282
+ const name = tool?.name;
283
+ if (typeof name !== 'string' || name.length === 0) {
284
+ continue;
285
+ }
286
+
287
+ const category = getToolCategory(name);
288
+ const includeMatch = !includeEnabled || includeCategories.has(category);
289
+ const excludeMatch = excludeEnabled && excludeCategories.has(category);
290
+ const visible = includeMatch && !excludeMatch;
291
+
292
+ if (visible) {
293
+ publicTools.push(tool);
294
+ publicToolNames.add(name);
295
+ } else {
296
+ hiddenToolNames.add(name);
297
+ }
298
+ }
299
+
300
+ return {
301
+ tools: publicTools,
302
+ publicToolNames,
303
+ hiddenToolNames
304
+ };
305
+ }
306
+
307
+ export function getKnownToolCategories() {
308
+ return [...CATEGORY_NAMES];
309
+ }
@@ -3602,6 +3602,14 @@
3602
3602
  "properties": {}
3603
3603
  }
3604
3604
  },
3605
+ {
3606
+ "name": "get_server_info",
3607
+ "description": "Get MCP server identifying information (pid, project root, workspace)",
3608
+ "inputSchema": {
3609
+ "type": "object",
3610
+ "properties": {}
3611
+ }
3612
+ },
3605
3613
  {
3606
3614
  "name": "ping",
3607
3615
  "description": "Test connection to Unity Editor",
@@ -20,6 +20,7 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import os from 'os';
22
22
  import { fileURLToPath } from 'url';
23
+ import { buildProgress, getReportEvery } from '../indexProgress.js';
23
24
 
24
25
  // fast-sql helper: run SQL statement
25
26
  function runSQL(db, sql) {
@@ -71,8 +72,8 @@ function log(level, message) {
71
72
  sendMessage('log', { level, message });
72
73
  }
73
74
 
74
- function sendProgress(processed, total, rate) {
75
- sendMessage('progress', { data: { processed, total, rate } });
75
+ function sendProgress(phase, processed, total, rate) {
76
+ sendMessage('progress', { data: buildProgress({ phase, processed, total, rate }) });
76
77
  }
77
78
 
78
79
  function sendComplete(result) {
@@ -624,6 +625,8 @@ async function runBuild() {
624
625
 
625
626
  // Determine changes (this calls makeSig for each file)
626
627
  log('info', `[worker] Computing file signatures (${files.length} files)...`);
628
+ sendProgress('signature', 0, files.length, 0);
629
+ const signatureReportEvery = getReportEvery(files.length);
627
630
  const sigStartTime = Date.now();
628
631
  let sigProcessed = 0;
629
632
  for (const abs of files) {
@@ -632,13 +635,19 @@ async function runBuild() {
632
635
  wanted.set(rel, sig);
633
636
  sigProcessed++;
634
637
  // Report progress every 10000 files
635
- if (sigProcessed % 10000 === 0) {
638
+ if (sigProcessed % signatureReportEvery === 0) {
636
639
  const elapsed = ((Date.now() - sigStartTime) / 1000).toFixed(1);
637
640
  log('info', `[worker] Signature progress: ${sigProcessed}/${files.length} (${elapsed}s)`);
641
+ const sigElapsed = Math.max(1, Date.now() - sigStartTime);
642
+ const sigRate = parseFloat(((sigProcessed * 1000) / sigElapsed).toFixed(1));
643
+ sendProgress('signature', sigProcessed, files.length, sigRate);
638
644
  }
639
645
  }
640
646
  const sigTime = ((Date.now() - sigStartTime) / 1000).toFixed(1);
641
647
  log('info', `[worker] Signatures computed in ${sigTime}s`);
648
+ const sigElapsed = Math.max(1, Date.now() - sigStartTime);
649
+ const sigRate = parseFloat(((sigProcessed * 1000) / sigElapsed).toFixed(1));
650
+ sendProgress('signature', sigProcessed, files.length, sigRate);
642
651
 
643
652
  for (const [rel, sig] of wanted) {
644
653
  if (current.get(rel) !== sig) changed.push(rel);
@@ -671,6 +680,7 @@ async function runBuild() {
671
680
 
672
681
  // Prepare for updates
673
682
  const absList = changed.map(rel => path.resolve(projectRoot, rel));
683
+ sendProgress('index', 0, absList.length, 0);
674
684
  const startAt = Date.now();
675
685
  let processed = 0;
676
686
  let updated = 0;
@@ -786,7 +796,7 @@ async function runBuild() {
786
796
  currentPercentage >= lastReportedPercentage + reportPercentage ||
787
797
  processed === absList.length
788
798
  ) {
789
- sendProgress(processed, absList.length, rate);
799
+ sendProgress('index', processed, absList.length, rate);
790
800
  log(
791
801
  'info',
792
802
  `[worker] progress ${currentPercentage}% (${processed}/${absList.length}) rate:${rate} f/s`
@@ -10,6 +10,7 @@
10
10
  import { SystemPingToolHandler } from './system/SystemPingToolHandler.js';
11
11
  import { SystemRefreshAssetsToolHandler } from './system/SystemRefreshAssetsToolHandler.js';
12
12
  import { SystemGetCommandStatsToolHandler } from './system/SystemGetCommandStatsToolHandler.js';
13
+ import { SystemGetServerInfoToolHandler } from './system/SystemGetServerInfoToolHandler.js';
13
14
  import { GameObjectCreateToolHandler } from './gameobject/GameObjectCreateToolHandler.js';
14
15
  import { GameObjectFindToolHandler } from './gameobject/GameObjectFindToolHandler.js';
15
16
  import { GameObjectModifyToolHandler } from './gameobject/GameObjectModifyToolHandler.js';
@@ -279,6 +280,7 @@ const HANDLER_CLASSES = [
279
280
  SystemPingToolHandler,
280
281
  SystemRefreshAssetsToolHandler,
281
282
  SystemGetCommandStatsToolHandler,
283
+ SystemGetServerInfoToolHandler,
282
284
 
283
285
  // GameObject handlers
284
286
  GameObjectCreateToolHandler,
@@ -1,6 +1,7 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { JobManager } from '../../core/jobManager.js';
3
3
  import { CodeIndex } from '../../core/codeIndex.js';
4
+ import { getWorkerPool } from '../../core/indexBuildWorkerPool.js';
4
5
 
5
6
  export class CodeIndexStatusToolHandler extends BaseToolHandler {
6
7
  constructor(unityConnection) {
@@ -16,6 +17,7 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
16
17
  this.unityConnection = unityConnection;
17
18
  this.jobManager = JobManager.getInstance();
18
19
  this.codeIndex = new CodeIndex(unityConnection);
20
+ this.workerPool = getWorkerPool();
19
21
  }
20
22
 
21
23
  async execute() {
@@ -53,7 +55,8 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
53
55
  }
54
56
  };
55
57
  }
56
- const buildInProgress = latestBuildJob?.status === 'running';
58
+ const workerBuildRunning = Boolean(this.workerPool?.isRunning?.());
59
+ const buildInProgress = latestBuildJob?.status === 'running' || workerBuildRunning;
57
60
  if (!ready && !buildInProgress) {
58
61
  if (latestBuildJob) {
59
62
  const indexInfo = {
@@ -93,6 +96,51 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
93
96
  message: 'Code index is not built. Please run UnityMCP.build_index first.'
94
97
  };
95
98
  }
99
+ if (!ready && buildInProgress) {
100
+ const buildJob = latestBuildJob
101
+ ? {
102
+ id: latestBuildJob.id,
103
+ status: latestBuildJob.status,
104
+ startedAt: latestBuildJob.startedAt ?? null,
105
+ ...(latestBuildJob.progress
106
+ ? {
107
+ progress: {
108
+ phase: latestBuildJob.progress.phase || 'index',
109
+ ...latestBuildJob.progress
110
+ }
111
+ }
112
+ : {})
113
+ }
114
+ : {
115
+ id: null,
116
+ status: 'running',
117
+ startedAt: null,
118
+ source: 'worker'
119
+ };
120
+
121
+ if (latestBuildJob?.status === 'failed') {
122
+ buildJob.failedAt = latestBuildJob.failedAt ?? null;
123
+ buildJob.error = latestBuildJob.error ?? 'Unknown error';
124
+ }
125
+
126
+ return {
127
+ success: true,
128
+ status: latestBuildJob?.status === 'failed' ? 'failed' : 'pending',
129
+ ready: false,
130
+ totalFiles: 0,
131
+ indexedFiles: 0,
132
+ coverage: 0,
133
+ message: buildInProgress
134
+ ? 'Code index build is running. Check back with get_index_status.'
135
+ : 'Code index is not ready yet.',
136
+ index: {
137
+ ready: false,
138
+ rows: 0,
139
+ lastIndexedAt: null,
140
+ buildJob
141
+ }
142
+ };
143
+ }
96
144
 
97
145
  // Use DB stats directly for fast status check - avoid expensive filesystem traversal
98
146
  const stats = await this.codeIndex.getStats();
@@ -109,7 +157,9 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
109
157
  };
110
158
 
111
159
  if (latestBuildJob) {
112
- const progress = latestBuildJob.progress ? { ...latestBuildJob.progress } : undefined;
160
+ const progress = latestBuildJob.progress
161
+ ? { phase: latestBuildJob.progress.phase || 'index', ...latestBuildJob.progress }
162
+ : undefined;
113
163
  indexInfo.buildJob = {
114
164
  id: latestBuildJob.id,
115
165
  status: latestBuildJob.status,
@@ -5,6 +5,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
6
  import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
7
  import { preSyntaxCheck } from './csharpSyntaxCheck.js';
8
+ import { logger } from '../../core/config.js';
8
9
 
9
10
  const MAX_INSTRUCTIONS = 10;
10
11
  const MAX_DIFF_CHARS = 80;
@@ -29,11 +30,6 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
29
30
  description:
30
31
  'If true, run validation and return preview text without writing to disk. Default=false.'
31
32
  },
32
- skipValidation: {
33
- type: 'boolean',
34
- description:
35
- 'If true, skip LSP validation for faster execution. Lightweight syntax checks (brace balance) are still performed. Use for simple edits on large files. Default=false.'
36
- },
37
33
  instructions: {
38
34
  type: 'array',
39
35
  minItems: 1,
@@ -84,7 +80,10 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
84
80
 
85
81
  validate(params) {
86
82
  super.validate(params);
87
- const { path: filePath, instructions } = params;
83
+ const { path: filePath, instructions, skipValidation } = params;
84
+ if (skipValidation === true) {
85
+ throw new Error('skipValidation is not allowed; LSP validation is required');
86
+ }
88
87
  if (!filePath || String(filePath).trim() === '') {
89
88
  throw new Error('path cannot be empty');
90
89
  }
@@ -115,10 +114,17 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
115
114
  }
116
115
 
117
116
  async execute(params) {
117
+ logger.info(
118
+ `[Handler edit_snippet] pid=${process.pid} path=${params?.path || ''} preview=${
119
+ params?.preview === true
120
+ }`
121
+ );
122
+ if (params?.skipValidation === true) {
123
+ throw new Error('skipValidation is not allowed; LSP validation is required');
124
+ }
118
125
  const info = await this.projectInfo.get();
119
126
  const { relative, absolute } = this.#resolvePaths(info, params.path);
120
127
  const preview = params.preview === true;
121
- const skipValidation = params.skipValidation === true;
122
128
  const instructions = params.instructions;
123
129
 
124
130
  let original;
@@ -152,7 +158,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
152
158
  results,
153
159
  original,
154
160
  updated: working,
155
- validationSkipped: skipValidation
161
+ validationSkipped: false
156
162
  });
157
163
  }
158
164
 
@@ -165,16 +171,14 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
165
171
  );
166
172
  }
167
173
 
168
- // LSP validation (skip if skipValidation=true for large files)
174
+ // LSP validation
169
175
  let diagnostics = [];
170
- if (!skipValidation) {
171
- diagnostics = await this.#validateWithLsp(info, relative, working);
172
- const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
173
- if (hasErrors) {
174
- const first = diagnostics.find(d => this.#severityIsError(d.severity));
175
- const msg = first?.message || 'syntax error';
176
- throw new Error(`syntax_error: ${msg}`);
177
- }
176
+ diagnostics = await this.#validateWithLsp(info, relative, working);
177
+ const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
178
+ if (hasErrors) {
179
+ const first = diagnostics.find(d => this.#severityIsError(d.severity));
180
+ const msg = first?.message || 'syntax error';
181
+ throw new Error(`syntax_error: ${msg}`);
178
182
  }
179
183
 
180
184
  if (!preview) {
@@ -187,7 +191,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
187
191
  original,
188
192
  updated: working,
189
193
  diagnostics,
190
- validationSkipped: skipValidation
194
+ validationSkipped: false
191
195
  });
192
196
  }
193
197
 
@@ -298,7 +302,30 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
298
302
  if (!this.lsp) {
299
303
  this.lsp = await LspRpcClientSingleton.getValidationInstance(info.projectRoot);
300
304
  }
301
- return await this.lsp.validateText(relative, updatedText);
305
+ const tempRelative = this.#buildTempValidationPath(relative);
306
+ const tempAbsolute = path.join(
307
+ info.projectRoot,
308
+ tempRelative.replace(/\//g, path.sep)
309
+ );
310
+ await fs.mkdir(path.dirname(tempAbsolute), { recursive: true });
311
+ await fs.writeFile(tempAbsolute, updatedText, 'utf8');
312
+ try {
313
+ return await this.lsp.validateText(tempRelative, '');
314
+ } finally {
315
+ try {
316
+ await fs.rm(tempAbsolute, { force: true });
317
+ } catch (e) {
318
+ logger.warning(`[Handler edit_snippet] failed to remove temp file: ${e.message}`);
319
+ }
320
+ }
321
+ }
322
+
323
+ #buildTempValidationPath(relative) {
324
+ const ext = path.extname(relative) || '.cs';
325
+ const base = path.basename(relative, ext).replace(/[^a-zA-Z0-9._-]/g, '_');
326
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
327
+ const hash = crypto.createHash('sha1').update(relative).digest('hex').slice(0, 8);
328
+ return `.unity/tmp/edit-snippet/${base}_${hash}_${stamp}${ext}`;
302
329
  }
303
330
 
304
331
  #buildResponse({
@@ -351,4 +378,5 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
351
378
  const s = String(severity).toLowerCase();
352
379
  return s === 'error' || s === '2';
353
380
  }
381
+
354
382
  }
@@ -0,0 +1,55 @@
1
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
+ import { CATEGORIES, SCOPES } from '../base/categories.js';
3
+ import { ProjectInfoProvider } from '../../core/projectInfo.js';
4
+ import { config, WORKSPACE_ROOT } from '../../core/config.js';
5
+
6
+ /**
7
+ * Handler for the get_server_info tool
8
+ * Provides identifiers to distinguish multiple MCP servers
9
+ */
10
+ export class SystemGetServerInfoToolHandler extends BaseToolHandler {
11
+ constructor(unityConnection) {
12
+ super(
13
+ 'get_server_info',
14
+ 'Get MCP server identifying information (pid, project root, workspace)',
15
+ {
16
+ type: 'object',
17
+ properties: {}
18
+ },
19
+ {
20
+ category: CATEGORIES.SYSTEM,
21
+ scope: SCOPES.READ,
22
+ keywords: ['server', 'info', 'pid', 'project', 'workspace', 'identify'],
23
+ tags: ['system', 'diagnostic']
24
+ }
25
+ );
26
+
27
+ this.unityConnection = unityConnection;
28
+ this.projectInfo = new ProjectInfoProvider();
29
+ }
30
+
31
+ async execute() {
32
+ const info = await this.projectInfo.get();
33
+ return {
34
+ success: true,
35
+ pid: process.pid,
36
+ projectRoot: info.projectRoot,
37
+ assetsPath: info.assetsPath,
38
+ packagesPath: info.packagesPath,
39
+ codeIndexRoot: info.codeIndexRoot,
40
+ workspaceRoot: WORKSPACE_ROOT,
41
+ server: {
42
+ name: config?.server?.name,
43
+ version: config?.server?.version
44
+ },
45
+ unity: {
46
+ host: config?.unity?.unityHost ?? config?.unity?.mcpHost,
47
+ port: config?.unity?.port
48
+ },
49
+ http: {
50
+ enabled: config?.http?.enabled,
51
+ port: config?.http?.port
52
+ }
53
+ };
54
+ }
55
+ }
@@ -152,12 +152,19 @@ export class LspRpcClient {
152
152
  return resp;
153
153
  }
154
154
 
155
- async request(method, params) {
156
- return await this.#requestWithRetry(method, params, 1);
155
+ async request(method, params, options = {}) {
156
+ return await this.#requestWithRetry(method, params, 1, options);
157
157
  }
158
158
 
159
159
  async validateText(relative, newText) {
160
- const resp = await this.request('mcp/validateTextEdits', { relative, newText });
160
+ const timeoutMs = Number.isFinite(config.lsp?.validationTimeoutMs)
161
+ ? config.lsp.validationTimeoutMs
162
+ : undefined;
163
+ const resp = await this.request(
164
+ 'mcp/validateTextEdits',
165
+ { relative, newText },
166
+ { timeoutMs }
167
+ );
161
168
  if (!resp) return [];
162
169
  const payload = resp.result ?? resp;
163
170
  const diagnostics = Array.isArray(payload?.diagnostics) ? payload.diagnostics : [];
@@ -170,10 +177,14 @@ export class LspRpcClient {
170
177
  }));
171
178
  }
172
179
 
173
- async #requestWithRetry(method, params, attempt) {
180
+ async #requestWithRetry(method, params, attempt, options) {
174
181
  let id = null;
175
182
  let timeoutHandle = null;
176
- const timeoutMs = Math.max(1000, Math.min(300000, config.lsp?.requestTimeoutMs || 60000));
183
+ const configuredTimeout = config.lsp?.requestTimeoutMs || 60000;
184
+ const overrideTimeout = options?.timeoutMs;
185
+ const timeoutMs = Number.isFinite(overrideTimeout)
186
+ ? Math.max(1000, Math.min(300000, overrideTimeout))
187
+ : Math.max(1000, Math.min(300000, configuredTimeout));
177
188
  const startedAt = Date.now();
178
189
  try {
179
190
  await this.ensure();
@@ -227,7 +238,7 @@ export class LspRpcClient {
227
238
  logger.warning(
228
239
  `[unity-mcp-server:lsp] recoverable error on ${method}: ${msg}. Retrying once...`
229
240
  );
230
- return await this.#requestWithRetry(method, params, attempt + 1);
241
+ return await this.#requestWithRetry(method, params, attempt + 1, options);
231
242
  }
232
243
  // Standardize error message with actionable recovery instructions
233
244
  let hint;