@akiojin/unity-mcp-server 2.41.7 → 2.41.8

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.41.7",
3
+ "version": "2.41.8",
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",
@@ -3,7 +3,7 @@ import { CodeIndex } from '../../core/codeIndex.js';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
6
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
7
  import { logger } from '../../core/config.js';
8
8
  import { JobManager } from '../../core/jobManager.js';
9
9
 
@@ -99,7 +99,7 @@ export class CodeIndexBuildToolHandler extends BaseToolHandler {
99
99
  // Initialize LSP with error handling
100
100
  if (!this.lsp) {
101
101
  try {
102
- this.lsp = new LspRpcClient(info.projectRoot);
102
+ this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
103
103
  logger.info(`[index][${job.id}] LSP initialized for project: ${info.projectRoot}`);
104
104
  } catch (lspError) {
105
105
  logger.error(`[index][${job.id}] LSP initialization failed: ${lspError.message}`);
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
4
4
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
5
  import { CodeIndex } from '../../core/codeIndex.js';
6
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
6
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
7
 
8
8
  /**
9
9
  * Incrementally refresh the persistent code index after local edits.
@@ -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 = new LspRpcClient(projectRoot);
63
+ this.lsp = await LspRpcClientSingleton.getInstance(projectRoot);
64
64
  }
65
65
 
66
66
  const normalized = requested.map(p => this._normalizePath(p, projectRoot));
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import crypto from 'crypto';
4
4
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
5
5
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
6
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
6
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
7
7
 
8
8
  const MAX_INSTRUCTIONS = 10;
9
9
  const MAX_DIFF_CHARS = 80;
@@ -258,7 +258,7 @@ export class ScriptEditSnippetToolHandler extends BaseToolHandler {
258
258
 
259
259
  async #validateWithLsp(info, relative, updatedText) {
260
260
  if (!this.lsp) {
261
- this.lsp = new LspRpcClient(info.projectRoot);
261
+ this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
262
262
  }
263
263
  return await this.lsp.validateText(relative, updatedText);
264
264
  }
@@ -1,5 +1,5 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
2
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
3
3
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
4
4
 
5
5
  export class ScriptEditStructuredToolHandler extends BaseToolHandler {
@@ -91,7 +91,7 @@ export class ScriptEditStructuredToolHandler extends BaseToolHandler {
91
91
 
92
92
  // Map operations to LSP extensions
93
93
  const info = await this.projectInfo.get();
94
- if (!this.lsp) this.lsp = new LspRpcClient(info.projectRoot);
94
+ if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
95
95
 
96
96
  if (operation === 'replace_body') {
97
97
  const resp = await this.lsp.request('mcp/replaceSymbolBody', {
@@ -1,5 +1,5 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
2
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
3
3
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
4
4
 
5
5
  export class ScriptRefactorRenameToolHandler extends BaseToolHandler {
@@ -38,7 +38,7 @@ 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 = new LspRpcClient(info.projectRoot);
41
+ if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
42
42
  const resp = await this.lsp.request('mcp/renameByNamePath', {
43
43
  relative: String(relative).replace(/\\\\/g, '/'),
44
44
  namePath: String(namePath),
@@ -1,6 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { CodeIndex } from '../../core/codeIndex.js';
3
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
3
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
4
4
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
5
 
6
6
  export class ScriptRefsFindToolHandler extends BaseToolHandler {
@@ -84,7 +84,7 @@ export class ScriptRefsFindToolHandler extends BaseToolHandler {
84
84
 
85
85
  // LSP拡張へ委譲(mcp/referencesByName)
86
86
  const info = await this.projectInfo.get();
87
- if (!this.lsp) this.lsp = new LspRpcClient(info.projectRoot);
87
+ if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
88
88
  const resp = await this.lsp.request('mcp/referencesByName', { name: String(name) });
89
89
  let raw = Array.isArray(resp?.result) ? resp.result : [];
90
90
 
@@ -1,5 +1,5 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
2
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
3
3
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
4
4
 
5
5
  // script_*系に名称統一: シンボル削除(型/メンバー)
@@ -39,7 +39,7 @@ 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 = new LspRpcClient(info.projectRoot);
42
+ if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
43
43
  const resp = await this.lsp.request('mcp/removeSymbol', {
44
44
  relative: String(path).replace(/\\\\/g, '/'),
45
45
  namePath: String(namePath),
@@ -1,6 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { CodeIndex } from '../../core/codeIndex.js';
3
- import { LspRpcClient } from '../../lsp/LspRpcClient.js';
3
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
4
4
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
5
 
6
6
  export class ScriptSymbolFindToolHandler extends BaseToolHandler {
@@ -72,7 +72,7 @@ export class ScriptSymbolFindToolHandler extends BaseToolHandler {
72
72
  }));
73
73
  } else {
74
74
  const info = await this.projectInfo.get();
75
- if (!this.lsp) this.lsp = new LspRpcClient(info.projectRoot);
75
+ if (!this.lsp) this.lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
76
76
  const resp = await this.lsp.request('workspace/symbol', { query: String(name) });
77
77
  const arr = resp?.result || [];
78
78
  const root = String(info.projectRoot || '').replace(/\\\\/g, '/');
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
4
4
  import { ProjectInfoProvider } from '../../core/projectInfo.js';
5
+ import { LspRpcClientSingleton } from '../../lsp/LspRpcClientSingleton.js';
5
6
 
6
7
  export class ScriptSymbolsGetToolHandler extends BaseToolHandler {
7
8
  constructor(unityConnection) {
@@ -55,8 +56,7 @@ export class ScriptSymbolsGetToolHandler extends BaseToolHandler {
55
56
  const abs = path.join(info.projectRoot, relPath);
56
57
  const st = await fs.stat(abs).catch(() => null);
57
58
  if (!st || !st.isFile()) return { error: 'File not found', path: relPath };
58
- const { LspRpcClient } = await import('../../lsp/LspRpcClient.js');
59
- const lsp = new LspRpcClient(info.projectRoot);
59
+ const lsp = await LspRpcClientSingleton.getInstance(info.projectRoot);
60
60
  const uri = 'file://' + abs.replace(/\\\\/g, '/');
61
61
  const res = await lsp.request('textDocument/documentSymbol', { textDocument: { uri } });
62
62
  const docSymbols = res?.result ?? res ?? [];
@@ -51,6 +51,15 @@ export class LspProcessManager {
51
51
  if (!this.state.proc || this.state.proc.killed) return;
52
52
  const p = this.state.proc;
53
53
  this.state.proc = null;
54
+
55
+ // Remove all listeners to prevent memory leaks
56
+ try {
57
+ if (p.stdout) p.stdout.removeAllListeners();
58
+ if (p.stderr) p.stderr.removeAllListeners();
59
+ p.removeAllListeners('error');
60
+ p.removeAllListeners('close');
61
+ } catch {}
62
+
54
63
  try {
55
64
  // Send LSP shutdown/exit if possible
56
65
  const shutdown = obj => {
@@ -0,0 +1,90 @@
1
+ import { LspRpcClient } from './LspRpcClient.js';
2
+ import { logger } from '../core/config.js';
3
+
4
+ /**
5
+ * Singleton manager for LspRpcClient instances.
6
+ * Maintains one client per projectRoot to enable process reuse and improve performance.
7
+ */
8
+
9
+ let instance = null;
10
+ let currentProjectRoot = null;
11
+ let heartbeatTimer = null;
12
+
13
+ export class LspRpcClientSingleton {
14
+ /**
15
+ * Get or create a shared LspRpcClient instance.
16
+ * @param {string} projectRoot - Project root path
17
+ * @returns {Promise<LspRpcClient>} Shared client instance
18
+ */
19
+ static async getInstance(projectRoot) {
20
+ // If projectRoot changed, reset the instance
21
+ if (instance && currentProjectRoot !== projectRoot) {
22
+ logger.info('[LspRpcClientSingleton] projectRoot changed, resetting instance');
23
+ await LspRpcClientSingleton.reset();
24
+ }
25
+
26
+ if (!instance) {
27
+ instance = new LspRpcClient(projectRoot);
28
+ currentProjectRoot = projectRoot;
29
+ logger.info(`[LspRpcClientSingleton] created new instance for ${projectRoot}`);
30
+ LspRpcClientSingleton.#startHeartbeat();
31
+ }
32
+
33
+ return instance;
34
+ }
35
+
36
+ /**
37
+ * Reset the singleton instance and stop the process.
38
+ */
39
+ static async reset() {
40
+ LspRpcClientSingleton.#stopHeartbeat();
41
+ if (instance) {
42
+ try {
43
+ await instance.mgr.stop();
44
+ } catch (e) {
45
+ logger.warn(`[LspRpcClientSingleton] error stopping: ${e.message}`);
46
+ }
47
+ instance = null;
48
+ currentProjectRoot = null;
49
+ logger.info('[LspRpcClientSingleton] instance reset');
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Start heartbeat monitoring to detect dead processes.
55
+ */
56
+ static #startHeartbeat() {
57
+ if (heartbeatTimer) return;
58
+ heartbeatTimer = setInterval(async () => {
59
+ if (!instance) return;
60
+ try {
61
+ // Use workspace/symbol with empty query as a lightweight ping
62
+ await instance.request('workspace/symbol', { query: '' });
63
+ } catch (e) {
64
+ logger.warn(`[LspRpcClientSingleton] heartbeat failed: ${e.message}, resetting...`);
65
+ // Process is dead, reset instance for next request
66
+ instance = null;
67
+ currentProjectRoot = null;
68
+ LspRpcClientSingleton.#stopHeartbeat();
69
+ }
70
+ }, 30000); // 30 seconds
71
+ }
72
+
73
+ /**
74
+ * Stop heartbeat monitoring.
75
+ */
76
+ static #stopHeartbeat() {
77
+ if (heartbeatTimer) {
78
+ clearInterval(heartbeatTimer);
79
+ heartbeatTimer = null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check if an instance exists.
85
+ * @returns {boolean}
86
+ */
87
+ static hasInstance() {
88
+ return instance !== null;
89
+ }
90
+ }