@akiojin/unity-mcp-server 2.41.7 → 2.42.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 +93 -10
- package/package.json +5 -6
- package/scripts/ensure-better-sqlite3.mjs +1 -1
- package/src/core/codeIndex.js +68 -41
- package/src/core/indexBuildWorkerPool.js +186 -0
- package/src/core/indexWatcher.js +58 -36
- package/src/core/transports/HybridStdioServerTransport.js +16 -4
- package/src/core/unityConnection.js +78 -51
- package/src/core/workers/indexBuildWorker.js +362 -0
- package/src/handlers/script/CodeIndexBuildToolHandler.js +7 -2
- package/src/handlers/script/CodeIndexStatusToolHandler.js +21 -59
- package/src/handlers/script/CodeIndexUpdateToolHandler.js +2 -2
- package/src/handlers/script/ScriptEditSnippetToolHandler.js +2 -2
- package/src/handlers/script/ScriptEditStructuredToolHandler.js +2 -2
- package/src/handlers/script/ScriptRefactorRenameToolHandler.js +2 -2
- package/src/handlers/script/ScriptRefsFindToolHandler.js +54 -3
- package/src/handlers/script/ScriptRemoveSymbolToolHandler.js +2 -2
- package/src/handlers/script/ScriptSearchToolHandler.js +1 -1
- package/src/handlers/script/ScriptSymbolFindToolHandler.js +64 -65
- package/src/handlers/script/ScriptSymbolsGetToolHandler.js +2 -2
- package/src/lsp/LspProcessManager.js +9 -0
- package/src/lsp/LspRpcClient.js +7 -0
- package/src/lsp/LspRpcClientSingleton.js +98 -0
- package/src/core/codeIndexDb.js +0 -112
- package/src/core/sqliteFallback.js +0 -82
package/src/core/indexWatcher.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger, config } from './config.js';
|
|
2
2
|
import { JobManager } from './jobManager.js';
|
|
3
|
+
import { getWorkerPool } from './indexBuildWorkerPool.js';
|
|
3
4
|
|
|
4
5
|
export class IndexWatcher {
|
|
5
6
|
constructor(unityConnection) {
|
|
@@ -8,19 +9,32 @@ export class IndexWatcher {
|
|
|
8
9
|
this.running = false;
|
|
9
10
|
this.jobManager = JobManager.getInstance();
|
|
10
11
|
this.currentWatcherJobId = null;
|
|
12
|
+
this.workerPool = getWorkerPool();
|
|
13
|
+
// FR-061: Use Worker Thread for watcher builds
|
|
14
|
+
this.useWorkerThread = true;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
start() {
|
|
14
18
|
if (!config.indexing?.watch) return;
|
|
15
19
|
if (this.timer) return;
|
|
16
20
|
const interval = Math.max(2000, Number(config.indexing.intervalMs || 15000));
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
// Initial delay: wait longer to allow MCP server to fully initialize
|
|
22
|
+
// and first tool calls to complete before starting background indexing
|
|
23
|
+
const initialDelay = Math.max(30000, Number(config.indexing.initialDelayMs || 30000));
|
|
24
|
+
logger.info(`[index] watcher enabled (interval=${interval}ms, initialDelay=${initialDelay}ms)`);
|
|
25
|
+
|
|
26
|
+
// Delay initial tick significantly to avoid blocking MCP server initialization
|
|
27
|
+
const delayedStart = setTimeout(() => {
|
|
28
|
+
this.tick();
|
|
29
|
+
// Start periodic timer only after first tick
|
|
30
|
+
this.timer = setInterval(() => this.tick(), interval);
|
|
31
|
+
if (typeof this.timer.unref === 'function') {
|
|
32
|
+
this.timer.unref();
|
|
33
|
+
}
|
|
34
|
+
}, initialDelay);
|
|
35
|
+
if (typeof delayedStart.unref === 'function') {
|
|
36
|
+
delayedStart.unref();
|
|
21
37
|
}
|
|
22
|
-
// Initial kick
|
|
23
|
-
this.tick();
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
stop() {
|
|
@@ -60,21 +74,10 @@ export class IndexWatcher {
|
|
|
60
74
|
const jobId = `watcher-rebuild-${Date.now()}`;
|
|
61
75
|
this.currentWatcherJobId = jobId;
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.jobManager.create(jobId, async job => {
|
|
69
|
-
const params = {
|
|
70
|
-
concurrency: config.indexing.concurrency || 8,
|
|
71
|
-
retry: config.indexing.retry || 2,
|
|
72
|
-
reportPercentage: 10
|
|
73
|
-
};
|
|
74
|
-
return await handler._executeBuild(params, job);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
logger.info(`[index] watcher: started DB rebuild job ${jobId}`);
|
|
77
|
+
// FR-061: Use Worker Thread for non-blocking builds
|
|
78
|
+
await this._startWorkerBuild(jobId, info, dbPath);
|
|
79
|
+
|
|
80
|
+
logger.info(`[index] watcher: started DB rebuild job ${jobId} (Worker Thread)`);
|
|
78
81
|
this._monitorJob(jobId);
|
|
79
82
|
return;
|
|
80
83
|
}
|
|
@@ -105,22 +108,10 @@ export class IndexWatcher {
|
|
|
105
108
|
const jobId = `watcher-${Date.now()}`;
|
|
106
109
|
this.currentWatcherJobId = jobId;
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
111
|
-
const handler = new CodeIndexBuildToolHandler(this.unityConnection);
|
|
112
|
-
|
|
113
|
-
// Create the build job through JobManager
|
|
114
|
-
this.jobManager.create(jobId, async job => {
|
|
115
|
-
const params = {
|
|
116
|
-
concurrency: config.indexing.concurrency || 8,
|
|
117
|
-
retry: config.indexing.retry || 2,
|
|
118
|
-
reportEvery: config.indexing.reportEvery || 500
|
|
119
|
-
};
|
|
120
|
-
return await handler._executeBuild(params, job);
|
|
121
|
-
});
|
|
111
|
+
// FR-061: Use Worker Thread for non-blocking builds
|
|
112
|
+
await this._startWorkerBuild(jobId, info, dbPath);
|
|
122
113
|
|
|
123
|
-
logger.info(`[index] watcher: started auto-build job ${jobId}`);
|
|
114
|
+
logger.info(`[index] watcher: started auto-build job ${jobId} (Worker Thread)`);
|
|
124
115
|
|
|
125
116
|
// Monitor job completion in background
|
|
126
117
|
// (Job result will be logged when it completes/fails)
|
|
@@ -132,6 +123,37 @@ export class IndexWatcher {
|
|
|
132
123
|
}
|
|
133
124
|
}
|
|
134
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Start a build using Worker Thread pool
|
|
128
|
+
* @param {string} jobId - Job ID for tracking
|
|
129
|
+
* @param {Object} info - Project info
|
|
130
|
+
* @param {string} dbPath - Database file path
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
async _startWorkerBuild(jobId, info, dbPath) {
|
|
134
|
+
// Create job in JobManager
|
|
135
|
+
this.jobManager.create(jobId, async job => {
|
|
136
|
+
// Initialize progress
|
|
137
|
+
job.progress = { processed: 0, total: 0, rate: 0 };
|
|
138
|
+
|
|
139
|
+
// Subscribe to progress updates from Worker
|
|
140
|
+
this.workerPool.onProgress(progress => {
|
|
141
|
+
job.progress = progress;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Execute build in Worker Thread (non-blocking)
|
|
145
|
+
const result = await this.workerPool.executeBuild({
|
|
146
|
+
projectRoot: info.projectRoot,
|
|
147
|
+
dbPath: dbPath,
|
|
148
|
+
concurrency: config.indexing?.watcherConcurrency || 1,
|
|
149
|
+
retry: config.indexing?.retry || 2,
|
|
150
|
+
reportPercentage: 10
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
/**
|
|
136
158
|
* Monitor job completion for logging
|
|
137
159
|
* @private
|
|
@@ -124,7 +124,19 @@ export class HybridStdioServerTransport {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
_readContentLengthMessage() {
|
|
127
|
-
const headerEndIndex =
|
|
127
|
+
const { headerEndIndex, separatorLength } = (() => {
|
|
128
|
+
const crlfIndex = this._buffer.indexOf('\r\n\r\n');
|
|
129
|
+
const lfIndex = this._buffer.indexOf('\n\n');
|
|
130
|
+
|
|
131
|
+
if (crlfIndex === -1 && lfIndex === -1) return { headerEndIndex: -1, separatorLength: 0 };
|
|
132
|
+
if (crlfIndex === -1) return { headerEndIndex: lfIndex, separatorLength: 2 };
|
|
133
|
+
if (lfIndex === -1) return { headerEndIndex: crlfIndex, separatorLength: 4 };
|
|
134
|
+
|
|
135
|
+
return crlfIndex < lfIndex
|
|
136
|
+
? { headerEndIndex: crlfIndex, separatorLength: 4 }
|
|
137
|
+
: { headerEndIndex: lfIndex, separatorLength: 2 };
|
|
138
|
+
})();
|
|
139
|
+
|
|
128
140
|
if (headerEndIndex === -1) {
|
|
129
141
|
return null;
|
|
130
142
|
}
|
|
@@ -132,20 +144,20 @@ export class HybridStdioServerTransport {
|
|
|
132
144
|
const header = this._buffer.toString('utf8', 0, headerEndIndex);
|
|
133
145
|
const match = header.match(HEADER_RE);
|
|
134
146
|
if (!match) {
|
|
135
|
-
this._buffer = this._buffer.subarray(headerEndIndex +
|
|
147
|
+
this._buffer = this._buffer.subarray(headerEndIndex + separatorLength);
|
|
136
148
|
this.onerror?.(new Error('Invalid Content-Length header'));
|
|
137
149
|
return null;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
const length = Number(match[1]);
|
|
141
|
-
const totalMessageLength = headerEndIndex +
|
|
153
|
+
const totalMessageLength = headerEndIndex + separatorLength + length;
|
|
142
154
|
if (this._buffer.length < totalMessageLength) {
|
|
143
155
|
return null;
|
|
144
156
|
}
|
|
145
157
|
|
|
146
158
|
const json = this._buffer.toString(
|
|
147
159
|
'utf8',
|
|
148
|
-
headerEndIndex +
|
|
160
|
+
headerEndIndex + separatorLength,
|
|
149
161
|
totalMessageLength
|
|
150
162
|
);
|
|
151
163
|
this._buffer = this._buffer.subarray(totalMessageLength);
|
|
@@ -40,7 +40,8 @@ export class UnityConnection extends EventEmitter {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// Skip connection in CI/test environments
|
|
43
|
-
|
|
43
|
+
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.CI === 'true';
|
|
44
|
+
if (isTestEnv && process.env.UNITY_MCP_ALLOW_TEST_CONNECT !== '1') {
|
|
44
45
|
logger.info('Skipping Unity connection in test/CI environment');
|
|
45
46
|
reject(new Error('Unity connection disabled in test environment'));
|
|
46
47
|
return;
|
|
@@ -50,6 +51,9 @@ export class UnityConnection extends EventEmitter {
|
|
|
50
51
|
logger.info(`Connecting to Unity at ${targetHost}:${config.unity.port}...`);
|
|
51
52
|
|
|
52
53
|
this.socket = new net.Socket();
|
|
54
|
+
try {
|
|
55
|
+
this.socket.setKeepAlive(true, 5000);
|
|
56
|
+
} catch {}
|
|
53
57
|
let connectionTimeout = null;
|
|
54
58
|
let resolved = false;
|
|
55
59
|
const settle = (fn, value) => {
|
|
@@ -79,6 +83,11 @@ export class UnityConnection extends EventEmitter {
|
|
|
79
83
|
settle(resolve);
|
|
80
84
|
});
|
|
81
85
|
|
|
86
|
+
this.socket.on('end', () => {
|
|
87
|
+
// Treat end as close to trigger reconnection
|
|
88
|
+
this.socket.destroy();
|
|
89
|
+
});
|
|
90
|
+
|
|
82
91
|
this.socket.on('data', data => {
|
|
83
92
|
this.handleData(data);
|
|
84
93
|
});
|
|
@@ -97,6 +106,11 @@ export class UnityConnection extends EventEmitter {
|
|
|
97
106
|
this.socket.destroy();
|
|
98
107
|
this.isDisconnecting = false;
|
|
99
108
|
reject(error);
|
|
109
|
+
} else if (this.connected) {
|
|
110
|
+
// Force close to trigger reconnect logic
|
|
111
|
+
try {
|
|
112
|
+
this.socket.destroy();
|
|
113
|
+
} catch {}
|
|
100
114
|
}
|
|
101
115
|
});
|
|
102
116
|
|
|
@@ -210,6 +224,19 @@ export class UnityConnection extends EventEmitter {
|
|
|
210
224
|
* @param {Buffer} data
|
|
211
225
|
*/
|
|
212
226
|
handleData(data) {
|
|
227
|
+
// Fast-path: accept single unframed JSON message (NDJSON style) from tests/clients
|
|
228
|
+
if (!this.messageBuffer.length) {
|
|
229
|
+
const asString = data.toString('utf8').trim();
|
|
230
|
+
if (asString.startsWith('{')) {
|
|
231
|
+
try {
|
|
232
|
+
const obj = JSON.parse(asString);
|
|
233
|
+
this._processResponseObject(obj);
|
|
234
|
+
return;
|
|
235
|
+
} catch {
|
|
236
|
+
// fall through to framed parsing
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
213
240
|
// Check if this is an unframed Unity debug log
|
|
214
241
|
if (data.length > 0 && !this.messageBuffer.length) {
|
|
215
242
|
const dataStr = data.toString('utf8');
|
|
@@ -281,56 +308,7 @@ export class UnityConnection extends EventEmitter {
|
|
|
281
308
|
logger.debug(`[Unity] Received framed message (length=${message.length})`);
|
|
282
309
|
|
|
283
310
|
const response = JSON.parse(message);
|
|
284
|
-
|
|
285
|
-
`[Unity] Parsed response id=${response.id || 'n/a'} status=${response.status || (response.success === false ? 'error' : 'success')}`
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
// Check if this is a response to a pending command
|
|
289
|
-
if (response.id && this.pendingCommands.has(response.id)) {
|
|
290
|
-
logger.info(`[Unity] Found pending command for ID ${response.id}`);
|
|
291
|
-
const pending = this.pendingCommands.get(response.id);
|
|
292
|
-
this.pendingCommands.delete(response.id);
|
|
293
|
-
|
|
294
|
-
// Handle both old and new response formats
|
|
295
|
-
if (response.status === 'success' || response.success === true) {
|
|
296
|
-
logger.info(`[Unity] Command ${response.id} succeeded`);
|
|
297
|
-
|
|
298
|
-
let result = response.result || response.data || {};
|
|
299
|
-
|
|
300
|
-
// If result is a string, try to parse it as JSON
|
|
301
|
-
if (typeof result === 'string') {
|
|
302
|
-
try {
|
|
303
|
-
result = JSON.parse(result);
|
|
304
|
-
logger.info(`[Unity] Parsed string result as JSON:`, result);
|
|
305
|
-
} catch (parseError) {
|
|
306
|
-
logger.warn(`[Unity] Failed to parse result as JSON: ${parseError.message}`);
|
|
307
|
-
// Keep the original string value
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Include version and editorState information if available
|
|
312
|
-
if (response.version) {
|
|
313
|
-
result._version = response.version;
|
|
314
|
-
}
|
|
315
|
-
if (response.editorState) {
|
|
316
|
-
result._editorState = response.editorState;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
logger.info(`[Unity] Command ${response.id} resolved successfully`);
|
|
320
|
-
pending.resolve(result);
|
|
321
|
-
} else if (response.status === 'error' || response.success === false) {
|
|
322
|
-
logger.error(`[Unity] Command ${response.id} failed:`, response.error);
|
|
323
|
-
pending.reject(new Error(response.error || 'Command failed'));
|
|
324
|
-
} else {
|
|
325
|
-
// Unknown format
|
|
326
|
-
logger.warn(`[Unity] Command ${response.id} has unknown response format`);
|
|
327
|
-
pending.resolve(response);
|
|
328
|
-
}
|
|
329
|
-
} else {
|
|
330
|
-
// Handle unsolicited messages
|
|
331
|
-
logger.debug(`[Unity] Received unsolicited message id=${response.id || 'n/a'}`);
|
|
332
|
-
this.emit('message', response);
|
|
333
|
-
}
|
|
311
|
+
this._processResponseObject(response);
|
|
334
312
|
} catch (error) {
|
|
335
313
|
logger.error('[Unity] Failed to parse response:', error.message);
|
|
336
314
|
logger.debug(`[Unity] Raw message: ${messageData.toString().substring(0, 200)}...`);
|
|
@@ -438,6 +416,55 @@ export class UnityConnection extends EventEmitter {
|
|
|
438
416
|
});
|
|
439
417
|
}
|
|
440
418
|
|
|
419
|
+
_processResponseObject(response) {
|
|
420
|
+
logger.debug(
|
|
421
|
+
`[Unity] Parsed response id=${response?.id || 'n/a'} status=${response?.status || (response?.success === false ? 'error' : 'success')}`
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const id = response?.id != null ? String(response.id) : null;
|
|
425
|
+
|
|
426
|
+
const hasExplicitPending = id && this.pendingCommands.has(id);
|
|
427
|
+
const fallbackPendingId =
|
|
428
|
+
!hasExplicitPending && this.pendingCommands.size > 0
|
|
429
|
+
? this.pendingCommands.keys().next().value
|
|
430
|
+
: null;
|
|
431
|
+
|
|
432
|
+
if (hasExplicitPending || fallbackPendingId) {
|
|
433
|
+
const targetId = hasExplicitPending ? id : fallbackPendingId;
|
|
434
|
+
const pending = this.pendingCommands.get(targetId);
|
|
435
|
+
this.pendingCommands.delete(targetId);
|
|
436
|
+
|
|
437
|
+
if (response.status === 'success' || response.success === true) {
|
|
438
|
+
let result = response.result || response.data || {};
|
|
439
|
+
if (typeof result === 'string') {
|
|
440
|
+
try {
|
|
441
|
+
result = JSON.parse(result);
|
|
442
|
+
logger.info(`[Unity] Parsed string result as JSON:`, result);
|
|
443
|
+
} catch (parseError) {
|
|
444
|
+
logger.warn(`[Unity] Failed to parse result as JSON: ${parseError.message}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (response.version) result._version = response.version;
|
|
448
|
+
if (response.editorState) result._editorState = response.editorState;
|
|
449
|
+
logger.info(`[Unity] Command ${targetId} resolved successfully`);
|
|
450
|
+
pending.resolve(result);
|
|
451
|
+
} else if (response.status === 'error' || response.success === false) {
|
|
452
|
+
logger.error(`[Unity] Command ${targetId} failed:`, response.error);
|
|
453
|
+
const err = new Error(response.error || 'Command failed');
|
|
454
|
+
err.code = response.code;
|
|
455
|
+
pending.reject(err);
|
|
456
|
+
} else {
|
|
457
|
+
logger.warn(`[Unity] Command ${targetId} has unknown response format`);
|
|
458
|
+
pending.resolve(response);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Unsolicited message
|
|
464
|
+
logger.debug(`[Unity] Received unsolicited message id=${response?.id || 'n/a'}`);
|
|
465
|
+
this.emit('message', response);
|
|
466
|
+
}
|
|
467
|
+
|
|
441
468
|
/**
|
|
442
469
|
* Sends a ping command to Unity
|
|
443
470
|
* @returns {Promise<any>}
|