@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.
Files changed (28) hide show
  1. package/README.md +1 -0
  2. package/package.json +28 -40
  3. package/src/core/codeIndex.js +54 -7
  4. package/src/core/config.js +15 -1
  5. package/src/core/httpServer.js +30 -6
  6. package/src/core/indexBuildWorkerPool.js +57 -3
  7. package/src/core/indexWatcher.js +10 -4
  8. package/src/core/projectInfo.js +34 -12
  9. package/src/core/server.js +58 -27
  10. package/src/core/toolManifest.json +145 -629
  11. package/src/handlers/addressables/AddressablesAnalyzeToolHandler.js +14 -6
  12. package/src/handlers/addressables/AddressablesBuildToolHandler.js +6 -3
  13. package/src/handlers/addressables/AddressablesManageToolHandler.js +6 -3
  14. package/src/handlers/input/InputSystemControlToolHandler.js +1 -1
  15. package/src/handlers/input/InputTouchToolHandler.js +7 -3
  16. package/src/handlers/package/PackageManagerToolHandler.js +6 -3
  17. package/src/handlers/script/CodeIndexStatusToolHandler.js +37 -1
  18. package/src/handlers/script/CodeIndexUpdateToolHandler.js +1 -1
  19. package/src/handlers/script/ScriptEditSnippetToolHandler.js +1 -2
  20. package/src/handlers/script/ScriptEditStructuredToolHandler.js +6 -1
  21. package/src/handlers/script/ScriptRefactorRenameToolHandler.js +3 -1
  22. package/src/handlers/script/ScriptRefsFindToolHandler.js +22 -4
  23. package/src/handlers/script/ScriptRemoveSymbolToolHandler.js +6 -1
  24. package/src/handlers/script/ScriptSymbolsGetToolHandler.js +1 -1
  25. package/src/lsp/CSharpLspUtils.js +11 -3
  26. package/src/lsp/LspProcessManager.js +24 -5
  27. package/src/lsp/LspRpcClient.js +115 -23
  28. 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
- 'analyze_addressables',
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 result = await this.unityConnection.sendCommand('analyze_addressables', {
72
- action,
73
- ...parameters
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('build_addressables', 'Build Unity Addressables content or clean build cache', {
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
- 'build_addressables',
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
- 'manage_addressables',
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 result = await this.unityConnection.sendCommand('manage_addressables', {
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('control_input_system', 'Main handler for Input System operations', {
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('simulate_touch', 'Touch input (tap/swipe/pinch/multi) with batching.', {
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 ? { actions: params.actions } : params;
184
+ const payload = hasBatch
185
+ ? { actions: params.actions, workspaceRoot: resolvedWorkspaceRoot }
186
+ : { ...params, workspaceRoot: resolvedWorkspaceRoot };
183
187
 
184
- const result = await this.unityConnection.sendCommand('simulate_touch', payload);
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('manage_packages', 'Manage Unity packages - search, install, remove, and list packages', {
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 result = await this.unityConnection.sendCommand('manage_packages', {
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(job => typeof job?.id === 'string' && job.id.startsWith('build-'))
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.getInstance(projectRoot);
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.getInstance(info.projectRoot);
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) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
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) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
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
- for (const [filePath, arr] of perFile) {
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
- return { success: true, results, total, truncated };
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) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
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.getInstance(info.projectRoot);
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 (fs.existsSync(p)) {
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
- const current = this.readLocalVersion(rid);
106
- if (fs.existsSync(p) && current === desired) return p;
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
- this.state = sharedState;
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
- if (this.state.proc && !this.state.proc.killed) return this.state.proc;
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 {
@@ -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.proc && !this.proc.killed) return this.proc;
18
- this.proc = await this.mgr.ensureStarted();
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
- this.proc.stdout.off('data', this.boundOnData);
41
+ proc.stdout.off('data', this.boundOnData);
23
42
  } catch {}
24
43
  }
25
44
  this.boundOnData = chunk => this.onData(chunk);
26
- this.proc.stdout.on('data', this.boundOnData);
45
+ proc.stdout.on('data', this.boundOnData);
46
+
27
47
  // On process close: reject all pending and reset state
28
- this.proc.on('close', () => {
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
- if (!this.initialized) await this.initialize();
39
- return this.proc;
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
- await this.ensure();
139
- const id = this.seq++;
174
+ let id = null;
175
+ let timeoutHandle = null;
140
176
  const timeoutMs = Math.max(1000, Math.min(300000, config.lsp?.requestTimeoutMs || 60000));
141
- const p = new Promise((resolve, reject) => {
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
- return await p;
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 = /timed out|LSP process exited/i.test(msg);
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
  }