@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 +59 -2
- package/package.json +1 -1
- package/src/core/config.js +27 -1
- package/src/handlers/base/BaseToolHandler.js +18 -3
- package/src/handlers/base/categories.js +69 -0
- package/src/handlers/index.js +27 -1
- package/src/handlers/profiler/ProfilerGetMetricsToolHandler.js +52 -0
- package/src/handlers/profiler/ProfilerStartToolHandler.js +81 -0
- package/src/handlers/profiler/ProfilerStatusToolHandler.js +24 -0
- package/src/handlers/profiler/ProfilerStopToolHandler.js +46 -0
- package/src/handlers/script/ScriptEditSnippetToolHandler.js +55 -19
- package/src/handlers/search/SearchToolsHandler.js +194 -0
- package/src/handlers/system/SystemPingToolHandler.js +10 -3
- package/src/lsp/CSharpLspUtils.js +48 -9
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
|
-
- **
|
|
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
package/src/core/config.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
+
};
|
package/src/handlers/index.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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: {
|
|
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:
|
|
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 =
|
|
154
|
-
|
|
155
|
-
|
|
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 =
|
|
186
|
+
let pos = normalizedText.indexOf(normalizedTarget);
|
|
172
187
|
while (pos !== -1) {
|
|
173
188
|
occurrences.push(pos);
|
|
174
|
-
pos =
|
|
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(
|
|
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 +
|
|
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(
|
|
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(
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Unsupported anchor.position "${instruction.anchor.position}" at instructions[${index}]`
|
|
222
|
+
);
|
|
197
223
|
}
|
|
198
|
-
return this.#replaceRange(
|
|
224
|
+
return this.#replaceRange(
|
|
225
|
+
normalizedText,
|
|
226
|
+
end,
|
|
227
|
+
end,
|
|
228
|
+
instruction.newText,
|
|
229
|
+
normalizedTarget,
|
|
230
|
+
index
|
|
231
|
+
);
|
|
199
232
|
}
|
|
200
|
-
return this.#replaceRange(
|
|
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 ?
|
|
289
|
+
return s.length > 120 ? s.slice(0, 117) + '…' : s;
|
|
257
290
|
}
|
|
258
291
|
|
|
259
292
|
#hash(content) {
|
|
260
|
-
return crypto
|
|
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
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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 {
|
|
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 {
|
|
184
|
+
try {
|
|
185
|
+
fs.renameSync(tmp, dest);
|
|
186
|
+
} catch (e) {
|
|
162
187
|
// Windows may need removal before rename
|
|
163
|
-
try {
|
|
188
|
+
try {
|
|
189
|
+
fs.unlinkSync(dest);
|
|
190
|
+
} catch {}
|
|
164
191
|
fs.renameSync(tmp, dest);
|
|
165
192
|
}
|
|
166
|
-
try {
|
|
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) {
|