@akiojin/unity-mcp-server 5.2.1 → 5.3.2
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 +1 -0
- package/package.json +28 -40
- package/src/core/codeIndex.js +54 -7
- package/src/core/config.js +15 -1
- package/src/core/httpServer.js +30 -6
- package/src/core/indexBuildWorkerPool.js +57 -3
- package/src/core/indexWatcher.js +10 -4
- package/src/core/projectInfo.js +34 -12
- package/src/core/server.js +58 -27
- package/src/core/toolManifest.json +145 -629
- package/src/handlers/addressables/AddressablesAnalyzeToolHandler.js +14 -6
- package/src/handlers/addressables/AddressablesBuildToolHandler.js +6 -3
- package/src/handlers/addressables/AddressablesManageToolHandler.js +6 -3
- package/src/handlers/input/InputSystemControlToolHandler.js +1 -1
- package/src/handlers/input/InputTouchToolHandler.js +7 -3
- package/src/handlers/package/PackageManagerToolHandler.js +6 -3
- package/src/handlers/script/CodeIndexStatusToolHandler.js +37 -1
- package/src/handlers/script/CodeIndexUpdateToolHandler.js +1 -1
- package/src/handlers/script/ScriptEditSnippetToolHandler.js +1 -2
- package/src/handlers/script/ScriptEditStructuredToolHandler.js +6 -1
- package/src/handlers/script/ScriptRefactorRenameToolHandler.js +3 -1
- package/src/handlers/script/ScriptRefsFindToolHandler.js +22 -4
- package/src/handlers/script/ScriptRemoveSymbolToolHandler.js +6 -1
- package/src/handlers/script/ScriptSymbolsGetToolHandler.js +1 -1
- package/src/lsp/CSharpLspUtils.js +11 -3
- package/src/lsp/LspProcessManager.js +24 -5
- package/src/lsp/LspRpcClient.js +115 -23
- package/src/lsp/LspRpcClientSingleton.js +79 -2
|
@@ -7,7 +7,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
|
7
7
|
export default class AddressablesAnalyzeToolHandler extends BaseToolHandler {
|
|
8
8
|
constructor(unityConnection) {
|
|
9
9
|
super(
|
|
10
|
-
'
|
|
10
|
+
'addressables_analyze',
|
|
11
11
|
'Analyze Unity Addressables for duplicates, dependencies, and unused assets',
|
|
12
12
|
{
|
|
13
13
|
type: 'object',
|
|
@@ -61,17 +61,25 @@ export default class AddressablesAnalyzeToolHandler extends BaseToolHandler {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
async execute(params) {
|
|
64
|
-
const { action, ...parameters } = params;
|
|
64
|
+
const { action, workspaceRoot, ...parameters } = params;
|
|
65
65
|
|
|
66
66
|
// Ensure connected
|
|
67
67
|
if (!this.unityConnection.isConnected()) {
|
|
68
68
|
await this.unityConnection.connect();
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
const { WORKSPACE_ROOT } = await import('../../core/config.js');
|
|
72
|
+
const resolvedWorkspaceRoot = workspaceRoot ?? WORKSPACE_ROOT;
|
|
73
|
+
const timeout = 300000; // 5 minutes
|
|
74
|
+
const result = await this.unityConnection.sendCommand(
|
|
75
|
+
'addressables_analyze',
|
|
76
|
+
{
|
|
77
|
+
action,
|
|
78
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
79
|
+
...parameters
|
|
80
|
+
},
|
|
81
|
+
timeout
|
|
82
|
+
);
|
|
75
83
|
|
|
76
84
|
return this.formatResponse(action, result);
|
|
77
85
|
}
|
|
@@ -6,7 +6,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
|
6
6
|
*/
|
|
7
7
|
export default class AddressablesBuildToolHandler extends BaseToolHandler {
|
|
8
8
|
constructor(unityConnection) {
|
|
9
|
-
super('
|
|
9
|
+
super('addressables_build', 'Build Unity Addressables content or clean build cache', {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
12
12
|
action: {
|
|
@@ -64,7 +64,7 @@ export default class AddressablesBuildToolHandler extends BaseToolHandler {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async execute(params) {
|
|
67
|
-
const { action, ...parameters } = params;
|
|
67
|
+
const { action, workspaceRoot, ...parameters } = params;
|
|
68
68
|
|
|
69
69
|
// Ensure connected
|
|
70
70
|
if (!this.unityConnection.isConnected()) {
|
|
@@ -74,10 +74,13 @@ export default class AddressablesBuildToolHandler extends BaseToolHandler {
|
|
|
74
74
|
// Build operations can take several minutes
|
|
75
75
|
const timeout = 300000; // 5 minutes
|
|
76
76
|
|
|
77
|
+
const { WORKSPACE_ROOT } = await import('../../core/config.js');
|
|
78
|
+
const resolvedWorkspaceRoot = workspaceRoot ?? WORKSPACE_ROOT;
|
|
77
79
|
const result = await this.unityConnection.sendCommand(
|
|
78
|
-
'
|
|
80
|
+
'addressables_build',
|
|
79
81
|
{
|
|
80
82
|
action,
|
|
83
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
81
84
|
...parameters
|
|
82
85
|
},
|
|
83
86
|
timeout
|
|
@@ -7,7 +7,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
|
7
7
|
export default class AddressablesManageToolHandler extends BaseToolHandler {
|
|
8
8
|
constructor(unityConnection) {
|
|
9
9
|
super(
|
|
10
|
-
'
|
|
10
|
+
'addressables_manage',
|
|
11
11
|
'Manage Unity Addressables assets and groups - add, remove, organize entries and groups',
|
|
12
12
|
{
|
|
13
13
|
type: 'object',
|
|
@@ -134,15 +134,18 @@ export default class AddressablesManageToolHandler extends BaseToolHandler {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
async execute(params) {
|
|
137
|
-
const { action, ...parameters } = params;
|
|
137
|
+
const { action, workspaceRoot, ...parameters } = params;
|
|
138
138
|
|
|
139
139
|
// Ensure connected
|
|
140
140
|
if (!this.unityConnection.isConnected()) {
|
|
141
141
|
await this.unityConnection.connect();
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
const
|
|
144
|
+
const { WORKSPACE_ROOT } = await import('../../core/config.js');
|
|
145
|
+
const resolvedWorkspaceRoot = workspaceRoot ?? WORKSPACE_ROOT;
|
|
146
|
+
const result = await this.unityConnection.sendCommand('addressables_manage', {
|
|
145
147
|
action,
|
|
148
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
146
149
|
...parameters
|
|
147
150
|
});
|
|
148
151
|
|
|
@@ -5,7 +5,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
|
5
5
|
*/
|
|
6
6
|
export class InputSystemControlToolHandler extends BaseToolHandler {
|
|
7
7
|
constructor(unityConnection) {
|
|
8
|
-
super('
|
|
8
|
+
super('input_system_control', 'Main handler for Input System operations', {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
11
11
|
operation: {
|
|
@@ -127,7 +127,7 @@ function validateTouchAction(params, context = 'action') {
|
|
|
127
127
|
*/
|
|
128
128
|
export class InputTouchToolHandler extends BaseToolHandler {
|
|
129
129
|
constructor(unityConnection) {
|
|
130
|
-
super('
|
|
130
|
+
super('input_touch', 'Touch input (tap/swipe/pinch/multi) with batching.', {
|
|
131
131
|
type: 'object',
|
|
132
132
|
properties: {
|
|
133
133
|
...actionProperties,
|
|
@@ -178,10 +178,14 @@ export class InputTouchToolHandler extends BaseToolHandler {
|
|
|
178
178
|
await this.unityConnection.connect();
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
const { WORKSPACE_ROOT } = await import('../../core/config.js');
|
|
182
|
+
const resolvedWorkspaceRoot = params.workspaceRoot ?? WORKSPACE_ROOT;
|
|
181
183
|
const hasBatch = Array.isArray(params.actions) && params.actions.length > 0;
|
|
182
|
-
const payload = hasBatch
|
|
184
|
+
const payload = hasBatch
|
|
185
|
+
? { actions: params.actions, workspaceRoot: resolvedWorkspaceRoot }
|
|
186
|
+
: { ...params, workspaceRoot: resolvedWorkspaceRoot };
|
|
183
187
|
|
|
184
|
-
const result = await this.unityConnection.sendCommand('
|
|
188
|
+
const result = await this.unityConnection.sendCommand('input_touch', payload);
|
|
185
189
|
return result;
|
|
186
190
|
}
|
|
187
191
|
}
|
|
@@ -7,7 +7,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
|
7
7
|
|
|
8
8
|
export default class PackageManagerToolHandler extends BaseToolHandler {
|
|
9
9
|
constructor(unityConnection) {
|
|
10
|
-
super('
|
|
10
|
+
super('package_manager', 'Manage Unity packages - search, install, remove, and list packages', {
|
|
11
11
|
type: 'object',
|
|
12
12
|
properties: {
|
|
13
13
|
action: {
|
|
@@ -77,15 +77,18 @@ export default class PackageManagerToolHandler extends BaseToolHandler {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
async execute(params) {
|
|
80
|
-
const { action, ...parameters } = params;
|
|
80
|
+
const { action, workspaceRoot, ...parameters } = params;
|
|
81
81
|
|
|
82
82
|
// Ensure connected
|
|
83
83
|
if (!this.unityConnection.isConnected()) {
|
|
84
84
|
await this.unityConnection.connect();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
const
|
|
87
|
+
const { WORKSPACE_ROOT } = await import('../../core/config.js');
|
|
88
|
+
const resolvedWorkspaceRoot = workspaceRoot ?? WORKSPACE_ROOT;
|
|
89
|
+
const result = await this.unityConnection.sendCommand('package_manager', {
|
|
88
90
|
action,
|
|
91
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
89
92
|
...parameters
|
|
90
93
|
});
|
|
91
94
|
|
|
@@ -22,7 +22,11 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
|
|
|
22
22
|
const jobManager = this.jobManager;
|
|
23
23
|
const buildJobs = jobManager
|
|
24
24
|
.getAllJobs()
|
|
25
|
-
.filter(
|
|
25
|
+
.filter(
|
|
26
|
+
job =>
|
|
27
|
+
typeof job?.id === 'string' &&
|
|
28
|
+
(job.id.startsWith('build-') || job.id.startsWith('watcher-'))
|
|
29
|
+
)
|
|
26
30
|
.sort((a, b) => new Date(b.startedAt || 0).getTime() - new Date(a.startedAt || 0).getTime());
|
|
27
31
|
const latestBuildJob = buildJobs.length > 0 ? buildJobs[0] : null;
|
|
28
32
|
|
|
@@ -51,6 +55,38 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
|
|
|
51
55
|
}
|
|
52
56
|
const buildInProgress = latestBuildJob?.status === 'running';
|
|
53
57
|
if (!ready && !buildInProgress) {
|
|
58
|
+
if (latestBuildJob) {
|
|
59
|
+
const indexInfo = {
|
|
60
|
+
ready: false,
|
|
61
|
+
rows: 0,
|
|
62
|
+
lastIndexedAt: null,
|
|
63
|
+
buildJob: {
|
|
64
|
+
id: latestBuildJob.id,
|
|
65
|
+
status: latestBuildJob.status,
|
|
66
|
+
startedAt: latestBuildJob.startedAt ?? null
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (latestBuildJob.status === 'completed') {
|
|
70
|
+
indexInfo.buildJob.completedAt = latestBuildJob.completedAt ?? null;
|
|
71
|
+
indexInfo.buildJob.result = latestBuildJob.result ?? null;
|
|
72
|
+
} else if (latestBuildJob.status === 'failed') {
|
|
73
|
+
indexInfo.buildJob.failedAt = latestBuildJob.failedAt ?? null;
|
|
74
|
+
indexInfo.buildJob.error = latestBuildJob.error ?? 'Unknown error';
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
status: latestBuildJob.status === 'failed' ? 'failed' : 'pending',
|
|
79
|
+
ready: false,
|
|
80
|
+
totalFiles: 0,
|
|
81
|
+
indexedFiles: 0,
|
|
82
|
+
coverage: 0,
|
|
83
|
+
message:
|
|
84
|
+
latestBuildJob.status === 'failed'
|
|
85
|
+
? 'Code index build failed. Check buildJob error or rerun build_index.'
|
|
86
|
+
: 'Code index is not ready yet. A build job exists but has not produced a usable index.',
|
|
87
|
+
index: indexInfo
|
|
88
|
+
};
|
|
89
|
+
}
|
|
54
90
|
return {
|
|
55
91
|
success: false,
|
|
56
92
|
error: 'index_not_built',
|
|
@@ -60,7 +60,7 @@ export class CodeIndexUpdateToolHandler extends BaseToolHandler {
|
|
|
60
60
|
const projectRoot = info.projectRoot;
|
|
61
61
|
|
|
62
62
|
if (!this.lsp) {
|
|
63
|
-
this.lsp = await LspRpcClientSingleton.
|
|
63
|
+
this.lsp = await LspRpcClientSingleton.getIsolatedInstance(projectRoot, 'update_index');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const normalized = requested.map(p => this._normalizePath(p, projectRoot));
|
|
@@ -9,7 +9,6 @@ import { preSyntaxCheck } from './csharpSyntaxCheck.js';
|
|
|
9
9
|
const MAX_INSTRUCTIONS = 10;
|
|
10
10
|
const MAX_DIFF_CHARS = 80;
|
|
11
11
|
const PREVIEW_MAX = 1000;
|
|
12
|
-
|
|
13
12
|
const normalizeSlashes = p => p.replace(/\\/g, '/');
|
|
14
13
|
|
|
15
14
|
export class ScriptEditSnippetToolHandler extends BaseToolHandler {
|
|
@@ -297,7 +296,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
|
|
|
297
296
|
|
|
298
297
|
async #validateWithLsp(info, relative, updatedText) {
|
|
299
298
|
if (!this.lsp) {
|
|
300
|
-
this.lsp = await LspRpcClientSingleton.
|
|
299
|
+
this.lsp = await LspRpcClientSingleton.getValidationInstance(info.projectRoot);
|
|
301
300
|
}
|
|
302
301
|
return await this.lsp.validateText(relative, updatedText);
|
|
303
302
|
}
|
|
@@ -110,7 +110,12 @@ export class ScriptEditStructuredToolHandler extends BaseToolHandler {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
if (!this.lsp)
|
|
113
|
+
if (!this.lsp) {
|
|
114
|
+
this.lsp = await LspRpcClientSingleton.getIsolatedInstance(
|
|
115
|
+
info.projectRoot,
|
|
116
|
+
'edit_structured'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
114
119
|
|
|
115
120
|
if (operation === 'replace_body') {
|
|
116
121
|
const resp = await this.lsp.request('mcp/replaceSymbolBody', {
|
|
@@ -38,7 +38,9 @@ export class ScriptRefactorRenameToolHandler extends BaseToolHandler {
|
|
|
38
38
|
async execute(params) {
|
|
39
39
|
const { relative, namePath, newName, preview = true } = params;
|
|
40
40
|
const info = await this.projectInfo.get();
|
|
41
|
-
if (!this.lsp)
|
|
41
|
+
if (!this.lsp) {
|
|
42
|
+
this.lsp = await LspRpcClientSingleton.getIsolatedInstance(info.projectRoot, 'rename');
|
|
43
|
+
}
|
|
42
44
|
const resp = await this.lsp.request('mcp/renameByNamePath', {
|
|
43
45
|
relative: String(relative).replace(/\\\\/g, '/'),
|
|
44
46
|
namePath: String(namePath),
|
|
@@ -9,7 +9,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
9
9
|
constructor(unityConnection) {
|
|
10
10
|
super(
|
|
11
11
|
'find_refs',
|
|
12
|
-
'[OFFLINE] No Unity connection required. Find code references/usages using fast file-based search. LLM-friendly paging/summary: respects pageSize and maxBytes, caps matches per file (maxMatchesPerFile), and trims snippet text to ~400 chars. Use scope/name/kind/path to narrow results.',
|
|
12
|
+
'[OFFLINE] No Unity connection required. Find code references/usages using fast file-based search. LLM-friendly paging/summary: respects pageSize and maxBytes, caps matches per file (maxMatchesPerFile), and trims snippet text to ~400 chars. Use scope/name/kind/path to narrow results. Use startAfter to continue from a cursor (file path).',
|
|
13
13
|
{
|
|
14
14
|
type: 'object',
|
|
15
15
|
properties: {
|
|
@@ -17,6 +17,10 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
17
17
|
type: 'string',
|
|
18
18
|
description: 'Symbol name to search usages for.'
|
|
19
19
|
},
|
|
20
|
+
startAfter: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Pagination cursor (file path). Return only results after this path.'
|
|
23
|
+
},
|
|
20
24
|
scope: {
|
|
21
25
|
type: 'string',
|
|
22
26
|
enum: ['assets', 'packages', 'embedded', 'all'],
|
|
@@ -78,6 +82,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
78
82
|
async execute(params) {
|
|
79
83
|
const {
|
|
80
84
|
name,
|
|
85
|
+
startAfter,
|
|
81
86
|
scope = 'all',
|
|
82
87
|
pageSize = 50,
|
|
83
88
|
maxBytes = 1024 * 64,
|
|
@@ -219,8 +224,16 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
219
224
|
let bytes = 0;
|
|
220
225
|
let total = 0;
|
|
221
226
|
let truncated = false;
|
|
222
|
-
|
|
223
|
-
|
|
227
|
+
let lastPath = null;
|
|
228
|
+
let started = !startAfter;
|
|
229
|
+
const sortedPaths = Array.from(perFile.keys()).sort();
|
|
230
|
+
|
|
231
|
+
for (const filePath of sortedPaths) {
|
|
232
|
+
if (!started) {
|
|
233
|
+
if (filePath <= startAfter) continue;
|
|
234
|
+
started = true;
|
|
235
|
+
}
|
|
236
|
+
const arr = perFile.get(filePath) || [];
|
|
224
237
|
const references = [];
|
|
225
238
|
for (const it of arr) {
|
|
226
239
|
const ref = {
|
|
@@ -243,6 +256,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
243
256
|
|
|
244
257
|
if (references.length > 0) {
|
|
245
258
|
results.push({ path: filePath, references });
|
|
259
|
+
lastPath = filePath;
|
|
246
260
|
}
|
|
247
261
|
|
|
248
262
|
if (truncated) break;
|
|
@@ -254,7 +268,11 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
|
|
|
254
268
|
truncated = true;
|
|
255
269
|
}
|
|
256
270
|
|
|
257
|
-
|
|
271
|
+
const response = { success: true, results, total, truncated };
|
|
272
|
+
if (truncated && lastPath) {
|
|
273
|
+
response.cursor = lastPath;
|
|
274
|
+
}
|
|
275
|
+
return response;
|
|
258
276
|
}
|
|
259
277
|
}
|
|
260
278
|
|
|
@@ -39,7 +39,12 @@ export class ScriptRemoveSymbolToolHandler extends BaseToolHandler {
|
|
|
39
39
|
removeEmptyFile = false
|
|
40
40
|
} = params;
|
|
41
41
|
const info = await this.projectInfo.get();
|
|
42
|
-
if (!this.lsp)
|
|
42
|
+
if (!this.lsp) {
|
|
43
|
+
this.lsp = await LspRpcClientSingleton.getIsolatedInstance(
|
|
44
|
+
info.projectRoot,
|
|
45
|
+
'remove_symbol'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
43
48
|
const resp = await this.lsp.request('mcp/removeSymbol', {
|
|
44
49
|
relative: String(path).replace(/\\\\/g, '/'),
|
|
45
50
|
namePath: String(namePath),
|
|
@@ -65,7 +65,7 @@ export class ScriptSymbolsGetToolHandler extends BaseToolHandler {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
try {
|
|
68
|
-
const lsp = await LspRpcClientSingleton.
|
|
68
|
+
const lsp = await LspRpcClientSingleton.getIsolatedInstance(info.projectRoot, 'symbols');
|
|
69
69
|
const uri = 'file://' + abs.replace(/\\\\/g, '/');
|
|
70
70
|
const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
|
|
71
71
|
const docSymbols = res?.result ?? res ?? [];
|
|
@@ -92,18 +92,26 @@ export class CSharpLspUtils {
|
|
|
92
92
|
async ensureLocal(rid) {
|
|
93
93
|
const p = this.getLocalPath(rid);
|
|
94
94
|
const desired = this.getDesiredVersion();
|
|
95
|
+
const binaryExists = fs.existsSync(p);
|
|
96
|
+
const current = this.readLocalVersion(rid);
|
|
97
|
+
const isTestMode = process.env.NODE_ENV === 'test';
|
|
95
98
|
|
|
96
99
|
// バージョン取得失敗時もバイナリが存在すれば使用
|
|
97
100
|
if (!desired) {
|
|
98
|
-
if (
|
|
101
|
+
if (binaryExists) {
|
|
99
102
|
logger.warning('[unity-mcp-server:lsp] version not found, using existing binary');
|
|
100
103
|
return p;
|
|
101
104
|
}
|
|
102
105
|
throw new Error('mcp-server version not found; cannot resolve LSP tag');
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
if (binaryExists && current === desired) return p;
|
|
109
|
+
|
|
110
|
+
// テスト環境では既存バイナリを優先し、並列ダウンロードによる不安定化を防ぐ
|
|
111
|
+
if (binaryExists && isTestMode) {
|
|
112
|
+
this.writeLocalVersion(rid, desired);
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
107
115
|
|
|
108
116
|
// ダウンロード失敗時のフォールバック
|
|
109
117
|
try {
|
|
@@ -4,28 +4,45 @@ import { CSharpLspUtils } from './CSharpLspUtils.js';
|
|
|
4
4
|
|
|
5
5
|
const sharedState = {
|
|
6
6
|
proc: null,
|
|
7
|
-
starting: null
|
|
7
|
+
starting: null,
|
|
8
|
+
version: null
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
export class LspProcessManager {
|
|
11
|
-
constructor() {
|
|
12
|
-
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
const useShared = options.shared !== false;
|
|
14
|
+
this.state = useShared ? sharedState : { proc: null, starting: null, version: null };
|
|
13
15
|
this.utils = new CSharpLspUtils();
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async ensureStarted() {
|
|
17
|
-
|
|
19
|
+
const rid = this.utils.detectRid();
|
|
20
|
+
const desired = this.utils.getDesiredVersion();
|
|
21
|
+
if (this.state.proc && !this.state.proc.killed) {
|
|
22
|
+
const local = this.utils.readLocalVersion(rid);
|
|
23
|
+
const procVersion = this.state.version;
|
|
24
|
+
const desiredVersion = desired || local || null;
|
|
25
|
+
if (procVersion && desiredVersion && procVersion !== desiredVersion) {
|
|
26
|
+
logger.warning(
|
|
27
|
+
`[unity-mcp-server:lsp] version changed (proc=${procVersion}, desired=${desiredVersion}), restarting`
|
|
28
|
+
);
|
|
29
|
+
await this.stop();
|
|
30
|
+
} else {
|
|
31
|
+
return this.state.proc;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
18
34
|
if (this.state.starting) return this.state.starting;
|
|
19
35
|
this.state.starting = (async () => {
|
|
20
36
|
try {
|
|
21
|
-
const rid = this.utils.detectRid();
|
|
22
37
|
const bin = await this.utils.ensureLocal(rid);
|
|
38
|
+
const resolvedVersion = this.utils.readLocalVersion(rid) || desired || null;
|
|
23
39
|
const proc = spawn(bin, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
24
40
|
proc.on('error', e => logger.error(`[unity-mcp-server:lsp] process error: ${e.message}`));
|
|
25
41
|
proc.on('close', (code, sig) => {
|
|
26
42
|
logger.warning(`[unity-mcp-server:lsp] exited code=${code} signal=${sig || ''}`);
|
|
27
43
|
if (this.state.proc === proc) {
|
|
28
44
|
this.state.proc = null;
|
|
45
|
+
this.state.version = null;
|
|
29
46
|
}
|
|
30
47
|
});
|
|
31
48
|
proc.stderr.on('data', d => {
|
|
@@ -33,6 +50,7 @@ export class LspProcessManager {
|
|
|
33
50
|
if (s) logger.debug(`[unity-mcp-server:lsp] ${s}`);
|
|
34
51
|
});
|
|
35
52
|
this.state.proc = proc;
|
|
53
|
+
this.state.version = resolvedVersion;
|
|
36
54
|
logger.info(`[unity-mcp-server:lsp] started (pid=${proc.pid})`);
|
|
37
55
|
return proc;
|
|
38
56
|
} catch (e) {
|
|
@@ -51,6 +69,7 @@ export class LspProcessManager {
|
|
|
51
69
|
if (!this.state.proc || this.state.proc.killed) return;
|
|
52
70
|
const p = this.state.proc;
|
|
53
71
|
this.state.proc = null;
|
|
72
|
+
this.state.version = null;
|
|
54
73
|
|
|
55
74
|
// Remove all listeners to prevent memory leaks
|
|
56
75
|
try {
|
package/src/lsp/LspRpcClient.js
CHANGED
|
@@ -2,8 +2,8 @@ import { LspProcessManager } from './LspProcessManager.js';
|
|
|
2
2
|
import { config, logger } from '../core/config.js';
|
|
3
3
|
|
|
4
4
|
export class LspRpcClient {
|
|
5
|
-
constructor(projectRoot = null) {
|
|
6
|
-
this.mgr = new LspProcessManager();
|
|
5
|
+
constructor(projectRoot = null, manager = null) {
|
|
6
|
+
this.mgr = manager || new LspProcessManager();
|
|
7
7
|
this.proc = null;
|
|
8
8
|
this.seq = 1;
|
|
9
9
|
this.pending = new Map();
|
|
@@ -13,19 +13,39 @@ export class LspRpcClient {
|
|
|
13
13
|
this.boundOnData = null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
async ensure() {
|
|
17
|
-
if (this
|
|
18
|
-
|
|
16
|
+
async ensure(attempt = 1) {
|
|
17
|
+
if (this.#isProcessUsable(this.proc)) {
|
|
18
|
+
return this.proc;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (this.proc && !this.#isProcessUsable(this.proc)) {
|
|
22
|
+
logger.warning('[unity-mcp-server:lsp] cached process stdin not writable, restarting');
|
|
23
|
+
await this.#restartProcess(this.proc);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proc = await this.mgr.ensureStarted();
|
|
27
|
+
this.proc = proc;
|
|
28
|
+
|
|
29
|
+
if (!this.#isProcessUsable(proc)) {
|
|
30
|
+
if (attempt === 1) {
|
|
31
|
+
logger.warning('[unity-mcp-server:lsp] started process has non-writable stdin, retrying');
|
|
32
|
+
await this.#restartProcess(proc);
|
|
33
|
+
return await this.ensure(attempt + 1);
|
|
34
|
+
}
|
|
35
|
+
throw new Error('LSP stdin not writable');
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
// Attach data handler once per process
|
|
20
39
|
if (this.boundOnData) {
|
|
21
40
|
try {
|
|
22
|
-
|
|
41
|
+
proc.stdout.off('data', this.boundOnData);
|
|
23
42
|
} catch {}
|
|
24
43
|
}
|
|
25
44
|
this.boundOnData = chunk => this.onData(chunk);
|
|
26
|
-
|
|
45
|
+
proc.stdout.on('data', this.boundOnData);
|
|
46
|
+
|
|
27
47
|
// On process close: reject all pending and reset state
|
|
28
|
-
|
|
48
|
+
proc.on('close', () => {
|
|
29
49
|
for (const [id, p] of Array.from(this.pending.entries())) {
|
|
30
50
|
try {
|
|
31
51
|
p.reject(new Error('LSP process exited'));
|
|
@@ -35,8 +55,24 @@ export class LspRpcClient {
|
|
|
35
55
|
this.initialized = false;
|
|
36
56
|
this.proc = null;
|
|
37
57
|
});
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
|
|
59
|
+
if (!this.initialized) {
|
|
60
|
+
try {
|
|
61
|
+
await this.initialize();
|
|
62
|
+
} catch (e) {
|
|
63
|
+
const msg = String((e && e.message) || e);
|
|
64
|
+
if (attempt === 1 && this.#isRecoverableMessage(msg)) {
|
|
65
|
+
logger.warning(
|
|
66
|
+
`[unity-mcp-server:lsp] initialize recoverable error: ${msg}. Restarting once...`
|
|
67
|
+
);
|
|
68
|
+
await this.#restartProcess(proc);
|
|
69
|
+
return await this.ensure(attempt + 1);
|
|
70
|
+
}
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return proc;
|
|
40
76
|
}
|
|
41
77
|
|
|
42
78
|
onData(chunk) {
|
|
@@ -135,24 +171,51 @@ export class LspRpcClient {
|
|
|
135
171
|
}
|
|
136
172
|
|
|
137
173
|
async #requestWithRetry(method, params, attempt) {
|
|
138
|
-
|
|
139
|
-
|
|
174
|
+
let id = null;
|
|
175
|
+
let timeoutHandle = null;
|
|
140
176
|
const timeoutMs = Math.max(1000, Math.min(300000, config.lsp?.requestTimeoutMs || 60000));
|
|
141
|
-
const
|
|
142
|
-
this.pending.set(id, { resolve, reject });
|
|
143
|
-
setTimeout(() => {
|
|
144
|
-
if (this.pending.has(id)) {
|
|
145
|
-
this.pending.delete(id);
|
|
146
|
-
reject(new Error(`${method} timed out after ${timeoutMs} ms`));
|
|
147
|
-
}
|
|
148
|
-
}, timeoutMs);
|
|
149
|
-
});
|
|
177
|
+
const startedAt = Date.now();
|
|
150
178
|
try {
|
|
179
|
+
await this.ensure();
|
|
180
|
+
id = this.seq++;
|
|
181
|
+
const p = new Promise((resolve, reject) => {
|
|
182
|
+
this.pending.set(id, { resolve, reject });
|
|
183
|
+
timeoutHandle = setTimeout(() => {
|
|
184
|
+
if (this.pending.has(id)) {
|
|
185
|
+
this.pending.delete(id);
|
|
186
|
+
reject(new Error(`${method} timed out after ${timeoutMs} ms`));
|
|
187
|
+
}
|
|
188
|
+
}, timeoutMs);
|
|
189
|
+
});
|
|
151
190
|
this.writeMessage({ jsonrpc: '2.0', id, method, params });
|
|
152
|
-
|
|
191
|
+
const response = await p;
|
|
192
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
193
|
+
const elapsedMs = Date.now() - startedAt;
|
|
194
|
+
const warnMs = Number.isFinite(config.lsp?.slowRequestWarnMs)
|
|
195
|
+
? config.lsp.slowRequestWarnMs
|
|
196
|
+
: 2000;
|
|
197
|
+
if (elapsedMs >= warnMs) {
|
|
198
|
+
logger.warning(
|
|
199
|
+
`[unity-mcp-server:lsp] slow request: ${method} ${elapsedMs}ms (attempt ${attempt})`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return response;
|
|
153
203
|
} catch (e) {
|
|
204
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
205
|
+
if (id !== null && this.pending.has(id)) {
|
|
206
|
+
this.pending.delete(id);
|
|
207
|
+
}
|
|
208
|
+
const elapsedMs = Date.now() - startedAt;
|
|
209
|
+
const warnMs = Number.isFinite(config.lsp?.slowRequestWarnMs)
|
|
210
|
+
? config.lsp.slowRequestWarnMs
|
|
211
|
+
: 2000;
|
|
212
|
+
if (elapsedMs >= warnMs) {
|
|
213
|
+
logger.warning(
|
|
214
|
+
`[unity-mcp-server:lsp] slow request (error): ${method} ${elapsedMs}ms (attempt ${attempt})`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
154
217
|
const msg = String((e && e.message) || e);
|
|
155
|
-
const recoverable =
|
|
218
|
+
const recoverable = this.#isRecoverableMessage(msg);
|
|
156
219
|
if (recoverable && attempt === 1) {
|
|
157
220
|
// Auto-reinit and retry once with grace period for proper LSP shutdown
|
|
158
221
|
try {
|
|
@@ -180,4 +243,33 @@ export class LspRpcClient {
|
|
|
180
243
|
throw new Error(`[${method}] failed: ${msg}. ${hint}`);
|
|
181
244
|
}
|
|
182
245
|
}
|
|
246
|
+
|
|
247
|
+
#isProcessUsable(proc) {
|
|
248
|
+
return Boolean(
|
|
249
|
+
proc &&
|
|
250
|
+
!proc.killed &&
|
|
251
|
+
proc.stdin &&
|
|
252
|
+
!proc.stdin.destroyed &&
|
|
253
|
+
!proc.stdin.writableEnded &&
|
|
254
|
+
typeof proc.stdin.write === 'function'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#isRecoverableMessage(message) {
|
|
259
|
+
return /timed out|LSP process exited|stdin not writable|process not available/i.test(message);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async #restartProcess(proc) {
|
|
263
|
+
if (proc && this.boundOnData) {
|
|
264
|
+
try {
|
|
265
|
+
proc.stdout.off('data', this.boundOnData);
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await this.mgr.stop(0);
|
|
270
|
+
} catch {}
|
|
271
|
+
this.proc = null;
|
|
272
|
+
this.initialized = false;
|
|
273
|
+
this.buf = Buffer.alloc(0);
|
|
274
|
+
}
|
|
183
275
|
}
|