@akiojin/unity-mcp-server 2.38.0 → 2.39.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/README.md CHANGED
@@ -4,7 +4,8 @@ MCP (Model Context Protocol) server for Unity Editor integration. Enables AI ass
4
4
 
5
5
  ## Features
6
6
 
7
- - **70 comprehensive tools** across 13 categories for Unity Editor automation
7
+ - **106 comprehensive tools** across 16+ categories for Unity Editor automation
8
+ - **Tool discovery** - Efficient `search_tools` meta-tool for discovering relevant tools (96.2% token reduction)
8
9
  - **GameObject management** - Create, find, modify, delete GameObjects with full hierarchy control
9
10
  - **Component system** - Add, remove, modify components with property control
10
11
  - **Scene management** - Create, load, save, list scenes with build settings integration
@@ -13,10 +14,66 @@ MCP (Model Context Protocol) server for Unity Editor integration. Enables AI ass
13
14
  - **UI automation** - Find, click, and interact with UI elements programmatically
14
15
  - **Input simulation** - Simulate keyboard, mouse, gamepad, and touch input
15
16
  - **Play mode controls** - Start, pause, stop Unity play mode for testing
17
+ - **Performance profiling** - Record profiling sessions, collect metrics, save .data files for analysis
16
18
  - **Project settings** - Read and update Unity project settings safely
17
- - **Editor operations** - Console logs, screenshots, compilation monitoring
19
+ - **Editor operations** - Console logs, screenshots, video capture, compilation monitoring
18
20
  - **Editor control** - Manage tags, layers, selection, windows, and tools
19
21
 
22
+ ## Tool Discovery
23
+
24
+ Unity MCP Server provides a **`search_tools`** meta-tool for efficient tool discovery, helping you find relevant tools quickly.
25
+
26
+ ### Usage Examples
27
+
28
+ ```javascript
29
+ // Find tools for GameObject manipulation
30
+ {
31
+ "tool": "search_tools",
32
+ "params": {
33
+ "query": "gameobject",
34
+ "limit": 10
35
+ }
36
+ }
37
+ // Returns: gameobject_create, gameobject_find, gameobject_modify, ...
38
+
39
+ // Filter by category
40
+ {
41
+ "tool": "search_tools",
42
+ "params": {
43
+ "category": "scene",
44
+ "limit": 10
45
+ }
46
+ }
47
+ // Returns: scene_create, scene_load, scene_save, scene_list, scene_info_get
48
+
49
+ // Filter by tags
50
+ {
51
+ "tool": "search_tools",
52
+ "params": {
53
+ "tags": ["create", "asset"],
54
+ "limit": 5
55
+ }
56
+ }
57
+ // Returns tools that create assets
58
+
59
+ // Include full input schemas (when needed)
60
+ {
61
+ "tool": "search_tools",
62
+ "params": {
63
+ "query": "screenshot",
64
+ "includeSchemas": true
65
+ }
66
+ }
67
+ // Returns full tool definitions with inputSchema
68
+ ```
69
+
70
+ ### Benefits
71
+
72
+ - **Smart filtering** - Search by keywords, categories, tags, or scope (read/write/execute)
73
+ - **Relevance scoring** - Results sorted by relevance to your query
74
+ - **On-demand schemas** - Full inputSchema only when explicitly requested
75
+ - **Easy discovery** - Find the right tool without browsing all 103 tools manually
76
+
20
77
  ## Quick Start
21
78
 
22
79
  ### Using npx (Recommended)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "2.38.0",
3
+ "version": "2.39.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",
@@ -17,6 +17,32 @@ function merge(a, b) {
17
17
  return out;
18
18
  }
19
19
 
20
+ function resolvePackageVersion() {
21
+ const candidates = [];
22
+
23
+ // Resolve relative to this module (always inside mcp-server/src/core)
24
+ try {
25
+ const moduleDir = path.dirname(new URL(import.meta.url).pathname);
26
+ candidates.push(path.resolve(moduleDir, '../../package.json'));
27
+ } catch {}
28
+
29
+ // When executed from workspace root (monorepo) or inside mcp-server package
30
+ try {
31
+ const here = findUpSync('package.json', { cwd: process.cwd() });
32
+ if (here) candidates.push(here);
33
+ } catch {}
34
+
35
+ for (const candidate of candidates) {
36
+ try {
37
+ if (!candidate || !fs.existsSync(candidate)) continue;
38
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
39
+ if (pkg?.version) return pkg.version;
40
+ } catch {}
41
+ }
42
+
43
+ return '0.1.0';
44
+ }
45
+
20
46
  /**
21
47
  * Base configuration for Unity MCP Server Server
22
48
  */
@@ -44,7 +70,7 @@ const baseConfig = {
44
70
  // Server settings
45
71
  server: {
46
72
  name: 'unity-mcp-server',
47
- version: '0.1.0',
73
+ version: resolvePackageVersion(),
48
74
  description: 'MCP server for Unity Editor integration'
49
75
  },
50
76
 
@@ -74,11 +74,19 @@ function sanitizeSchema(schema) {
74
74
  }
75
75
 
76
76
  export class BaseToolHandler {
77
- constructor(name, description, inputSchema = {}) {
77
+ constructor(name, description, inputSchema = {}, metadata = {}) {
78
78
  this.name = name;
79
79
  this.description = description;
80
80
  const clonedSchema = cloneSchema(inputSchema) || {};
81
81
  this.inputSchema = sanitizeSchema(clonedSchema);
82
+
83
+ // Metadata for search_tools optimization
84
+ this.metadata = {
85
+ category: metadata.category || 'general',
86
+ scope: metadata.scope || 'read',
87
+ keywords: metadata.keywords || [],
88
+ tags: metadata.tags || []
89
+ };
82
90
  }
83
91
 
84
92
  /**
@@ -191,13 +199,20 @@ export class BaseToolHandler {
191
199
 
192
200
  /**
193
201
  * Returns the tool definition for MCP
202
+ * @param {boolean} includeMetadata - Include metadata in the definition
194
203
  * @returns {object} Tool definition
195
204
  */
196
- getDefinition() {
197
- return {
205
+ getDefinition(includeMetadata = false) {
206
+ const definition = {
198
207
  name: this.name,
199
208
  description: this.description,
200
209
  inputSchema: this.inputSchema
201
210
  };
211
+
212
+ if (includeMetadata) {
213
+ definition.metadata = this.metadata;
214
+ }
215
+
216
+ return definition;
202
217
  }
203
218
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tool metadata categories and constants for search_tools optimization
3
+ */
4
+
5
+ /**
6
+ * Tool categories for grouping related functionality
7
+ */
8
+ export const CATEGORIES = {
9
+ SYSTEM: 'system',
10
+ GAMEOBJECT: 'gameobject',
11
+ SCENE: 'scene',
12
+ ANALYSIS: 'analysis',
13
+ PLAYMODE: 'playmode',
14
+ UI: 'ui',
15
+ INPUT: 'input',
16
+ ASSET: 'asset',
17
+ PREFAB: 'prefab',
18
+ MATERIAL: 'material',
19
+ ADDRESSABLES: 'addressables',
20
+ MENU: 'menu',
21
+ CONSOLE: 'console',
22
+ SCREENSHOT: 'screenshot',
23
+ VIDEO: 'video',
24
+ COMPONENT: 'component',
25
+ COMPILATION: 'compilation',
26
+ TEST: 'test',
27
+ EDITOR: 'editor',
28
+ SETTINGS: 'settings',
29
+ PACKAGE: 'package',
30
+ SCRIPT: 'script'
31
+ };
32
+
33
+ /**
34
+ * Tool scopes indicating read/write/execute permissions
35
+ */
36
+ export const SCOPES = {
37
+ READ: 'read',
38
+ WRITE: 'write',
39
+ EXECUTE: 'execute'
40
+ };
41
+
42
+ /**
43
+ * Common tags for cross-cutting concerns
44
+ */
45
+ export const TAGS = {
46
+ // Operation types
47
+ CREATE: 'create',
48
+ READ: 'read',
49
+ UPDATE: 'update',
50
+ DELETE: 'delete',
51
+ QUERY: 'query',
52
+ ANALYZE: 'analyze',
53
+
54
+ // Scope tags
55
+ HIERARCHY: 'hierarchy',
56
+ PREFAB_MODE: 'prefab_mode',
57
+ PLAYMODE: 'playmode',
58
+ EDITOR_ONLY: 'editor_only',
59
+
60
+ // Resource types
61
+ ASSET: 'asset',
62
+ SCENE: 'scene',
63
+ RUNTIME: 'runtime',
64
+
65
+ // Input/Output
66
+ INPUT: 'input',
67
+ OUTPUT: 'output',
68
+ VISUAL: 'visual'
69
+ };
@@ -80,6 +80,10 @@ import { VideoCaptureStartToolHandler } from './video/VideoCaptureStartToolHandl
80
80
  import { VideoCaptureStopToolHandler } from './video/VideoCaptureStopToolHandler.js';
81
81
  import { VideoCaptureStatusToolHandler } from './video/VideoCaptureStatusToolHandler.js';
82
82
  import { VideoCaptureForToolHandler } from './video/VideoCaptureForToolHandler.js';
83
+ import { ProfilerStartToolHandler } from './profiler/ProfilerStartToolHandler.js';
84
+ import { ProfilerStopToolHandler } from './profiler/ProfilerStopToolHandler.js';
85
+ import { ProfilerStatusToolHandler } from './profiler/ProfilerStatusToolHandler.js';
86
+ import { ProfilerGetMetricsToolHandler } from './profiler/ProfilerGetMetricsToolHandler.js';
83
87
  import { ComponentAddToolHandler } from './component/ComponentAddToolHandler.js';
84
88
  import { ComponentRemoveToolHandler } from './component/ComponentRemoveToolHandler.js';
85
89
  import { ComponentModifyToolHandler } from './component/ComponentModifyToolHandler.js';
@@ -113,7 +117,9 @@ import { ScriptCreateClassToolHandler } from './script/ScriptCreateClassToolHand
113
117
  import { ScriptRemoveSymbolToolHandler } from './script/ScriptRemoveSymbolToolHandler.js';
114
118
  import { CodeIndexUpdateToolHandler } from './script/CodeIndexUpdateToolHandler.js';
115
119
  import { CodeIndexBuildToolHandler } from './script/CodeIndexBuildToolHandler.js';
120
+ import { SearchToolsHandler } from './search/SearchToolsHandler.js';
116
121
  export { BaseToolHandler } from './base/BaseToolHandler.js';
122
+ export { SearchToolsHandler } from './search/SearchToolsHandler.js';
117
123
 
118
124
  // System handlers
119
125
  export { SystemPingToolHandler } from './system/SystemPingToolHandler.js';
@@ -214,6 +220,12 @@ export { VideoCaptureStopToolHandler } from './video/VideoCaptureStopToolHandler
214
220
  export { VideoCaptureStatusToolHandler } from './video/VideoCaptureStatusToolHandler.js';
215
221
  export { VideoCaptureForToolHandler } from './video/VideoCaptureForToolHandler.js';
216
222
 
223
+ // Profiler handlers
224
+ export { ProfilerStartToolHandler } from './profiler/ProfilerStartToolHandler.js';
225
+ export { ProfilerStopToolHandler } from './profiler/ProfilerStopToolHandler.js';
226
+ export { ProfilerStatusToolHandler } from './profiler/ProfilerStatusToolHandler.js';
227
+ export { ProfilerGetMetricsToolHandler } from './profiler/ProfilerGetMetricsToolHandler.js';
228
+
217
229
  // Component handlers
218
230
  export { ComponentAddToolHandler } from './component/ComponentAddToolHandler.js';
219
231
  export { ComponentRemoveToolHandler } from './component/ComponentRemoveToolHandler.js';
@@ -356,6 +368,11 @@ const HANDLER_CLASSES = [
356
368
  VideoCaptureStopToolHandler,
357
369
  VideoCaptureStatusToolHandler,
358
370
  VideoCaptureForToolHandler,
371
+ // Profiler handlers
372
+ ProfilerStartToolHandler,
373
+ ProfilerStopToolHandler,
374
+ ProfilerStatusToolHandler,
375
+ ProfilerGetMetricsToolHandler,
359
376
  // Script handlers
360
377
  ScriptPackagesListToolHandler,
361
378
  ScriptReadToolHandler,
@@ -425,6 +442,15 @@ export function createHandlers(unityConnection) {
425
442
  }
426
443
  }
427
444
 
445
+ // Add SearchToolsHandler with reference to all handlers
446
+ try {
447
+ const searchHandler = new SearchToolsHandler(unityConnection, handlers);
448
+ handlers.set(searchHandler.name, searchHandler);
449
+ } catch (error) {
450
+ failedHandlers.push('SearchToolsHandler');
451
+ console.error(`[MCP] Failed to create SearchToolsHandler:`, error.message);
452
+ }
453
+
428
454
  if (failedHandlers.length > 0) {
429
455
  console.error(
430
456
  `[MCP] Failed to initialize ${failedHandlers.length} handlers: ${failedHandlers.join(', ')}`
@@ -432,7 +458,7 @@ export function createHandlers(unityConnection) {
432
458
  }
433
459
 
434
460
  console.error(
435
- `[MCP] Successfully initialized ${handlers.size}/${HANDLER_CLASSES.length} handlers`
461
+ `[MCP] Successfully initialized ${handlers.size}/${HANDLER_CLASSES.length + 1} handlers (including search_tools)`
436
462
  );
437
463
 
438
464
  return handlers;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Handler for getting Unity Profiler metrics (via MCP)
3
+ */
4
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
+
6
+ export class ProfilerGetMetricsToolHandler extends BaseToolHandler {
7
+ constructor(unityConnection) {
8
+ super(
9
+ 'profiler_get_metrics',
10
+ 'Get available profiler metrics or current metric values. Can list all available metrics by category, or query current values of specific metrics.',
11
+ {
12
+ type: 'object',
13
+ properties: {
14
+ listAvailable: {
15
+ type: 'boolean',
16
+ default: false,
17
+ description:
18
+ 'If true, return list of all available metrics grouped by category. If false, return current values of specified metrics.'
19
+ },
20
+ metrics: {
21
+ type: 'array',
22
+ items: { type: 'string' },
23
+ description:
24
+ "Specific metrics to query (only used when listAvailable=false). Leave empty to get all current metric values. Examples: 'System Used Memory', 'Draw Calls Count'"
25
+ }
26
+ }
27
+ }
28
+ );
29
+ this.unityConnection = unityConnection;
30
+ }
31
+
32
+ /** @override */
33
+ async execute(params, _context) {
34
+ // Validate metrics parameter
35
+ if (params?.metrics !== undefined && !Array.isArray(params.metrics)) {
36
+ return {
37
+ error: 'Invalid metrics parameter. Must be an array of strings.',
38
+ code: 'E_INVALID_PARAMETER'
39
+ };
40
+ }
41
+
42
+ if (Array.isArray(params?.metrics) && params.metrics.some(m => typeof m !== 'string')) {
43
+ return {
44
+ error: 'Invalid metrics parameter. All elements must be strings.',
45
+ code: 'E_INVALID_PARAMETER'
46
+ };
47
+ }
48
+
49
+ const result = await this.unityConnection.sendCommand('profiler_get_metrics', params || {});
50
+ return result;
51
+ }
52
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Handler for starting Unity Profiler recording session (via MCP)
3
+ */
4
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
+
6
+ export class ProfilerStartToolHandler extends BaseToolHandler {
7
+ constructor(unityConnection) {
8
+ super(
9
+ 'profiler_start',
10
+ 'Start Unity Profiler recording session. Records CPU, memory, rendering, and GC metrics. Data can be saved to .data file for later analysis in Unity Profiler Window.',
11
+ {
12
+ type: 'object',
13
+ properties: {
14
+ mode: {
15
+ type: 'string',
16
+ enum: ['normal', 'deep'],
17
+ default: 'normal',
18
+ description:
19
+ "Profiling mode. 'normal' for standard profiling, 'deep' for deep profiling (more detailed but higher overhead)"
20
+ },
21
+ recordToFile: {
22
+ type: 'boolean',
23
+ default: true,
24
+ description:
25
+ 'Save profiling data to .data file in .unity/capture/ directory. Set to false for real-time metrics only.'
26
+ },
27
+ metrics: {
28
+ type: 'array',
29
+ items: { type: 'string' },
30
+ description:
31
+ "Specific metrics to record. Leave empty to record all available metrics. Examples: 'System Used Memory', 'Draw Calls Count', 'GC Allocated In Frame'"
32
+ },
33
+ maxDurationSec: {
34
+ type: 'number',
35
+ minimum: 0,
36
+ description:
37
+ 'Auto-stop profiling after N seconds. Set to 0 for unlimited recording (manual stop required).'
38
+ }
39
+ }
40
+ }
41
+ );
42
+ this.unityConnection = unityConnection;
43
+ }
44
+
45
+ /** @override */
46
+ async execute(params, _context) {
47
+ // Validate mode parameter
48
+ if (params?.mode && !['normal', 'deep'].includes(params.mode)) {
49
+ return {
50
+ error: `Invalid mode '${params.mode}'. Must be 'normal' or 'deep'.`,
51
+ code: 'E_INVALID_MODE'
52
+ };
53
+ }
54
+
55
+ // Validate maxDurationSec parameter
56
+ if (params?.maxDurationSec !== undefined && params.maxDurationSec < 0) {
57
+ return {
58
+ error: `Invalid maxDurationSec '${params.maxDurationSec}'. Must be >= 0.`,
59
+ code: 'E_INVALID_PARAMETER'
60
+ };
61
+ }
62
+
63
+ // Validate metrics parameter
64
+ if (params?.metrics !== undefined && !Array.isArray(params.metrics)) {
65
+ return {
66
+ error: 'Invalid metrics parameter. Must be an array of strings.',
67
+ code: 'E_INVALID_PARAMETER'
68
+ };
69
+ }
70
+
71
+ if (Array.isArray(params?.metrics) && params.metrics.some(m => typeof m !== 'string')) {
72
+ return {
73
+ error: 'Invalid metrics parameter. All elements must be strings.',
74
+ code: 'E_INVALID_PARAMETER'
75
+ };
76
+ }
77
+
78
+ const result = await this.unityConnection.sendCommand('profiler_start', params || {});
79
+ return result;
80
+ }
81
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Handler for getting Unity Profiler recording status (via MCP)
3
+ */
4
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
+
6
+ export class ProfilerStatusToolHandler extends BaseToolHandler {
7
+ constructor(unityConnection) {
8
+ super(
9
+ 'profiler_status',
10
+ 'Get current Unity Profiler recording status. Returns information about active session, elapsed time, and remaining time (if auto-stop is configured).',
11
+ {
12
+ type: 'object',
13
+ properties: {}
14
+ }
15
+ );
16
+ this.unityConnection = unityConnection;
17
+ }
18
+
19
+ /** @override */
20
+ async execute(params, _context) {
21
+ const result = await this.unityConnection.sendCommand('profiler_status', params || {});
22
+ return result;
23
+ }
24
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Handler for stopping Unity Profiler recording session (via MCP)
3
+ */
4
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
+
6
+ export class ProfilerStopToolHandler extends BaseToolHandler {
7
+ constructor(unityConnection) {
8
+ super(
9
+ 'profiler_stop',
10
+ 'Stop Unity Profiler recording and save data to .data file. Returns profiling session summary including duration, frame count, and optionally recorded metrics.',
11
+ {
12
+ type: 'object',
13
+ properties: {
14
+ sessionId: {
15
+ type: 'string',
16
+ description:
17
+ 'Optional session ID to stop. If not provided, stops the current active session.'
18
+ }
19
+ }
20
+ }
21
+ );
22
+ this.unityConnection = unityConnection;
23
+ }
24
+
25
+ /** @override */
26
+ async execute(params, _context) {
27
+ // Validate sessionId format (32 hex characters, no hyphens)
28
+ if (params?.sessionId) {
29
+ if (typeof params.sessionId !== 'string') {
30
+ return {
31
+ error: 'Invalid sessionId parameter. Must be a string.',
32
+ code: 'E_INVALID_PARAMETER'
33
+ };
34
+ }
35
+ if (!/^[0-9a-f]{32}$/.test(params.sessionId)) {
36
+ return {
37
+ error: `Invalid sessionId format '${params.sessionId}'. Must be 32 hex characters without hyphens.`,
38
+ code: 'E_INVALID_PARAMETER'
39
+ };
40
+ }
41
+ }
42
+
43
+ const result = await this.unityConnection.sendCommand('profiler_stop', params || {});
44
+ return result;
45
+ }
46
+ }
@@ -9,7 +9,7 @@ const MAX_INSTRUCTIONS = 10;
9
9
  const MAX_DIFF_CHARS = 80;
10
10
  const PREVIEW_MAX = 1000;
11
11
 
12
- const normalizeSlashes = (p) => p.replace(/\\/g, '/');
12
+ const normalizeSlashes = p => p.replace(/\\/g, '/');
13
13
 
14
14
  export class ScriptEditSnippetToolHandler extends BaseToolHandler {
15
15
  constructor(unityConnection) {
@@ -21,11 +21,13 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
21
21
  properties: {
22
22
  path: {
23
23
  type: 'string',
24
- description: 'Project-relative C# path starting with Assets/ or Packages/ (e.g., Assets/Scripts/Foo.cs).'
24
+ description:
25
+ 'Project-relative C# path starting with Assets/ or Packages/ (e.g., Assets/Scripts/Foo.cs).'
25
26
  },
26
27
  preview: {
27
28
  type: 'boolean',
28
- description: 'If true, run validation and return preview text without writing to disk. Default=false.'
29
+ description:
30
+ 'If true, run validation and return preview text without writing to disk. Default=false.'
29
31
  },
30
32
  instructions: {
31
33
  type: 'array',
@@ -42,14 +44,19 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
42
44
  },
43
45
  anchor: {
44
46
  type: 'object',
45
- description: 'Positioning info. Currently supports type=text with exact target snippet.',
47
+ description:
48
+ 'Positioning info. Currently supports type=text with exact target snippet.',
46
49
  properties: {
47
50
  type: { type: 'string', enum: ['text'], default: 'text' },
48
- target: { type: 'string', description: 'Exact snippet to locate (including whitespace).' },
51
+ target: {
52
+ type: 'string',
53
+ description: 'Exact snippet to locate (including whitespace).'
54
+ },
49
55
  position: {
50
56
  type: 'string',
51
57
  enum: ['before', 'after'],
52
- description: 'For insert operations, whether to insert before or after the anchor text (default=after).'
58
+ description:
59
+ 'For insert operations, whether to insert before or after the anchor text (default=after).'
53
60
  }
54
61
  },
55
62
  required: ['type', 'target']
@@ -150,9 +157,12 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
150
157
  const normalized = normalizeSlashes(String(rawPath));
151
158
  const idxAssets = normalized.indexOf('Assets/');
152
159
  const idxPackages = normalized.indexOf('Packages/');
153
- const idx = (idxAssets >= 0 && idxPackages >= 0)
154
- ? Math.min(idxAssets, idxPackages)
155
- : (idxAssets >= 0 ? idxAssets : idxPackages);
160
+ const idx =
161
+ idxAssets >= 0 && idxPackages >= 0
162
+ ? Math.min(idxAssets, idxPackages)
163
+ : idxAssets >= 0
164
+ ? idxAssets
165
+ : idxPackages;
156
166
  const relative = idx >= 0 ? normalized.substring(idx) : normalized;
157
167
 
158
168
  if (!relative.startsWith('Assets/') && !relative.startsWith('Packages/')) {
@@ -167,20 +177,27 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
167
177
  #applyInstruction(text, instruction, index) {
168
178
  const anchor = instruction.anchor;
169
179
  const target = anchor.target;
180
+
181
+ // Normalize line endings to LF for consistent matching
182
+ const normalizedText = text.replace(/\r\n/g, '\n');
183
+ const normalizedTarget = target.replace(/\r\n/g, '\n');
184
+
170
185
  const occurrences = [];
171
- let pos = text.indexOf(target);
186
+ let pos = normalizedText.indexOf(normalizedTarget);
172
187
  while (pos !== -1) {
173
188
  occurrences.push(pos);
174
- pos = text.indexOf(target, pos + 1);
189
+ pos = normalizedText.indexOf(normalizedTarget, pos + 1);
175
190
  }
176
191
  if (occurrences.length === 0) {
177
192
  throw new Error(`anchor_not_found: instructions[${index}]`);
178
193
  }
179
194
  if (occurrences.length > 1) {
180
- throw new Error(`anchor_not_unique: instructions[${index}] matches ${occurrences.length} locations`);
195
+ throw new Error(
196
+ `anchor_not_unique: instructions[${index}] matches ${occurrences.length} locations`
197
+ );
181
198
  }
182
199
  const start = occurrences[0];
183
- const end = start + target.length;
200
+ const end = start + normalizedTarget.length;
184
201
 
185
202
  let replacement = '';
186
203
  if (instruction.operation === 'delete') {
@@ -190,14 +207,30 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
190
207
  } else if (instruction.operation === 'insert') {
191
208
  const position = (instruction.anchor.position || 'after').toLowerCase();
192
209
  if (position === 'before') {
193
- return this.#replaceRange(text, start, start, instruction.newText, target, index);
210
+ return this.#replaceRange(
211
+ normalizedText,
212
+ start,
213
+ start,
214
+ instruction.newText,
215
+ normalizedTarget,
216
+ index
217
+ );
194
218
  }
195
219
  if (position !== 'after') {
196
- throw new Error(`Unsupported anchor.position "${instruction.anchor.position}" at instructions[${index}]`);
220
+ throw new Error(
221
+ `Unsupported anchor.position "${instruction.anchor.position}" at instructions[${index}]`
222
+ );
197
223
  }
198
- return this.#replaceRange(text, end, end, instruction.newText, target, index);
224
+ return this.#replaceRange(
225
+ normalizedText,
226
+ end,
227
+ end,
228
+ instruction.newText,
229
+ normalizedTarget,
230
+ index
231
+ );
199
232
  }
200
- return this.#replaceRange(text, start, end, replacement, target, index);
233
+ return this.#replaceRange(normalizedText, start, end, replacement, normalizedTarget, index);
201
234
  }
202
235
 
203
236
  #replaceRange(text, start, end, newText, anchorTarget, index) {
@@ -253,11 +286,14 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
253
286
 
254
287
  #clipSnippet(s) {
255
288
  if (!s) return '';
256
- return s.length > 120 ? (s.slice(0, 117) + '…') : s;
289
+ return s.length > 120 ? s.slice(0, 117) + '…' : s;
257
290
  }
258
291
 
259
292
  #hash(content) {
260
- return crypto.createHash('sha256').update(content ?? '', 'utf8').digest('hex');
293
+ return crypto
294
+ .createHash('sha256')
295
+ .update(content ?? '', 'utf8')
296
+ .digest('hex');
261
297
  }
262
298
 
263
299
  #severityIsError(severity) {
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Handler for searching available tools by keywords, categories, or tags
3
+ * This meta-tool enables token-efficient tool discovery
4
+ */
5
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
6
+ import { CATEGORIES, SCOPES } from '../base/categories.js';
7
+
8
+ export class SearchToolsHandler extends BaseToolHandler {
9
+ constructor(unityConnection, handlersMap) {
10
+ super(
11
+ 'search_tools',
12
+ 'Search available Unity MCP tools by keywords, categories, or tags. Use this to discover relevant tools before calling them.',
13
+ {
14
+ type: 'object',
15
+ properties: {
16
+ query: {
17
+ type: 'string',
18
+ description:
19
+ 'Search query (keywords to match against tool names, descriptions, and metadata)'
20
+ },
21
+ category: {
22
+ type: 'string',
23
+ description: `Filter by category (${Object.values(CATEGORIES).join(', ')})`
24
+ },
25
+ tags: {
26
+ type: 'array',
27
+ items: { type: 'string' },
28
+ description: 'Filter by tags (e.g., ["create", "scene"], ["read", "gameobject"])'
29
+ },
30
+ scope: {
31
+ type: 'string',
32
+ enum: Object.values(SCOPES),
33
+ description: 'Filter by scope: read, write, or execute'
34
+ },
35
+ limit: {
36
+ type: 'number',
37
+ description: 'Maximum number of results to return (default: 10, max: 50)',
38
+ minimum: 1,
39
+ maximum: 50
40
+ },
41
+ includeSchemas: {
42
+ type: 'boolean',
43
+ description: 'Include full inputSchema in results (default: false for token efficiency)'
44
+ }
45
+ }
46
+ },
47
+ {
48
+ category: CATEGORIES.SYSTEM,
49
+ scope: SCOPES.READ,
50
+ keywords: ['search', 'discover', 'find', 'tools', 'list'],
51
+ tags: ['meta', 'query', 'discovery']
52
+ }
53
+ );
54
+
55
+ this.unityConnection = unityConnection;
56
+ this.handlersMap = handlersMap;
57
+ }
58
+
59
+ /**
60
+ * Calculate relevance score for a handler against the search query
61
+ * @param {BaseToolHandler} handler - Handler to score
62
+ * @param {string} query - Search query
63
+ * @param {Array<string>} tags - Tag filters
64
+ * @returns {number} Relevance score (0-100)
65
+ */
66
+ calculateRelevance(handler, query, tags) {
67
+ let score = 0;
68
+
69
+ if (!query && (!tags || tags.length === 0)) {
70
+ return 50; // Neutral score for no filters
71
+ }
72
+
73
+ const lowerQuery = query ? query.toLowerCase() : '';
74
+ const searchableText = [handler.name, handler.description, ...(handler.metadata.keywords || [])]
75
+ .join(' ')
76
+ .toLowerCase();
77
+
78
+ // Exact name match: highest priority
79
+ if (handler.name.toLowerCase() === lowerQuery) {
80
+ score += 100;
81
+ }
82
+ // Name contains query: high priority
83
+ else if (handler.name.toLowerCase().includes(lowerQuery)) {
84
+ score += 50;
85
+ }
86
+ // Description/keywords contain query: medium priority
87
+ else if (searchableText.includes(lowerQuery)) {
88
+ score += 25;
89
+ }
90
+
91
+ // Tag matches: bonus points
92
+ if (tags && tags.length > 0) {
93
+ const matchedTags = tags.filter(tag => handler.metadata.tags.includes(tag));
94
+ score += matchedTags.length * 10;
95
+ }
96
+
97
+ return Math.min(score, 100);
98
+ }
99
+
100
+ async execute(params) {
101
+ const { query = '', category, tags = [], scope, limit = 10, includeSchemas = false } = params;
102
+
103
+ // Get all handlers from the map
104
+ const allHandlers = Array.from(this.handlersMap.values());
105
+
106
+ // Filter handlers
107
+ let filteredHandlers = allHandlers.filter(handler => {
108
+ // Skip search_tools itself from results
109
+ if (handler.name === 'search_tools') {
110
+ return false;
111
+ }
112
+
113
+ // Category filter
114
+ if (category && handler.metadata.category !== category) {
115
+ return false;
116
+ }
117
+
118
+ // Scope filter
119
+ if (scope && handler.metadata.scope !== scope) {
120
+ return false;
121
+ }
122
+
123
+ // Tag filter (AND logic: all specified tags must match)
124
+ if (tags.length > 0) {
125
+ const hasAllTags = tags.every(tag => handler.metadata.tags.includes(tag));
126
+ if (!hasAllTags) {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ // Query filter (if specified)
132
+ if (query) {
133
+ const lowerQuery = query.toLowerCase();
134
+ const searchableText = [
135
+ handler.name,
136
+ handler.description,
137
+ ...(handler.metadata.keywords || [])
138
+ ]
139
+ .join(' ')
140
+ .toLowerCase();
141
+
142
+ if (!searchableText.includes(lowerQuery)) {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ return true;
148
+ });
149
+
150
+ // Calculate relevance scores and sort
151
+ const scoredHandlers = filteredHandlers.map(handler => ({
152
+ handler,
153
+ relevance: this.calculateRelevance(handler, query, tags)
154
+ }));
155
+
156
+ scoredHandlers.sort((a, b) => b.relevance - a.relevance);
157
+
158
+ // Apply limit
159
+ const limitedHandlers = scoredHandlers.slice(0, limit);
160
+
161
+ // Format results
162
+ const results = limitedHandlers.map(({ handler, relevance }) => {
163
+ const result = {
164
+ name: handler.name,
165
+ description: handler.description,
166
+ category: handler.metadata.category,
167
+ scope: handler.metadata.scope,
168
+ tags: handler.metadata.tags,
169
+ keywords: handler.metadata.keywords,
170
+ relevance: Math.round(relevance)
171
+ };
172
+
173
+ if (includeSchemas) {
174
+ result.inputSchema = handler.inputSchema;
175
+ }
176
+
177
+ return result;
178
+ });
179
+
180
+ return {
181
+ success: true,
182
+ results,
183
+ totalMatches: filteredHandlers.length,
184
+ returned: results.length,
185
+ query: {
186
+ query,
187
+ category,
188
+ tags,
189
+ scope,
190
+ limit
191
+ }
192
+ };
193
+ }
194
+ }
@@ -1,4 +1,5 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
+ import { CATEGORIES, SCOPES } from '../base/categories.js';
2
3
 
3
4
  /**
4
5
  * Handler for the system_ping tool
@@ -18,9 +19,15 @@ export class SystemPingToolHandler extends BaseToolHandler {
18
19
  }
19
20
  },
20
21
  required: []
22
+ },
23
+ {
24
+ category: CATEGORIES.SYSTEM,
25
+ scope: SCOPES.READ,
26
+ keywords: ['ping', 'test', 'connection', 'health', 'status'],
27
+ tags: ['system', 'diagnostic', 'health-check']
21
28
  }
22
29
  );
23
-
30
+
24
31
  this.unityConnection = unityConnection;
25
32
  }
26
33
 
@@ -34,12 +41,12 @@ export class SystemPingToolHandler extends BaseToolHandler {
34
41
  if (!this.unityConnection.isConnected()) {
35
42
  await this.unityConnection.connect();
36
43
  }
37
-
44
+
38
45
  // Send ping command with optional message
39
46
  const result = await this.unityConnection.sendCommand('ping', {
40
47
  message: params.message || 'ping'
41
48
  });
42
-
49
+
43
50
  // Format the result for the response
44
51
  return {
45
52
  message: result.message,
@@ -134,17 +134,38 @@ export class CSharpLspUtils {
134
134
  if (!desired) throw new Error('mcp-server version not found; cannot resolve LSP tag');
135
135
  const current = this.readLocalVersion(rid);
136
136
  if (fs.existsSync(p) && current === desired) return p;
137
- await this.autoDownload(rid, desired);
137
+ const resolved = await this.autoDownload(rid, desired);
138
138
  if (!fs.existsSync(p)) throw new Error('csharp-lsp binary not found after download');
139
- this.writeLocalVersion(rid, desired);
139
+ this.writeLocalVersion(rid, resolved || desired);
140
140
  return p;
141
141
  }
142
142
 
143
143
  async autoDownload(rid, version) {
144
144
  const repo = process.env.GITHUB_REPOSITORY || 'akiojin/unity-mcp-server';
145
- const tag = `v${version}`;
146
- const manifestUrl = `https://github.com/${repo}/releases/download/${tag}/csharp-lsp-manifest.json`;
147
- const manifest = await this.fetchJson(manifestUrl);
145
+
146
+ const fetchManifest = async ver => {
147
+ const tag = `v${ver}`;
148
+ const manifestUrl = `https://github.com/${repo}/releases/download/${tag}/csharp-lsp-manifest.json`;
149
+ const manifest = await this.fetchJson(manifestUrl);
150
+ return { manifest, tag };
151
+ };
152
+
153
+ let targetVersion = version;
154
+ let manifest;
155
+ try {
156
+ ({ manifest } = await fetchManifest(targetVersion));
157
+ } catch (e) {
158
+ // Gracefully fall back to the latest release when the requested manifest is missing (404).
159
+ if (String(e?.message || '').includes('HTTP 404')) {
160
+ const latest = await this.fetchLatestReleaseVersion(repo);
161
+ if (!latest) throw e;
162
+ targetVersion = latest;
163
+ ({ manifest } = await fetchManifest(targetVersion));
164
+ } else {
165
+ throw e;
166
+ }
167
+ }
168
+
148
169
  const entry = manifest?.assets?.[rid];
149
170
  if (!entry?.url || !entry?.sha256) throw new Error(`manifest missing entry for ${rid}`);
150
171
 
@@ -154,17 +175,35 @@ export class CSharpLspUtils {
154
175
  await this.downloadTo(entry.url, tmp);
155
176
  const actual = await this.sha256File(tmp);
156
177
  if (String(actual).toLowerCase() !== String(entry.sha256).toLowerCase()) {
157
- try { fs.unlinkSync(tmp); } catch {}
178
+ try {
179
+ fs.unlinkSync(tmp);
180
+ } catch {}
158
181
  throw new Error('checksum mismatch for csharp-lsp asset');
159
182
  }
160
183
  // atomic replace
161
- try { fs.renameSync(tmp, dest); } catch (e) {
184
+ try {
185
+ fs.renameSync(tmp, dest);
186
+ } catch (e) {
162
187
  // Windows may need removal before rename
163
- try { fs.unlinkSync(dest); } catch {}
188
+ try {
189
+ fs.unlinkSync(dest);
190
+ } catch {}
164
191
  fs.renameSync(tmp, dest);
165
192
  }
166
- try { if (process.platform !== 'win32') fs.chmodSync(dest, 0o755); } catch {}
193
+ try {
194
+ if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
195
+ } catch {}
167
196
  logger.info(`[csharp-lsp] downloaded: ${path.basename(dest)} @ ${path.dirname(dest)}`);
197
+ return targetVersion;
198
+ }
199
+
200
+ async fetchLatestReleaseVersion(repo) {
201
+ const url = `https://api.github.com/repos/${repo}/releases/latest`;
202
+ const json = await this.fetchJson(url);
203
+ const tag = json?.tag_name || '';
204
+ const version = tag.replace(/^v/, '');
205
+ if (!version) throw new Error('latest release version not found');
206
+ return version;
168
207
  }
169
208
 
170
209
  async fetchJson(url) {