@akiojin/unity-mcp-server 2.45.0 → 2.45.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akiojin/unity-mcp-server",
|
|
3
|
-
"version": "2.45.
|
|
3
|
+
"version": "2.45.2",
|
|
4
4
|
"description": "MCP server and Unity Editor bridge — enables AI assistants to control Unity for AI-assisted workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/core/server.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
52
52
|
"find-up": "^6.3.0",
|
|
53
|
-
"@akiojin/fast-sql": "
|
|
53
|
+
"@akiojin/fast-sql": "^0.1.0"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"node": ">=18 <23"
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { BaseToolHandler } from '../base/BaseToolHandler.js';
|
|
2
|
-
import { CodeIndex } from '../../core/codeIndex.js';
|
|
3
|
-
import fs from 'fs';
|
|
4
2
|
import path from 'path';
|
|
5
3
|
import { ProjectInfoProvider } from '../../core/projectInfo.js';
|
|
6
|
-
import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
|
|
7
|
-
import { logger } from '../../core/config.js';
|
|
8
4
|
import { JobManager } from '../../core/jobManager.js';
|
|
5
|
+
import { getWorkerPool } from '../../core/indexBuildWorkerPool.js';
|
|
9
6
|
|
|
10
7
|
export class CodeIndexBuildToolHandler extends BaseToolHandler {
|
|
11
8
|
constructor(unityConnection) {
|
|
@@ -21,26 +18,25 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
|
|
|
21
18
|
description:
|
|
22
19
|
'Optional delay in milliseconds after processing each file (testing/debugging).'
|
|
23
20
|
},
|
|
24
|
-
|
|
21
|
+
retry: {
|
|
25
22
|
type: 'number',
|
|
26
23
|
minimum: 0,
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
maximum: 5,
|
|
25
|
+
description: 'Number of retries for LSP requests (default: 2).'
|
|
29
26
|
}
|
|
30
27
|
},
|
|
31
28
|
required: []
|
|
32
29
|
}
|
|
33
30
|
);
|
|
34
31
|
this.unityConnection = unityConnection;
|
|
35
|
-
this.index = new CodeIndex(unityConnection);
|
|
36
32
|
this.projectInfo = new ProjectInfoProvider(unityConnection);
|
|
37
|
-
this.lsp = null; // lazy init with projectRoot
|
|
38
33
|
this.jobManager = JobManager.getInstance();
|
|
34
|
+
this.workerPool = getWorkerPool();
|
|
39
35
|
this.currentJobId = null; // Track current running job
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
async execute(params = {}) {
|
|
43
|
-
// Check if a build is already running
|
|
39
|
+
// Check if a build is already running via JobManager
|
|
44
40
|
if (this.currentJobId) {
|
|
45
41
|
const existingJob = this.jobManager.get(this.currentJobId);
|
|
46
42
|
if (existingJob && existingJob.status === 'running') {
|
|
@@ -53,283 +49,62 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
|
|
|
53
49
|
}
|
|
54
50
|
}
|
|
55
51
|
|
|
52
|
+
// Check Worker Pool status (prevents concurrent Worker Thread builds)
|
|
53
|
+
if (this.workerPool.isRunning()) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: 'build_already_running',
|
|
57
|
+
message: 'Code index build is already running (Worker Thread). Use code_index_status to check progress.',
|
|
58
|
+
jobId: this.currentJobId
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
// Generate new jobId
|
|
57
63
|
const jobId = `build-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
|
58
64
|
this.currentJobId = jobId;
|
|
59
65
|
|
|
60
|
-
//
|
|
66
|
+
// Get project info for DB path
|
|
67
|
+
const info = await this.projectInfo.get();
|
|
68
|
+
const dbPath = path.join(info.codeIndexRoot, 'code-index.db');
|
|
69
|
+
|
|
70
|
+
// Create background job using Worker Thread (non-blocking)
|
|
71
|
+
// This is the key change: execute build in Worker Thread instead of main thread
|
|
61
72
|
this.jobManager.create(jobId, async job => {
|
|
62
|
-
|
|
73
|
+
// Initialize progress
|
|
74
|
+
job.progress = { processed: 0, total: 0, rate: 0 };
|
|
75
|
+
|
|
76
|
+
// Subscribe to progress updates from Worker Thread
|
|
77
|
+
this.workerPool.onProgress(progress => {
|
|
78
|
+
job.progress = progress;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Execute build in Worker Thread (non-blocking for main event loop)
|
|
83
|
+
const result = await this.workerPool.executeBuild({
|
|
84
|
+
projectRoot: info.projectRoot,
|
|
85
|
+
dbPath: dbPath,
|
|
86
|
+
concurrency: 1, // Worker Thread uses sequential processing for stability
|
|
87
|
+
throttleMs: Math.max(0, Number(params?.throttleMs ?? 0)),
|
|
88
|
+
retry: Math.max(0, Math.min(5, Number(params?.retry ?? 2))),
|
|
89
|
+
reportPercentage: 10
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Clear current job tracking on success
|
|
93
|
+
this.currentJobId = null;
|
|
94
|
+
return result;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Clear current job tracking on error
|
|
97
|
+
this.currentJobId = null;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
63
100
|
});
|
|
64
101
|
|
|
65
|
-
// Return immediately with jobId
|
|
102
|
+
// Return immediately with jobId (non-blocking)
|
|
66
103
|
return {
|
|
67
104
|
success: true,
|
|
68
105
|
jobId,
|
|
69
|
-
message: 'Code index build started in background',
|
|
106
|
+
message: 'Code index build started in background (Worker Thread)',
|
|
70
107
|
checkStatus: 'Use code_index_status to check progress and completion'
|
|
71
108
|
};
|
|
72
109
|
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Internal method that performs the actual build
|
|
76
|
-
* @private
|
|
77
|
-
*/
|
|
78
|
-
async _executeBuild(params, job) {
|
|
79
|
-
try {
|
|
80
|
-
// Fail fast when native SQLite binding is unavailable
|
|
81
|
-
const db = await this.index.open();
|
|
82
|
-
if (!db) {
|
|
83
|
-
const reason = this.index.disableReason || 'Code index unavailable (SQLite driver missing)';
|
|
84
|
-
throw new Error(reason);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const throttleMs = Math.max(0, Number(params?.throttleMs ?? 0));
|
|
88
|
-
const delayStartMs = Math.max(0, Number(params?.delayStartMs ?? 0));
|
|
89
|
-
const info = await this.projectInfo.get();
|
|
90
|
-
const roots = [
|
|
91
|
-
path.resolve(info.projectRoot, 'Assets'),
|
|
92
|
-
path.resolve(info.projectRoot, 'Packages'),
|
|
93
|
-
path.resolve(info.projectRoot, 'Library/PackageCache')
|
|
94
|
-
];
|
|
95
|
-
const files = [];
|
|
96
|
-
const seen = new Set();
|
|
97
|
-
for (const r of roots) this.walkCs(r, files, seen);
|
|
98
|
-
|
|
99
|
-
// Initialize LSP with error handling
|
|
100
|
-
if (!this.lsp) {
|
|
101
|
-
try {
|
|
102
|
-
this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
|
|
103
|
-
logger.info(`[index][${job.id}] LSP initialized for project: ${info.projectRoot}`);
|
|
104
|
-
} catch (lspError) {
|
|
105
|
-
logger.error(`[index][${job.id}] LSP initialization failed: ${lspError.message}`);
|
|
106
|
-
throw new Error(
|
|
107
|
-
`LSP initialization failed: ${lspError.message}. Ensure C# LSP is properly configured and OmniSharp is available.`
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
const lsp = this.lsp;
|
|
112
|
-
|
|
113
|
-
// Incremental detection based on size-mtime signature
|
|
114
|
-
const makeSig = abs => {
|
|
115
|
-
try {
|
|
116
|
-
const st = fs.statSync(abs);
|
|
117
|
-
return `${st.size}-${Math.floor(st.mtimeMs)}`;
|
|
118
|
-
} catch {
|
|
119
|
-
return '0-0';
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
const wanted = new Map(files.map(abs => [this.toRel(abs, info.projectRoot), makeSig(abs)]));
|
|
123
|
-
const current = await this.index.getFiles();
|
|
124
|
-
const changed = [];
|
|
125
|
-
const removed = [];
|
|
126
|
-
for (const [rel, sig] of wanted) {
|
|
127
|
-
if (current.get(rel) !== sig) changed.push(rel);
|
|
128
|
-
}
|
|
129
|
-
for (const [rel] of current) if (!wanted.has(rel)) removed.push(rel);
|
|
130
|
-
const toRows = (uri, symbols) => {
|
|
131
|
-
const rel = this.toRel(uri.replace('file://', ''), info.projectRoot);
|
|
132
|
-
const rows = [];
|
|
133
|
-
const visit = (s, container) => {
|
|
134
|
-
const kind = this.kindFromLsp(s.kind);
|
|
135
|
-
const name = s.name || '';
|
|
136
|
-
const start = s.range?.start || s.selectionRange?.start || {};
|
|
137
|
-
rows.push({
|
|
138
|
-
path: rel,
|
|
139
|
-
name,
|
|
140
|
-
kind,
|
|
141
|
-
container: container || null,
|
|
142
|
-
ns: null,
|
|
143
|
-
line: (start.line ?? 0) + 1,
|
|
144
|
-
column: (start.character ?? 0) + 1
|
|
145
|
-
});
|
|
146
|
-
if (Array.isArray(s.children)) for (const c of s.children) visit(c, name || container);
|
|
147
|
-
};
|
|
148
|
-
if (Array.isArray(symbols)) for (const s of symbols) visit(s, null);
|
|
149
|
-
return rows;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
// Remove vanished files
|
|
153
|
-
for (const rel of removed) await this.index.removeFile(rel);
|
|
154
|
-
|
|
155
|
-
// Update changed files
|
|
156
|
-
const absList = changed.map(rel => path.resolve(info.projectRoot, rel));
|
|
157
|
-
const concurrency = Math.max(1, Math.min(64, Number(params?.concurrency ?? 8)));
|
|
158
|
-
const reportPercentage = Math.max(1, Math.min(100, Number(params?.reportPercentage ?? 10)));
|
|
159
|
-
const startAt = Date.now();
|
|
160
|
-
let i = 0;
|
|
161
|
-
let updated = 0;
|
|
162
|
-
let processed = 0;
|
|
163
|
-
let lastReportedPercentage = 0;
|
|
164
|
-
|
|
165
|
-
// Initialize progress
|
|
166
|
-
job.progress.total = absList.length;
|
|
167
|
-
job.progress.processed = 0;
|
|
168
|
-
job.progress.rate = 0;
|
|
169
|
-
|
|
170
|
-
logger.info(
|
|
171
|
-
`[index][${job.id}] Build started: ${absList.length} files to process, ${removed.length} to remove (status: ${job.status})`
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
// LSP request with small retry/backoff
|
|
175
|
-
const requestWithRetry = async (
|
|
176
|
-
uri,
|
|
177
|
-
maxRetries = Math.max(0, Math.min(5, Number(params?.retry ?? 2)))
|
|
178
|
-
) => {
|
|
179
|
-
let lastErr = null;
|
|
180
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
181
|
-
try {
|
|
182
|
-
const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
|
|
183
|
-
return res?.result ?? res;
|
|
184
|
-
} catch (err) {
|
|
185
|
-
lastErr = err;
|
|
186
|
-
await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
throw lastErr || new Error('documentSymbol failed');
|
|
190
|
-
};
|
|
191
|
-
if (delayStartMs > 0) {
|
|
192
|
-
await new Promise(resolve => setTimeout(resolve, delayStartMs));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const worker = async () => {
|
|
196
|
-
while (true) {
|
|
197
|
-
const idx = i++;
|
|
198
|
-
if (idx >= absList.length) break;
|
|
199
|
-
const abs = absList[idx];
|
|
200
|
-
const rel = this.toRel(abs, info.projectRoot);
|
|
201
|
-
try {
|
|
202
|
-
const uri = 'file://' + abs.replace(/\\/g, '/');
|
|
203
|
-
const docSymbols = await requestWithRetry(uri, 2);
|
|
204
|
-
const rows = toRows(uri, docSymbols);
|
|
205
|
-
await this.index.replaceSymbolsForPath(rel, rows);
|
|
206
|
-
await this.index.upsertFile(rel, wanted.get(rel));
|
|
207
|
-
updated += 1;
|
|
208
|
-
} catch (err) {
|
|
209
|
-
// File access or LSP error - skip and continue
|
|
210
|
-
// This allows build to continue even if some files fail
|
|
211
|
-
if (processed % 50 === 0) {
|
|
212
|
-
// Log occasionally to avoid spam
|
|
213
|
-
logger.warning(
|
|
214
|
-
`[index][${job.id}] Skipped file due to error: ${rel} - ${err.message}`
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
} finally {
|
|
218
|
-
processed += 1;
|
|
219
|
-
|
|
220
|
-
// Update job progress
|
|
221
|
-
const elapsed = Math.max(1, Date.now() - startAt);
|
|
222
|
-
job.progress.processed = processed;
|
|
223
|
-
job.progress.rate = parseFloat(((processed * 1000) / elapsed).toFixed(1));
|
|
224
|
-
|
|
225
|
-
// Calculate current percentage
|
|
226
|
-
const currentPercentage = Math.floor((processed / absList.length) * 100);
|
|
227
|
-
|
|
228
|
-
// Log when percentage increases by reportPercentage (default: 10%)
|
|
229
|
-
if (
|
|
230
|
-
currentPercentage >= lastReportedPercentage + reportPercentage ||
|
|
231
|
-
processed === absList.length
|
|
232
|
-
) {
|
|
233
|
-
logger.info(
|
|
234
|
-
`[index][${job.id}] progress ${currentPercentage}% (${processed}/${absList.length}) removed:${removed.length} rate:${job.progress.rate} f/s`
|
|
235
|
-
);
|
|
236
|
-
lastReportedPercentage = currentPercentage;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Yield to event loop after each file to allow MCP requests to be processed
|
|
240
|
-
// This prevents the index build from blocking other MCP tool calls (US-8.1)
|
|
241
|
-
// Use setTimeout(1) instead of setImmediate to ensure MCP I/O callbacks get processed
|
|
242
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
243
|
-
|
|
244
|
-
if (throttleMs > 0) {
|
|
245
|
-
await new Promise(resolve => setTimeout(resolve, throttleMs));
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
};
|
|
250
|
-
const workers = Array.from({ length: Math.min(concurrency, absList.length) }, () => worker());
|
|
251
|
-
await Promise.all(workers);
|
|
252
|
-
|
|
253
|
-
const stats = await this.index.getStats();
|
|
254
|
-
|
|
255
|
-
// Clear current job tracking on success
|
|
256
|
-
if (this.currentJobId === job.id) {
|
|
257
|
-
this.currentJobId = null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const result = {
|
|
261
|
-
updatedFiles: updated,
|
|
262
|
-
removedFiles: removed.length,
|
|
263
|
-
totalIndexedSymbols: stats.total,
|
|
264
|
-
lastIndexedAt: stats.lastIndexedAt
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
logger.info(
|
|
268
|
-
`[index][${job.id}] Build completed successfully: updated=${result.updatedFiles}, removed=${result.removedFiles}, total=${result.totalIndexedSymbols} (status: completed)`
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
return result;
|
|
272
|
-
} catch (e) {
|
|
273
|
-
// Clear current job tracking on error
|
|
274
|
-
if (this.currentJobId === job.id) {
|
|
275
|
-
this.currentJobId = null;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Log detailed error with job context
|
|
279
|
-
logger.error(`[index][${job.id}] Build failed: ${e.message} (status: failed)`);
|
|
280
|
-
|
|
281
|
-
// Provide helpful error message with context
|
|
282
|
-
const errorMessage = e.message.includes('LSP')
|
|
283
|
-
? `LSP error: ${e.message}`
|
|
284
|
-
: `Build error: ${e.message}. Hint: C# LSP not ready. Ensure manifest/auto-download and workspace paths are valid.`;
|
|
285
|
-
|
|
286
|
-
throw new Error(errorMessage);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
walkCs(root, files, seen) {
|
|
291
|
-
try {
|
|
292
|
-
if (!fs.existsSync(root)) return;
|
|
293
|
-
const st = fs.statSync(root);
|
|
294
|
-
if (st.isFile()) {
|
|
295
|
-
if (root.endsWith('.cs') && !seen.has(root)) {
|
|
296
|
-
files.push(root);
|
|
297
|
-
seen.add(root);
|
|
298
|
-
}
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
302
|
-
for (const e of entries) {
|
|
303
|
-
if (e.name === 'obj' || e.name === 'bin' || e.name.startsWith('.')) continue;
|
|
304
|
-
this.walkCs(path.join(root, e.name), files, seen);
|
|
305
|
-
}
|
|
306
|
-
} catch {}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
toRel(full, projectRoot) {
|
|
310
|
-
const normFull = String(full).replace(/\\/g, '/');
|
|
311
|
-
const normRoot = String(projectRoot).replace(/\\/g, '/').replace(/\/$/, '');
|
|
312
|
-
return normFull.startsWith(normRoot) ? normFull.substring(normRoot.length + 1) : normFull;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
kindFromLsp(k) {
|
|
316
|
-
switch (k) {
|
|
317
|
-
case 5:
|
|
318
|
-
return 'class';
|
|
319
|
-
case 23:
|
|
320
|
-
return 'struct';
|
|
321
|
-
case 11:
|
|
322
|
-
return 'interface';
|
|
323
|
-
case 10:
|
|
324
|
-
return 'enum';
|
|
325
|
-
case 6:
|
|
326
|
-
return 'method';
|
|
327
|
-
case 7:
|
|
328
|
-
return 'property';
|
|
329
|
-
case 8:
|
|
330
|
-
return 'field';
|
|
331
|
-
case 3:
|
|
332
|
-
return 'namespace';
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
110
|
}
|