@akiojin/unity-mcp-server 5.3.2 → 5.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "5.3.2",
3
+ "version": "5.4.0",
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",
@@ -105,7 +105,8 @@ const baseConfig = {
105
105
  // Project settings (primarily for code index paths)
106
106
  project: {
107
107
  root: null,
108
- codeIndexRoot: null
108
+ codeIndexRoot: null,
109
+ requireClientRoot: false
109
110
  },
110
111
 
111
112
  // Server settings
@@ -154,7 +155,8 @@ const baseConfig = {
154
155
  // LSP client defaults
155
156
  lsp: {
156
157
  requestTimeoutMs: 120000,
157
- slowRequestWarnMs: 2000
158
+ slowRequestWarnMs: 2000,
159
+ validationTimeoutMs: 5000
158
160
  },
159
161
 
160
162
  // Indexing (code index) settings
@@ -176,6 +178,7 @@ function loadEnvConfig() {
176
178
  const unityPort = parseIntEnv(process.env.UNITY_MCP_PORT);
177
179
 
178
180
  const projectRoot = envString('UNITY_PROJECT_ROOT');
181
+ const requireProjectRoot = parseBoolEnv(process.env.UNITY_MCP_REQUIRE_PROJECT_ROOT);
179
182
 
180
183
  const logLevel = envString('UNITY_MCP_LOG_LEVEL');
181
184
  const versionMismatch = envString('UNITY_MCP_VERSION_MISMATCH');
@@ -186,6 +189,7 @@ function loadEnvConfig() {
186
189
  const telemetryEnabled = parseBoolEnv(process.env.UNITY_MCP_TELEMETRY_ENABLED);
187
190
  const lspRequestTimeoutMs = parseIntEnv(process.env.UNITY_MCP_LSP_REQUEST_TIMEOUT_MS);
188
191
  const lspSlowRequestWarnMs = parseIntEnv(process.env.UNITY_MCP_LSP_SLOW_REQUEST_WARN_MS);
192
+ const lspValidationTimeoutMs = parseIntEnv(process.env.UNITY_MCP_LSP_VALIDATION_TIMEOUT_MS);
189
193
 
190
194
  const out = {};
191
195
 
@@ -201,6 +205,9 @@ function loadEnvConfig() {
201
205
  out.project = {};
202
206
  if (projectRoot) out.project.root = projectRoot;
203
207
  }
208
+ if (requireProjectRoot !== undefined) {
209
+ out.project = { ...(out.project || {}), requireClientRoot: requireProjectRoot };
210
+ }
204
211
 
205
212
  if (logLevel) {
206
213
  out.logging = { level: logLevel };
@@ -226,6 +233,9 @@ function loadEnvConfig() {
226
233
  if (lspSlowRequestWarnMs !== undefined) {
227
234
  out.lsp = { ...(out.lsp || {}), slowRequestWarnMs: lspSlowRequestWarnMs };
228
235
  }
236
+ if (lspValidationTimeoutMs !== undefined) {
237
+ out.lsp = { ...(out.lsp || {}), validationTimeoutMs: lspValidationTimeoutMs };
238
+ }
229
239
 
230
240
  return out;
231
241
  }
@@ -315,6 +325,15 @@ function validateAndNormalizeConfig(cfg) {
315
325
  cfg.lsp.slowRequestWarnMs = 2000;
316
326
  }
317
327
  }
328
+ if (cfg.lsp?.validationTimeoutMs !== undefined) {
329
+ const t = Number(cfg.lsp.validationTimeoutMs);
330
+ if (!Number.isFinite(t) || t < 0) {
331
+ logger.warning(
332
+ `[unity-mcp-server] WARN: Invalid UNITY_MCP_LSP_VALIDATION_TIMEOUT_MS (${cfg.lsp.validationTimeoutMs}); using default 5000`
333
+ );
334
+ cfg.lsp.validationTimeoutMs = 5000;
335
+ }
336
+ }
318
337
  }
319
338
 
320
339
  export const config = merge(baseConfig, loadEnvConfig());
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { logger } from './config.js';
3
+ import { createProjectRootGuard } from './projectRootGuard.js';
3
4
 
4
5
  function buildHealthResponse({ startedAt, mode, port, telemetryEnabled }) {
5
6
  return {
@@ -22,10 +23,12 @@ export function createHttpServer({
22
23
  port = 6401,
23
24
  telemetryEnabled = false,
24
25
  healthPath = '/healthz',
25
- allowedHosts = ['localhost', '127.0.0.1']
26
+ allowedHosts = ['localhost', '127.0.0.1'],
27
+ requireClientRoot = false
26
28
  } = {}) {
27
29
  const startedAt = Date.now();
28
30
  let server;
31
+ const projectRootGuard = createProjectRootGuard({ requireClientRoot, logger });
29
32
 
30
33
  const listener = async (req, res) => {
31
34
  try {
@@ -76,6 +79,18 @@ export function createHttpServer({
76
79
  if (method === 'tools/call' || method === 'callTool') {
77
80
  const name = params?.name;
78
81
  const args = params?.arguments || {};
82
+ const guardError = await projectRootGuard(args);
83
+ if (guardError) {
84
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
85
+ res.end(
86
+ JSON.stringify({
87
+ jsonrpc: '2.0',
88
+ id,
89
+ error: { code: -32010, message: guardError }
90
+ })
91
+ );
92
+ return;
93
+ }
79
94
  const handler = handlers.get(name);
80
95
  if (!handler) {
81
96
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -0,0 +1,15 @@
1
+ export function buildProgress({ phase, processed = 0, total = 0, rate = 0 } = {}) {
2
+ const safePhase = phase && String(phase).trim() ? String(phase) : 'index';
3
+ return {
4
+ phase: safePhase,
5
+ processed: Number.isFinite(processed) ? processed : 0,
6
+ total: Number.isFinite(total) ? total : 0,
7
+ rate: Number.isFinite(rate) ? rate : 0
8
+ };
9
+ }
10
+
11
+ export function getReportEvery(total, steps = 20) {
12
+ const safeTotal = Number.isFinite(total) && total > 0 ? total : 0;
13
+ const safeSteps = Number.isFinite(steps) && steps > 0 ? steps : 20;
14
+ return Math.max(1, Math.floor(safeTotal / safeSteps) || 1);
15
+ }
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { ProjectInfoProvider } from './projectInfo.js';
3
+
4
+ const normalizeRoot = root => {
5
+ if (!root) return '';
6
+ const resolved = path.resolve(String(root));
7
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/g, '');
8
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
9
+ };
10
+
11
+ export function createProjectRootGuard({
12
+ requireClientRoot = false,
13
+ projectInfoProvider = null,
14
+ logger = null
15
+ } = {}) {
16
+ const provider = projectInfoProvider || new ProjectInfoProvider();
17
+ let cachedRoot = null;
18
+
19
+ const getServerRoot = async () => {
20
+ if (cachedRoot) return cachedRoot;
21
+ try {
22
+ const info = await provider.get();
23
+ cachedRoot = normalizeRoot(info?.projectRoot || '');
24
+ } catch (e) {
25
+ logger?.warning?.(`[unity-mcp-server] project root resolve failed: ${e.message}`);
26
+ }
27
+ return cachedRoot;
28
+ };
29
+
30
+ return async function guard(args = {}) {
31
+ const clientRootRaw = args?.projectRoot;
32
+ if (!requireClientRoot && !clientRootRaw) return null;
33
+
34
+ if (!clientRootRaw) {
35
+ return 'projectRoot is required. Call get_server_info and pass projectRoot with tool arguments.';
36
+ }
37
+
38
+ const clientRoot = normalizeRoot(clientRootRaw);
39
+ const serverRoot = await getServerRoot();
40
+ if (!serverRoot) {
41
+ return 'server projectRoot could not be resolved';
42
+ }
43
+ if (clientRoot !== serverRoot) {
44
+ return `projectRoot mismatch (client=${clientRootRaw}, server=${serverRoot})`;
45
+ }
46
+ return null;
47
+ };
48
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import fs from 'node:fs';
17
17
  import { StdioRpcServer } from './stdioRpcServer.js';
18
+ import { createProjectRootGuard } from './projectRootGuard.js';
18
19
 
19
20
  // Deferred state - will be initialized after transport connection
20
21
  let unityConnection = null;
@@ -128,6 +129,15 @@ export async function startServer(options = {}) {
128
129
  stdioEnabled: options.stdioEnabled !== undefined ? options.stdioEnabled : true
129
130
  };
130
131
 
132
+ const projectInfoProvider =
133
+ deps.projectInfoProvider ||
134
+ (deps.ProjectInfoProvider ? new deps.ProjectInfoProvider() : null);
135
+ const projectRootGuard = createProjectRootGuard({
136
+ requireClientRoot: runtimeConfig.project?.requireClientRoot === true,
137
+ logger,
138
+ projectInfoProvider
139
+ });
140
+
131
141
  // Step 2: Create a lightweight stdio MCP server (no TS SDK import)
132
142
  const server =
133
143
  runtimeConfig.stdioEnabled === false
@@ -180,7 +190,8 @@ export async function startServer(options = {}) {
180
190
  port: runtimeConfig.http.port,
181
191
  telemetryEnabled: runtimeConfig.telemetry.enabled,
182
192
  healthPath: runtimeConfig.http.healthPath,
183
- allowedHosts: runtimeConfig.http.allowedHosts
193
+ allowedHosts: runtimeConfig.http.allowedHosts,
194
+ requireClientRoot: runtimeConfig.project?.requireClientRoot === true
184
195
  });
185
196
  try {
186
197
  await httpServerInstance.start();
@@ -362,6 +373,19 @@ export async function startServer(options = {}) {
362
373
  { args }
363
374
  );
364
375
 
376
+ const guardError = await projectRootGuard(args || {});
377
+ if (guardError) {
378
+ logger.error(`[MCP] projectRoot guard failed: ${guardError}`);
379
+ return {
380
+ content: [
381
+ {
382
+ type: 'text',
383
+ text: `Error: ${guardError}\nCode: PROJECT_ROOT_MISMATCH`
384
+ }
385
+ ]
386
+ };
387
+ }
388
+
365
389
  const handler = handlers.get(name);
366
390
  if (!handler) {
367
391
  logger.error(`[MCP] Tool not found: ${name}`);
@@ -3602,6 +3602,14 @@
3602
3602
  "properties": {}
3603
3603
  }
3604
3604
  },
3605
+ {
3606
+ "name": "get_server_info",
3607
+ "description": "Get MCP server identifying information (pid, project root, workspace)",
3608
+ "inputSchema": {
3609
+ "type": "object",
3610
+ "properties": {}
3611
+ }
3612
+ },
3605
3613
  {
3606
3614
  "name": "ping",
3607
3615
  "description": "Test connection to Unity Editor",
@@ -20,6 +20,7 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import os from 'os';
22
22
  import { fileURLToPath } from 'url';
23
+ import { buildProgress, getReportEvery } from '../indexProgress.js';
23
24
 
24
25
  // fast-sql helper: run SQL statement
25
26
  function runSQL(db, sql) {
@@ -71,8 +72,8 @@ function log(level, message) {
71
72
  sendMessage('log', { level, message });
72
73
  }
73
74
 
74
- function sendProgress(processed, total, rate) {
75
- sendMessage('progress', { data: { processed, total, rate } });
75
+ function sendProgress(phase, processed, total, rate) {
76
+ sendMessage('progress', { data: buildProgress({ phase, processed, total, rate }) });
76
77
  }
77
78
 
78
79
  function sendComplete(result) {
@@ -624,6 +625,8 @@ async function runBuild() {
624
625
 
625
626
  // Determine changes (this calls makeSig for each file)
626
627
  log('info', `[worker] Computing file signatures (${files.length} files)...`);
628
+ sendProgress('signature', 0, files.length, 0);
629
+ const signatureReportEvery = getReportEvery(files.length);
627
630
  const sigStartTime = Date.now();
628
631
  let sigProcessed = 0;
629
632
  for (const abs of files) {
@@ -632,13 +635,19 @@ async function runBuild() {
632
635
  wanted.set(rel, sig);
633
636
  sigProcessed++;
634
637
  // Report progress every 10000 files
635
- if (sigProcessed % 10000 === 0) {
638
+ if (sigProcessed % signatureReportEvery === 0) {
636
639
  const elapsed = ((Date.now() - sigStartTime) / 1000).toFixed(1);
637
640
  log('info', `[worker] Signature progress: ${sigProcessed}/${files.length} (${elapsed}s)`);
641
+ const sigElapsed = Math.max(1, Date.now() - sigStartTime);
642
+ const sigRate = parseFloat(((sigProcessed * 1000) / sigElapsed).toFixed(1));
643
+ sendProgress('signature', sigProcessed, files.length, sigRate);
638
644
  }
639
645
  }
640
646
  const sigTime = ((Date.now() - sigStartTime) / 1000).toFixed(1);
641
647
  log('info', `[worker] Signatures computed in ${sigTime}s`);
648
+ const sigElapsed = Math.max(1, Date.now() - sigStartTime);
649
+ const sigRate = parseFloat(((sigProcessed * 1000) / sigElapsed).toFixed(1));
650
+ sendProgress('signature', sigProcessed, files.length, sigRate);
642
651
 
643
652
  for (const [rel, sig] of wanted) {
644
653
  if (current.get(rel) !== sig) changed.push(rel);
@@ -671,6 +680,7 @@ async function runBuild() {
671
680
 
672
681
  // Prepare for updates
673
682
  const absList = changed.map(rel => path.resolve(projectRoot, rel));
683
+ sendProgress('index', 0, absList.length, 0);
674
684
  const startAt = Date.now();
675
685
  let processed = 0;
676
686
  let updated = 0;
@@ -786,7 +796,7 @@ async function runBuild() {
786
796
  currentPercentage >= lastReportedPercentage + reportPercentage ||
787
797
  processed === absList.length
788
798
  ) {
789
- sendProgress(processed, absList.length, rate);
799
+ sendProgress('index', processed, absList.length, rate);
790
800
  log(
791
801
  'info',
792
802
  `[worker] progress ${currentPercentage}% (${processed}/${absList.length}) rate:${rate} f/s`
@@ -10,6 +10,7 @@
10
10
  import { SystemPingToolHandler } from './system/SystemPingToolHandler.js';
11
11
  import { SystemRefreshAssetsToolHandler } from './system/SystemRefreshAssetsToolHandler.js';
12
12
  import { SystemGetCommandStatsToolHandler } from './system/SystemGetCommandStatsToolHandler.js';
13
+ import { SystemGetServerInfoToolHandler } from './system/SystemGetServerInfoToolHandler.js';
13
14
  import { GameObjectCreateToolHandler } from './gameobject/GameObjectCreateToolHandler.js';
14
15
  import { GameObjectFindToolHandler } from './gameobject/GameObjectFindToolHandler.js';
15
16
  import { GameObjectModifyToolHandler } from './gameobject/GameObjectModifyToolHandler.js';
@@ -279,6 +280,7 @@ const HANDLER_CLASSES = [
279
280
  SystemPingToolHandler,
280
281
  SystemRefreshAssetsToolHandler,
281
282
  SystemGetCommandStatsToolHandler,
283
+ SystemGetServerInfoToolHandler,
282
284
 
283
285
  // GameObject handlers
284
286
  GameObjectCreateToolHandler,
@@ -1,6 +1,7 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { JobManager } from '../../core/jobManager.js';
3
3
  import { CodeIndex } from '../../core/codeIndex.js';
4
+ import { getWorkerPool } from '../../core/indexBuildWorkerPool.js';
4
5
 
5
6
  export class CodeIndexStatusToolHandler extends BaseToolHandler {
6
7
  constructor(unityConnection) {
@@ -16,6 +17,7 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
16
17
  this.unityConnection = unityConnection;
17
18
  this.jobManager = JobManager.getInstance();
18
19
  this.codeIndex = new CodeIndex(unityConnection);
20
+ this.workerPool = getWorkerPool();
19
21
  }
20
22
 
21
23
  async execute() {
@@ -53,7 +55,8 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
53
55
  }
54
56
  };
55
57
  }
56
- const buildInProgress = latestBuildJob?.status === 'running';
58
+ const workerBuildRunning = Boolean(this.workerPool?.isRunning?.());
59
+ const buildInProgress = latestBuildJob?.status === 'running' || workerBuildRunning;
57
60
  if (!ready && !buildInProgress) {
58
61
  if (latestBuildJob) {
59
62
  const indexInfo = {
@@ -93,6 +96,51 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
93
96
  message: 'Code index is not built. Please run UnityMCP.build_index first.'
94
97
  };
95
98
  }
99
+ if (!ready && buildInProgress) {
100
+ const buildJob = latestBuildJob
101
+ ? {
102
+ id: latestBuildJob.id,
103
+ status: latestBuildJob.status,
104
+ startedAt: latestBuildJob.startedAt ?? null,
105
+ ...(latestBuildJob.progress
106
+ ? {
107
+ progress: {
108
+ phase: latestBuildJob.progress.phase || 'index',
109
+ ...latestBuildJob.progress
110
+ }
111
+ }
112
+ : {})
113
+ }
114
+ : {
115
+ id: null,
116
+ status: 'running',
117
+ startedAt: null,
118
+ source: 'worker'
119
+ };
120
+
121
+ if (latestBuildJob?.status === 'failed') {
122
+ buildJob.failedAt = latestBuildJob.failedAt ?? null;
123
+ buildJob.error = latestBuildJob.error ?? 'Unknown error';
124
+ }
125
+
126
+ return {
127
+ success: true,
128
+ status: latestBuildJob?.status === 'failed' ? 'failed' : 'pending',
129
+ ready: false,
130
+ totalFiles: 0,
131
+ indexedFiles: 0,
132
+ coverage: 0,
133
+ message: buildInProgress
134
+ ? 'Code index build is running. Check back with get_index_status.'
135
+ : 'Code index is not ready yet.',
136
+ index: {
137
+ ready: false,
138
+ rows: 0,
139
+ lastIndexedAt: null,
140
+ buildJob
141
+ }
142
+ };
143
+ }
96
144
 
97
145
  // Use DB stats directly for fast status check - avoid expensive filesystem traversal
98
146
  const stats = await this.codeIndex.getStats();
@@ -109,7 +157,9 @@ export class CodeIndexStatusToolHandler extends BaseToolHandler {
109
157
  };
110
158
 
111
159
  if (latestBuildJob) {
112
- const progress = latestBuildJob.progress ? { ...latestBuildJob.progress } : undefined;
160
+ const progress = latestBuildJob.progress
161
+ ? { phase: latestBuildJob.progress.phase || 'index', ...latestBuildJob.progress }
162
+ : undefined;
113
163
  indexInfo.buildJob = {
114
164
  id: latestBuildJob.id,
115
165
  status: latestBuildJob.status,
@@ -5,6 +5,7 @@ import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
6
  import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
7
  import { preSyntaxCheck } from './csharpSyntaxCheck.js';
8
+ import { logger } from '../../core/config.js';
8
9
 
9
10
  const MAX_INSTRUCTIONS = 10;
10
11
  const MAX_DIFF_CHARS = 80;
@@ -29,11 +30,6 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
29
30
  description:
30
31
  'If true, run validation and return preview text without writing to disk. Default=false.'
31
32
  },
32
- skipValidation: {
33
- type: 'boolean',
34
- description:
35
- 'If true, skip LSP validation for faster execution. Lightweight syntax checks (brace balance) are still performed. Use for simple edits on large files. Default=false.'
36
- },
37
33
  instructions: {
38
34
  type: 'array',
39
35
  minItems: 1,
@@ -84,7 +80,10 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
84
80
 
85
81
  validate(params) {
86
82
  super.validate(params);
87
- const { path: filePath, instructions } = params;
83
+ const { path: filePath, instructions, skipValidation } = params;
84
+ if (skipValidation === true) {
85
+ throw new Error('skipValidation is not allowed; LSP validation is required');
86
+ }
88
87
  if (!filePath || String(filePath).trim() === '') {
89
88
  throw new Error('path cannot be empty');
90
89
  }
@@ -115,10 +114,17 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
115
114
  }
116
115
 
117
116
  async execute(params) {
117
+ logger.info(
118
+ `[Handler edit_snippet] pid=${process.pid} path=${params?.path || ''} preview=${
119
+ params?.preview === true
120
+ }`
121
+ );
122
+ if (params?.skipValidation === true) {
123
+ throw new Error('skipValidation is not allowed; LSP validation is required');
124
+ }
118
125
  const info = await this.projectInfo.get();
119
126
  const { relative, absolute } = this.#resolvePaths(info, params.path);
120
127
  const preview = params.preview === true;
121
- const skipValidation = params.skipValidation === true;
122
128
  const instructions = params.instructions;
123
129
 
124
130
  let original;
@@ -152,7 +158,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
152
158
  results,
153
159
  original,
154
160
  updated: working,
155
- validationSkipped: skipValidation
161
+ validationSkipped: false
156
162
  });
157
163
  }
158
164
 
@@ -165,16 +171,14 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
165
171
  );
166
172
  }
167
173
 
168
- // LSP validation (skip if skipValidation=true for large files)
174
+ // LSP validation
169
175
  let diagnostics = [];
170
- if (!skipValidation) {
171
- diagnostics = await this.#validateWithLsp(info, relative, working);
172
- const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
173
- if (hasErrors) {
174
- const first = diagnostics.find(d => this.#severityIsError(d.severity));
175
- const msg = first?.message || 'syntax error';
176
- throw new Error(`syntax_error: ${msg}`);
177
- }
176
+ diagnostics = await this.#validateWithLsp(info, relative, working);
177
+ const hasErrors = diagnostics.some(d => this.#severityIsError(d.severity));
178
+ if (hasErrors) {
179
+ const first = diagnostics.find(d => this.#severityIsError(d.severity));
180
+ const msg = first?.message || 'syntax error';
181
+ throw new Error(`syntax_error: ${msg}`);
178
182
  }
179
183
 
180
184
  if (!preview) {
@@ -187,7 +191,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
187
191
  original,
188
192
  updated: working,
189
193
  diagnostics,
190
- validationSkipped: skipValidation
194
+ validationSkipped: false
191
195
  });
192
196
  }
193
197
 
@@ -298,7 +302,30 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
298
302
  if (!this.lsp) {
299
303
  this.lsp = await LspRpcClientSingleton.getValidationInstance(info.projectRoot);
300
304
  }
301
- return await this.lsp.validateText(relative, updatedText);
305
+ const tempRelative = this.#buildTempValidationPath(relative);
306
+ const tempAbsolute = path.join(
307
+ info.projectRoot,
308
+ tempRelative.replace(/\//g, path.sep)
309
+ );
310
+ await fs.mkdir(path.dirname(tempAbsolute), { recursive: true });
311
+ await fs.writeFile(tempAbsolute, updatedText, 'utf8');
312
+ try {
313
+ return await this.lsp.validateText(tempRelative, '');
314
+ } finally {
315
+ try {
316
+ await fs.rm(tempAbsolute, { force: true });
317
+ } catch (e) {
318
+ logger.warning(`[Handler edit_snippet] failed to remove temp file: ${e.message}`);
319
+ }
320
+ }
321
+ }
322
+
323
+ #buildTempValidationPath(relative) {
324
+ const ext = path.extname(relative) || '.cs';
325
+ const base = path.basename(relative, ext).replace(/[^a-zA-Z0-9._-]/g, '_');
326
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
327
+ const hash = crypto.createHash('sha1').update(relative).digest('hex').slice(0, 8);
328
+ return `.unity/tmp/edit-snippet/${base}_${hash}_${stamp}${ext}`;
302
329
  }
303
330
 
304
331
  #buildResponse({
@@ -351,4 +378,5 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
351
378
  const s = String(severity).toLowerCase();
352
379
  return s === 'error' || s === '2';
353
380
  }
381
+
354
382
  }
@@ -0,0 +1,55 @@
1
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
+ import { CATEGORIES, SCOPES } from '../base/categories.js';
3
+ import { ProjectInfoProvider } from '../../core/projectInfo.js';
4
+ import { config, WORKSPACE_ROOT } from '../../core/config.js';
5
+
6
+ /**
7
+ * Handler for the get_server_info tool
8
+ * Provides identifiers to distinguish multiple MCP servers
9
+ */
10
+ export class SystemGetServerInfoToolHandler extends BaseToolHandler {
11
+ constructor(unityConnection) {
12
+ super(
13
+ 'get_server_info',
14
+ 'Get MCP server identifying information (pid, project root, workspace)',
15
+ {
16
+ type: 'object',
17
+ properties: {}
18
+ },
19
+ {
20
+ category: CATEGORIES.SYSTEM,
21
+ scope: SCOPES.READ,
22
+ keywords: ['server', 'info', 'pid', 'project', 'workspace', 'identify'],
23
+ tags: ['system', 'diagnostic']
24
+ }
25
+ );
26
+
27
+ this.unityConnection = unityConnection;
28
+ this.projectInfo = new ProjectInfoProvider();
29
+ }
30
+
31
+ async execute() {
32
+ const info = await this.projectInfo.get();
33
+ return {
34
+ success: true,
35
+ pid: process.pid,
36
+ projectRoot: info.projectRoot,
37
+ assetsPath: info.assetsPath,
38
+ packagesPath: info.packagesPath,
39
+ codeIndexRoot: info.codeIndexRoot,
40
+ workspaceRoot: WORKSPACE_ROOT,
41
+ server: {
42
+ name: config?.server?.name,
43
+ version: config?.server?.version
44
+ },
45
+ unity: {
46
+ host: config?.unity?.unityHost ?? config?.unity?.mcpHost,
47
+ port: config?.unity?.port
48
+ },
49
+ http: {
50
+ enabled: config?.http?.enabled,
51
+ port: config?.http?.port
52
+ }
53
+ };
54
+ }
55
+ }
@@ -152,12 +152,19 @@ export class LspRpcClient {
152
152
  return resp;
153
153
  }
154
154
 
155
- async request(method, params) {
156
- return await this.#requestWithRetry(method, params, 1);
155
+ async request(method, params, options = {}) {
156
+ return await this.#requestWithRetry(method, params, 1, options);
157
157
  }
158
158
 
159
159
  async validateText(relative, newText) {
160
- const resp = await this.request('mcp/validateTextEdits', { relative, newText });
160
+ const timeoutMs = Number.isFinite(config.lsp?.validationTimeoutMs)
161
+ ? config.lsp.validationTimeoutMs
162
+ : undefined;
163
+ const resp = await this.request(
164
+ 'mcp/validateTextEdits',
165
+ { relative, newText },
166
+ { timeoutMs }
167
+ );
161
168
  if (!resp) return [];
162
169
  const payload = resp.result ?? resp;
163
170
  const diagnostics = Array.isArray(payload?.diagnostics) ? payload.diagnostics : [];
@@ -170,10 +177,14 @@ export class LspRpcClient {
170
177
  }));
171
178
  }
172
179
 
173
- async #requestWithRetry(method, params, attempt) {
180
+ async #requestWithRetry(method, params, attempt, options) {
174
181
  let id = null;
175
182
  let timeoutHandle = null;
176
- const timeoutMs = Math.max(1000, Math.min(300000, config.lsp?.requestTimeoutMs || 60000));
183
+ const configuredTimeout = config.lsp?.requestTimeoutMs || 60000;
184
+ const overrideTimeout = options?.timeoutMs;
185
+ const timeoutMs = Number.isFinite(overrideTimeout)
186
+ ? Math.max(1000, Math.min(300000, overrideTimeout))
187
+ : Math.max(1000, Math.min(300000, configuredTimeout));
177
188
  const startedAt = Date.now();
178
189
  try {
179
190
  await this.ensure();
@@ -227,7 +238,7 @@ export class LspRpcClient {
227
238
  logger.warning(
228
239
  `[unity-mcp-server:lsp] recoverable error on ${method}: ${msg}. Retrying once...`
229
240
  );
230
- return await this.#requestWithRetry(method, params, attempt + 1);
241
+ return await this.#requestWithRetry(method, params, attempt + 1, options);
231
242
  }
232
243
  // Standardize error message with actionable recovery instructions
233
244
  let hint;