@akiojin/unity-mcp-server 2.42.1 → 2.42.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.
@@ -1,8 +1,92 @@
1
1
  #!/usr/bin/env node
2
2
  import { startServer } from '../src/core/server.js';
3
+ import { listInstances } from '../src/cli/commands/listInstances.js';
4
+ import { setActive } from '../src/cli/commands/setActive.js';
3
5
 
4
- startServer().catch(error => {
5
- console.error('Fatal error:', error);
6
- console.error('Stack trace:', error?.stack);
7
- process.exit(1);
8
- });
6
+ const args = process.argv.slice(2);
7
+ const command = args[0] && !args[0].startsWith('--') ? args[0] : null;
8
+ const rest = command ? args.slice(1) : args;
9
+ let httpEnabled = false;
10
+ let httpPort;
11
+ let stdioEnabled = true;
12
+ let telemetryEnabled;
13
+
14
+ for (let i = 0; i < rest.length; i++) {
15
+ const arg = rest[i];
16
+ switch (arg) {
17
+ case '--http':
18
+ httpEnabled = true;
19
+ if (args[i + 1] && !args[i + 1].startsWith('--')) {
20
+ httpPort = parseInt(args[i + 1], 10);
21
+ i++;
22
+ }
23
+ break;
24
+ case '--no-http':
25
+ httpEnabled = false;
26
+ break;
27
+ case '--stdio':
28
+ stdioEnabled = true;
29
+ break;
30
+ case '--no-stdio':
31
+ stdioEnabled = false;
32
+ break;
33
+ case '--telemetry':
34
+ telemetryEnabled = true;
35
+ break;
36
+ case '--no-telemetry':
37
+ telemetryEnabled = false;
38
+ break;
39
+ default:
40
+ break;
41
+ }
42
+ }
43
+
44
+ async function main() {
45
+ if (command === 'list-instances') {
46
+ const portsArg = rest.find(a => a.startsWith('--ports='));
47
+ const ports = portsArg ? portsArg.replace('--ports=', '').split(',').map(p => Number(p)) : [];
48
+ const hostArg = rest.find(a => a.startsWith('--host='));
49
+ const host = hostArg ? hostArg.replace('--host=', '') : 'localhost';
50
+ const json = rest.includes('--json');
51
+ const list = await listInstances({ ports, host });
52
+ if (json) {
53
+ console.log(JSON.stringify(list, null, 2));
54
+ } else {
55
+ for (const e of list) {
56
+ console.log(`${e.active ? '*' : ' '} ${e.id} ${e.status}`);
57
+ }
58
+ }
59
+ return;
60
+ }
61
+
62
+ if (command === 'set-active') {
63
+ const id = rest[0];
64
+ if (!id) {
65
+ console.error('Usage: unity-mcp-server set-active <host:port>');
66
+ process.exit(1);
67
+ }
68
+ try {
69
+ const result = await setActive({ id });
70
+ console.log(JSON.stringify(result, null, 2));
71
+ } catch (e) {
72
+ console.error(e.message);
73
+ process.exit(1);
74
+ }
75
+ return;
76
+ }
77
+
78
+ startServer({
79
+ http: {
80
+ enabled: httpEnabled,
81
+ port: httpPort
82
+ },
83
+ telemetry: telemetryEnabled === undefined ? undefined : { enabled: telemetryEnabled },
84
+ stdioEnabled
85
+ }).catch(error => {
86
+ console.error('Fatal error:', error);
87
+ console.error('Stack trace:', error?.stack);
88
+ process.exit(1);
89
+ });
90
+ }
91
+
92
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/unity-mcp-server",
3
- "version": "2.42.1",
3
+ "version": "2.42.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
  "author": "Akio Jinsenji <akio-jinsenji@cloud-creative-studios.com>",
51
51
  "license": "MIT",
52
52
  "dependencies": {
53
- "@modelcontextprotocol/sdk": "^1.24.0",
53
+ "@modelcontextprotocol/sdk": "^1.24.3",
54
54
  "better-sqlite3": "^9.4.3",
55
55
  "find-up": "^6.3.0"
56
56
  },
@@ -0,0 +1,10 @@
1
+ import { InstanceRegistry } from '../../core/instanceRegistry.js';
2
+
3
+ export async function listInstances({ ports, host = 'localhost', registry }) {
4
+ const reg = registry || new InstanceRegistry();
5
+ if (Array.isArray(ports)) {
6
+ ports.forEach(p => reg.add({ host, port: Number(p) }));
7
+ }
8
+ await reg.refreshStatus();
9
+ return reg.list();
10
+ }
@@ -0,0 +1,6 @@
1
+ import { InstanceRegistry } from '../../core/instanceRegistry.js';
2
+
3
+ export async function setActive({ id, registry }) {
4
+ const reg = registry || new InstanceRegistry();
5
+ return reg.setActive(id, { timeoutMs: 1000 });
6
+ }
@@ -1,267 +1,313 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { findUpSync } from 'find-up';
4
-
5
- /**
6
- * Shallow merge utility (simple objects only)
7
- */
8
- function merge(a, b) {
9
- const out = { ...a };
10
- for (const [k, v] of Object.entries(b || {})) {
11
- if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') {
12
- out[k] = { ...a[k], ...v };
13
- } else {
14
- out[k] = v;
15
- }
16
- }
17
- return out;
18
- }
19
-
20
- function resolvePackageVersion() {
21
- const candidates = [];
22
-
23
- // Resolve relative to this module (always inside mcp-server/src/core)
24
- try {
25
- const moduleDir = path.dirname(new URL(import.meta.url).pathname);
26
- candidates.push(path.resolve(moduleDir, '../../package.json'));
27
- } catch {}
28
-
29
- // When executed from workspace root (monorepo) or inside mcp-server package
30
- try {
31
- const here = findUpSync('package.json', { cwd: process.cwd() });
32
- if (here) candidates.push(here);
33
- } catch {}
34
-
35
- for (const candidate of candidates) {
36
- try {
37
- if (!candidate || !fs.existsSync(candidate)) continue;
38
- const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
39
- if (pkg?.version) return pkg.version;
40
- } catch {}
41
- }
42
-
43
- return '0.1.0';
44
- }
45
-
46
- /**
47
- * Base configuration for Unity MCP Server Server
48
- */
49
- const envUnityHost =
50
- process.env.UNITY_BIND_HOST || process.env.UNITY_HOST || null;
51
-
52
- const envMcpHost =
53
- process.env.UNITY_MCP_HOST || process.env.UNITY_CLIENT_HOST || process.env.UNITY_HOST || null;
54
-
55
- const envBindHost = process.env.UNITY_BIND_HOST || null;
56
-
57
- const baseConfig = {
58
- // Unity connection settings
59
- unity: {
60
- unityHost: envUnityHost,
61
- mcpHost: envMcpHost,
62
- bindHost: envBindHost,
63
- port: parseInt(process.env.UNITY_PORT || '', 10) || 6400,
64
- reconnectDelay: 1000,
65
- maxReconnectDelay: 30000,
66
- reconnectBackoffMultiplier: 2,
67
- commandTimeout: 30000
68
- },
69
-
70
- // Server settings
71
- server: {
72
- name: 'unity-mcp-server',
73
- version: resolvePackageVersion(),
74
- description: 'MCP server for Unity Editor integration'
75
- },
76
-
77
- // Logging settings
78
- logging: {
79
- level: process.env.LOG_LEVEL || 'info',
80
- prefix: '[Unity MCP Server]'
81
- },
82
-
83
- // Write queue removed: all edits go through structured Roslyn tools.
84
-
85
- // Search-related defaults and engine selection
86
- search: {
87
- // detail alias: 'compact' maps to returnMode 'snippets'
88
- defaultDetail: (process.env.SEARCH_DEFAULT_DETAIL || 'compact').toLowerCase(), // compact|metadata|snippets|full
89
- engine: (process.env.SEARCH_ENGINE || 'naive').toLowerCase() // naive|treesitter (future)
90
- },
91
-
92
- // LSP client defaults
93
- lsp: {
94
- requestTimeoutMs: Number(process.env.LSP_REQUEST_TIMEOUT_MS || 60000)
95
- },
96
-
97
- // Indexing (code index) settings
98
- indexing: {
99
- // Enable periodic incremental index updates (polling watcher)
100
- watch: true,
101
- // Polling interval (ms)
102
- intervalMs: Number(process.env.INDEX_WATCH_INTERVAL_MS || 15000),
103
- // Build options
104
- concurrency: Number(process.env.INDEX_CONCURRENCY || 8),
105
- retry: Number(process.env.INDEX_RETRY || 2),
106
- reportEvery: Number(process.env.INDEX_REPORT_EVERY || 500)
107
- }
108
- };
109
-
110
- /**
111
- * External config resolution (no legacy compatibility):
112
- * Priority:
113
- * 1) UNITY_MCP_CONFIG (explicit file path)
114
- * 2) ./.unity/config.json (project-local)
115
- * 3) ~/.unity/config.json (user-global)
116
- * If none found, create ./.unity/config.json with defaults.
117
- */
118
- function ensureDefaultProjectConfig(baseDir) {
119
- const dir = path.resolve(baseDir, '.unity');
120
- const file = path.join(dir, 'config.json');
121
-
122
- try {
123
- if (!fs.existsSync(dir)) {
124
- fs.mkdirSync(dir, { recursive: true });
125
- }
126
-
127
- if (!fs.existsSync(file)) {
128
- const inferredRoot = fs.existsSync(path.join(baseDir, 'Assets')) ? baseDir : '';
129
- const defaultConfig = {
130
- unity: {
131
- unityHost: 'localhost',
132
- mcpHost: 'localhost',
133
- port: 6400
134
- },
135
- project: {
136
- root: inferredRoot ? inferredRoot.replace(/\\/g, '/') : ''
137
- }
138
- };
139
- fs.writeFileSync(file, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8');
140
- }
141
- return file;
142
- } catch (error) {
143
- return null;
144
- }
145
- }
146
-
147
- function loadExternalConfig() {
148
- const explicitPath = process.env.UNITY_MCP_CONFIG;
149
-
150
- const projectPath = findUpSync(
151
- directory => {
152
- const candidate = path.resolve(directory, '.unity', 'config.json');
153
- return fs.existsSync(candidate) ? candidate : undefined;
154
- },
155
- { cwd: process.cwd() }
156
- );
157
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
158
- const userPath = homeDir ? path.resolve(homeDir, '.unity', 'config.json') : null;
159
-
160
- const candidates = [explicitPath, projectPath, userPath].filter(Boolean);
161
- for (const p of candidates) {
162
- try {
163
- if (p && fs.existsSync(p)) {
164
- const raw = fs.readFileSync(p, 'utf8');
165
- const json = JSON.parse(raw);
166
- const out = json && typeof json === 'object' ? json : {};
167
- out.__configPath = p;
168
- return out;
169
- }
170
- } catch (e) {
171
- return { __configLoadError: `${p}: ${e.message}` };
172
- }
173
- }
174
- const fallbackPath = ensureDefaultProjectConfig(process.cwd());
175
- if (fallbackPath && fs.existsSync(fallbackPath)) {
176
- try {
177
- const raw = fs.readFileSync(fallbackPath, 'utf8');
178
- const json = JSON.parse(raw);
179
- const out = json && typeof json === 'object' ? json : {};
180
- out.__configPath = fallbackPath;
181
- out.__configGenerated = true;
182
- return out;
183
- } catch (e) {
184
- return { __configLoadError: `${fallbackPath}: ${e.message}` };
185
- }
186
- }
187
- return {};
188
- }
189
-
190
- const external = loadExternalConfig();
191
- export const config = merge(baseConfig, external);
192
-
193
- const normalizeUnityConfig = () => {
194
- const unityConfig = config.unity || (config.unity = {});
195
-
196
- // Legacy aliases coming from config files or env vars
197
- const legacyHost = unityConfig.host;
198
- const legacyClientHost = unityConfig.clientHost;
199
- const legacyBindHost = unityConfig.bindHost;
200
-
201
- if (!unityConfig.unityHost) {
202
- unityConfig.unityHost = legacyBindHost || legacyHost || envUnityHost || 'localhost';
203
- }
204
-
205
- if (!unityConfig.mcpHost) {
206
- unityConfig.mcpHost = legacyClientHost || envMcpHost || legacyHost || unityConfig.unityHost;
207
- }
208
-
209
- // Keep bindHost for backwards compatibility with legacy code paths
210
- if (!unityConfig.bindHost) {
211
- unityConfig.bindHost = legacyBindHost || envBindHost || unityConfig.unityHost;
212
- }
213
-
214
- // Maintain legacy properties so older handlers keep working
215
- unityConfig.host = unityConfig.unityHost;
216
- unityConfig.clientHost = unityConfig.mcpHost;
217
- };
218
-
219
- normalizeUnityConfig();
220
-
221
- // Workspace root detection: directory that contains .unity/config.json used
222
- const initialCwd = process.cwd();
223
- let workspaceRoot = initialCwd;
224
- try {
225
- if (config.__configPath) {
226
- const cfgDir = path.dirname(config.__configPath); // <workspace>/.unity
227
- workspaceRoot = path.dirname(cfgDir); // <workspace>
228
- }
229
- } catch {}
230
- export const WORKSPACE_ROOT = workspaceRoot;
231
-
232
- /**
233
- * Logger utility
234
- * IMPORTANT: In MCP servers, all stdout output must be JSON-RPC protocol messages.
235
- * Logging must go to stderr to avoid breaking the protocol.
236
- */
237
- export const logger = {
238
- info: (message, ...args) => {
239
- if (['info', 'debug'].includes(config.logging.level)) {
240
- console.error(`${config.logging.prefix} ${message}`, ...args);
241
- }
242
- },
243
-
244
- warn: (message, ...args) => {
245
- if (['info', 'debug', 'warn'].includes(config.logging.level)) {
246
- console.error(`${config.logging.prefix} WARN: ${message}`, ...args);
247
- }
248
- },
249
-
250
- error: (message, ...args) => {
251
- console.error(`${config.logging.prefix} ERROR: ${message}`, ...args);
252
- },
253
-
254
- debug: (message, ...args) => {
255
- if (config.logging.level === 'debug') {
256
- console.error(`${config.logging.prefix} DEBUG: ${message}`, ...args);
257
- }
258
- }
259
- };
260
-
261
- // Late log if external config failed to load
262
- if (config.__configLoadError) {
263
- console.error(
264
- `${baseConfig.logging.prefix} WARN: Failed to load external config: ${config.__configLoadError}`
265
- );
266
- delete config.__configLoadError;
267
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import * as findUpPkg from 'find-up';
4
+ function findUpSyncCompat(matcher, options = {}) {
5
+ if (typeof matcher === 'function') {
6
+ let dir = options.cwd || process.cwd();
7
+ const { root } = path.parse(dir);
8
+ // walk up until root
9
+ while (true) {
10
+ const found = matcher(dir);
11
+ if (found) return found;
12
+ if (dir === root) return undefined;
13
+ dir = path.dirname(dir);
14
+ }
15
+ }
16
+ if (typeof findUpPkg.sync === 'function') return findUpPkg.sync(matcher, options);
17
+ if (typeof findUpPkg === 'function') return findUpPkg(matcher, options);
18
+ return undefined;
19
+ }
20
+
21
+ /**
22
+ * Shallow merge utility (simple objects only)
23
+ */
24
+ function merge(a, b) {
25
+ const out = { ...a };
26
+ for (const [k, v] of Object.entries(b || {})) {
27
+ if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') {
28
+ out[k] = { ...a[k], ...v };
29
+ } else {
30
+ out[k] = v;
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function resolvePackageVersion() {
37
+ const candidates = [];
38
+
39
+ // Resolve relative to this module (always inside mcp-server/src/core)
40
+ try {
41
+ const moduleDir = path.dirname(new URL(import.meta.url).pathname);
42
+ candidates.push(path.resolve(moduleDir, '../../package.json'));
43
+ } catch {}
44
+
45
+ // When executed from workspace root (monorepo) or inside mcp-server package
46
+ try {
47
+ const here = findUpSyncCompat('package.json', { cwd: process.cwd() });
48
+ if (here) candidates.push(here);
49
+ } catch {}
50
+
51
+ for (const candidate of candidates) {
52
+ try {
53
+ if (!candidate || !fs.existsSync(candidate)) continue;
54
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
55
+ if (pkg?.version) return pkg.version;
56
+ } catch {}
57
+ }
58
+
59
+ return '0.1.0';
60
+ }
61
+
62
+ /**
63
+ * Base configuration for Unity MCP Server Server
64
+ */
65
+ const envUnityHost = process.env.UNITY_BIND_HOST || process.env.UNITY_HOST || null;
66
+
67
+ const envMcpHost =
68
+ process.env.UNITY_MCP_HOST || process.env.UNITY_CLIENT_HOST || process.env.UNITY_HOST || null;
69
+
70
+ const envBindHost = process.env.UNITY_BIND_HOST || null;
71
+
72
+ const baseConfig = {
73
+ // Unity connection settings
74
+ unity: {
75
+ unityHost: envUnityHost,
76
+ mcpHost: envMcpHost,
77
+ bindHost: envBindHost,
78
+ port: parseInt(process.env.UNITY_PORT || '', 10) || 6400,
79
+ reconnectDelay: 1000,
80
+ maxReconnectDelay: 30000,
81
+ reconnectBackoffMultiplier: 2,
82
+ commandTimeout: 30000
83
+ },
84
+
85
+ // Server settings
86
+ server: {
87
+ name: 'unity-mcp-server',
88
+ version: resolvePackageVersion(),
89
+ description: 'MCP server for Unity Editor integration'
90
+ },
91
+
92
+ // Logging settings
93
+ logging: {
94
+ level: process.env.LOG_LEVEL || 'info',
95
+ prefix: '[unity-mcp-server]'
96
+ },
97
+
98
+ // HTTP transport (off by default)
99
+ http: {
100
+ enabled: (process.env.UNITY_MCP_HTTP_ENABLED || 'false').toLowerCase() === 'true',
101
+ host: process.env.UNITY_MCP_HTTP_HOST || '0.0.0.0',
102
+ port: parseInt(process.env.UNITY_MCP_HTTP_PORT || '', 10) || 6401,
103
+ healthPath: '/healthz',
104
+ allowedHosts: (process.env.UNITY_MCP_HTTP_ALLOWED_HOSTS || 'localhost,127.0.0.1')
105
+ .split(',')
106
+ .map(h => h.trim())
107
+ .filter(h => h.length > 0)
108
+ },
109
+
110
+ telemetry: {
111
+ enabled: (process.env.UNITY_MCP_TELEMETRY || 'off').toLowerCase() === 'on',
112
+ destinations: [],
113
+ fields: []
114
+ },
115
+
116
+ // Write queue removed: all edits go through structured Roslyn tools.
117
+
118
+ // Search-related defaults and engine selection
119
+ search: {
120
+ // detail alias: 'compact' maps to returnMode 'snippets'
121
+ defaultDetail: (process.env.SEARCH_DEFAULT_DETAIL || 'compact').toLowerCase(), // compact|metadata|snippets|full
122
+ engine: (process.env.SEARCH_ENGINE || 'naive').toLowerCase() // naive|treesitter (future)
123
+ },
124
+
125
+ // LSP client defaults
126
+ lsp: {
127
+ requestTimeoutMs: Number(process.env.LSP_REQUEST_TIMEOUT_MS || 60000)
128
+ },
129
+
130
+ // Indexing (code index) settings
131
+ indexing: {
132
+ // Enable periodic incremental index updates (polling watcher)
133
+ watch: true,
134
+ // Polling interval (ms)
135
+ intervalMs: Number(process.env.INDEX_WATCH_INTERVAL_MS || 15000),
136
+ // Build options
137
+ concurrency: Number(process.env.INDEX_CONCURRENCY || 8),
138
+ retry: Number(process.env.INDEX_RETRY || 2),
139
+ reportEvery: Number(process.env.INDEX_REPORT_EVERY || 500)
140
+ }
141
+ };
142
+
143
+ /**
144
+ * External config resolution (no legacy compatibility):
145
+ * Priority:
146
+ * 1) UNITY_MCP_CONFIG (explicit file path)
147
+ * 2) ./.unity/config.json (project-local)
148
+ * 3) ~/.unity/config.json (user-global)
149
+ * If none found, create ./.unity/config.json with defaults.
150
+ */
151
+ function ensureDefaultProjectConfig(baseDir) {
152
+ const dir = path.resolve(baseDir, '.unity');
153
+ const file = path.join(dir, 'config.json');
154
+
155
+ try {
156
+ if (!fs.existsSync(dir)) {
157
+ fs.mkdirSync(dir, { recursive: true });
158
+ }
159
+
160
+ if (!fs.existsSync(file)) {
161
+ const inferredRoot = fs.existsSync(path.join(baseDir, 'Assets')) ? baseDir : '';
162
+ const defaultConfig = {
163
+ unity: {
164
+ unityHost: 'localhost',
165
+ mcpHost: 'localhost',
166
+ port: 6400
167
+ },
168
+ project: {
169
+ root: inferredRoot ? inferredRoot.replace(/\\/g, '/') : ''
170
+ }
171
+ };
172
+ fs.writeFileSync(file, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8');
173
+ }
174
+ return file;
175
+ } catch (error) {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function loadExternalConfig() {
181
+ if (typeof findUpSyncCompat !== 'function') {
182
+ return {};
183
+ }
184
+ const explicitPath = process.env.UNITY_MCP_CONFIG;
185
+
186
+ const projectPath = findUpSyncCompat(
187
+ directory => {
188
+ const candidate = path.resolve(directory, '.unity', 'config.json');
189
+ return fs.existsSync(candidate) ? candidate : undefined;
190
+ },
191
+ { cwd: process.cwd() }
192
+ );
193
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
194
+ const userPath = homeDir ? path.resolve(homeDir, '.unity', 'config.json') : null;
195
+
196
+ const candidates = [explicitPath, projectPath, userPath].filter(Boolean);
197
+ for (const p of candidates) {
198
+ try {
199
+ if (p && fs.existsSync(p)) {
200
+ const raw = fs.readFileSync(p, 'utf8');
201
+ const json = JSON.parse(raw);
202
+ const out = json && typeof json === 'object' ? json : {};
203
+ out.__configPath = p;
204
+ return out;
205
+ }
206
+ } catch (e) {
207
+ return { __configLoadError: `${p}: ${e.message}` };
208
+ }
209
+ }
210
+ const fallbackPath = ensureDefaultProjectConfig(process.cwd());
211
+ if (fallbackPath && fs.existsSync(fallbackPath)) {
212
+ try {
213
+ const raw = fs.readFileSync(fallbackPath, 'utf8');
214
+ const json = JSON.parse(raw);
215
+ const out = json && typeof json === 'object' ? json : {};
216
+ out.__configPath = fallbackPath;
217
+ out.__configGenerated = true;
218
+ return out;
219
+ } catch (e) {
220
+ return { __configLoadError: `${fallbackPath}: ${e.message}` };
221
+ }
222
+ }
223
+ return {};
224
+ }
225
+
226
+ const external = loadExternalConfig();
227
+ export const config = merge(baseConfig, external);
228
+
229
+ const normalizeUnityConfig = () => {
230
+ const unityConfig = config.unity || (config.unity = {});
231
+
232
+ // Legacy aliases coming from config files or env vars
233
+ const legacyHost = unityConfig.host;
234
+ const legacyClientHost = unityConfig.clientHost;
235
+ const legacyBindHost = unityConfig.bindHost;
236
+
237
+ if (!unityConfig.unityHost) {
238
+ unityConfig.unityHost = legacyBindHost || legacyHost || envUnityHost || 'localhost';
239
+ }
240
+
241
+ if (!unityConfig.mcpHost) {
242
+ unityConfig.mcpHost = legacyClientHost || envMcpHost || legacyHost || unityConfig.unityHost;
243
+ }
244
+
245
+ // Keep bindHost for backwards compatibility with legacy code paths
246
+ if (!unityConfig.bindHost) {
247
+ unityConfig.bindHost = legacyBindHost || envBindHost || unityConfig.unityHost;
248
+ }
249
+
250
+ // Maintain legacy properties so older handlers keep working
251
+ unityConfig.host = unityConfig.unityHost;
252
+ unityConfig.clientHost = unityConfig.mcpHost;
253
+ };
254
+
255
+ normalizeUnityConfig();
256
+
257
+ // Workspace root detection: directory that contains .unity/config.json used
258
+ const initialCwd = process.cwd();
259
+ let workspaceRoot = initialCwd;
260
+ try {
261
+ if (config.__configPath) {
262
+ const cfgDir = path.dirname(config.__configPath); // <workspace>/.unity
263
+ workspaceRoot = path.dirname(cfgDir); // <workspace>
264
+ }
265
+ } catch {}
266
+ export const WORKSPACE_ROOT = workspaceRoot;
267
+
268
+ /**
269
+ * Logger utility
270
+ * IMPORTANT: In MCP servers, all stdout output must be JSON-RPC protocol messages.
271
+ * Logging must go to stderr to avoid breaking the protocol.
272
+ */
273
+ export const logger = {
274
+ info: (message, ...args) => {
275
+ if (['info', 'debug'].includes(config.logging.level)) {
276
+ console.error(`${config.logging.prefix} ${message}`, ...args);
277
+ }
278
+ },
279
+
280
+ warn: (message, ...args) => {
281
+ if (['info', 'debug', 'warn'].includes(config.logging.level)) {
282
+ console.error(`${config.logging.prefix} WARN: ${message}`, ...args);
283
+ }
284
+ },
285
+
286
+ error: (message, ...args) => {
287
+ console.error(`${config.logging.prefix} ERROR: ${message}`, ...args);
288
+ },
289
+
290
+ debug: (message, ...args) => {
291
+ if (config.logging.level === 'debug') {
292
+ console.error(`${config.logging.prefix} DEBUG: ${message}`, ...args);
293
+ }
294
+ }
295
+ };
296
+
297
+ // Late log if external config failed to load
298
+ if (config.__configLoadError) {
299
+ console.error(
300
+ `${baseConfig.logging.prefix} WARN: Failed to load external config: ${config.__configLoadError}`
301
+ );
302
+ delete config.__configLoadError;
303
+ }
304
+
305
+ // Startup debug log: output config info to stderr for troubleshooting
306
+ // This helps diagnose connection issues (especially in WSL2/Docker environments)
307
+ console.error(`[unity-mcp-server] Startup config:`);
308
+ console.error(`[unity-mcp-server] Config file: ${config.__configPath || '(defaults)'}`);
309
+ console.error(
310
+ `[unity-mcp-server] Unity host: ${config.unity.mcpHost || config.unity.unityHost || 'localhost'}`
311
+ );
312
+ console.error(`[unity-mcp-server] Unity port: ${config.unity.port}`);
313
+ console.error(`[unity-mcp-server] Workspace root: ${WORKSPACE_ROOT}`);
@@ -0,0 +1,147 @@
1
+ import http from 'node:http';
2
+ import { logger } from './config.js';
3
+
4
+ function buildHealthResponse({ startedAt, mode, port, telemetryEnabled }) {
5
+ return {
6
+ status: 'ok',
7
+ mode,
8
+ port,
9
+ telemetryEnabled,
10
+ uptimeMs: Date.now() - startedAt
11
+ };
12
+ }
13
+
14
+ function suggestPorts(port) {
15
+ if (!port || typeof port !== 'number') return [];
16
+ return [port + 1, port + 2, port + 11];
17
+ }
18
+
19
+ export function createHttpServer({
20
+ handlers,
21
+ host = '0.0.0.0',
22
+ port = 6401,
23
+ telemetryEnabled = false,
24
+ healthPath = '/healthz',
25
+ allowedHosts = ['localhost', '127.0.0.1']
26
+ } = {}) {
27
+ const startedAt = Date.now();
28
+ let server;
29
+
30
+ const listener = async (req, res) => {
31
+ try {
32
+ const hostHeader = req.headers.host?.split(':')[0];
33
+ if (allowedHosts && allowedHosts.length && hostHeader && !allowedHosts.includes(hostHeader)) {
34
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
35
+ res.end(JSON.stringify({ error: 'forbidden host' }));
36
+ return;
37
+ }
38
+
39
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
40
+
41
+ if (req.method === 'GET' && url.pathname === healthPath) {
42
+ const body = buildHealthResponse({
43
+ startedAt,
44
+ mode: 'http',
45
+ port: server?.address()?.port,
46
+ telemetryEnabled
47
+ });
48
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
49
+ res.end(JSON.stringify(body));
50
+ return;
51
+ }
52
+
53
+ if (req.method === 'POST' && url.pathname === '/rpc') {
54
+ const chunks = [];
55
+ for await (const chunk of req) chunks.push(chunk);
56
+ const raw = Buffer.concat(chunks).toString('utf8');
57
+ let payload;
58
+ try {
59
+ payload = JSON.parse(raw || '{}');
60
+ } catch (e) {
61
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
62
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Invalid JSON' } }));
63
+ return;
64
+ }
65
+
66
+ const { method, params, id } = payload || {};
67
+ if (method === 'tools/list' || method === 'listTools') {
68
+ const tools = Array.from(handlers.values()).map(h => h.getDefinition());
69
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
70
+ res.end(JSON.stringify({ jsonrpc: '2.0', id, result: { tools } }));
71
+ return;
72
+ }
73
+
74
+ if (method === 'tools/call' || method === 'callTool') {
75
+ const name = params?.name;
76
+ const args = params?.arguments || {};
77
+ const handler = handlers.get(name);
78
+ if (!handler) {
79
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
80
+ res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32004, message: `Tool not found: ${name}` } }));
81
+ return;
82
+ }
83
+ try {
84
+ const result = await handler.handle(args);
85
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
86
+ res.end(JSON.stringify({ jsonrpc: '2.0', id, result }));
87
+ } catch (e) {
88
+ logger.error(`[http] tool error ${name}: ${e.message}`);
89
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
90
+ res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: e.message } }));
91
+ }
92
+ return;
93
+ }
94
+
95
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
96
+ res.end(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }));
97
+ return;
98
+ }
99
+
100
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
101
+ res.end(JSON.stringify({ error: 'Not found' }));
102
+ } catch (e) {
103
+ logger.error(`[http] unexpected error: ${e.message}`);
104
+ try {
105
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
106
+ res.end(JSON.stringify({ error: 'internal error' }));
107
+ } catch {}
108
+ }
109
+ };
110
+
111
+ server = http.createServer(listener);
112
+
113
+ const start = () =>
114
+ new Promise((resolve, reject) => {
115
+ const onError = err => {
116
+ server.off('error', onError);
117
+ if (err.code === 'EADDRINUSE') {
118
+ const suggestions = suggestPorts(port);
119
+ const msg = `Port ${port} is already in use. Try: ${suggestions.join(', ')}`;
120
+ logger.error(msg);
121
+ }
122
+ reject(err);
123
+ };
124
+ server.once('error', onError);
125
+ server.listen(port, host, () => {
126
+ server.off('error', onError);
127
+ const address = server.address();
128
+ logger.info(`HTTP listening on http://${host}:${address.port}, telemetry: ${telemetryEnabled ? 'on' : 'off'}`);
129
+ resolve(address.port);
130
+ });
131
+ });
132
+
133
+ const close = () =>
134
+ new Promise((resolve, reject) => {
135
+ server.close(err => {
136
+ if (err) reject(err);
137
+ else resolve();
138
+ });
139
+ });
140
+
141
+ return {
142
+ start,
143
+ close,
144
+ getPort: () => server.address()?.port,
145
+ health: () => buildHealthResponse({ startedAt, mode: 'http', port: server.address()?.port, telemetryEnabled })
146
+ };
147
+ }
@@ -0,0 +1,73 @@
1
+ import net from 'node:net';
2
+
3
+ async function ping(host, port, timeoutMs = 1000) {
4
+ return new Promise(resolve => {
5
+ const socket = net.connect({ host, port });
6
+ const timer = setTimeout(() => {
7
+ socket.destroy();
8
+ resolve(false);
9
+ }, timeoutMs);
10
+
11
+ socket.on('connect', () => {
12
+ clearTimeout(timer);
13
+ socket.end();
14
+ resolve(true);
15
+ });
16
+ socket.on('error', () => {
17
+ clearTimeout(timer);
18
+ resolve(false);
19
+ });
20
+ });
21
+ }
22
+
23
+ export class InstanceRegistry {
24
+ constructor(entries = []) {
25
+ this.entries = [];
26
+ this.activeId = null;
27
+ entries.forEach(e => this.add(e));
28
+ }
29
+
30
+ add(entry) {
31
+ const id = entry.id || `${entry.host}:${entry.port}`;
32
+ if (this.entries.find(e => e.id === id)) return;
33
+ this.entries.push({
34
+ id,
35
+ host: entry.host,
36
+ port: entry.port,
37
+ status: entry.status || 'unknown',
38
+ lastCheckedAt: entry.lastCheckedAt,
39
+ active: false
40
+ });
41
+ }
42
+
43
+ remove(id) {
44
+ this.entries = this.entries.filter(e => e.id !== id);
45
+ }
46
+
47
+ list() {
48
+ return this.entries.map(e => ({ ...e, active: e.id === this.activeId }));
49
+ }
50
+
51
+ async refreshStatus(timeoutMs = 1000) {
52
+ for (const entry of this.entries) {
53
+ const ok = await ping(entry.host, entry.port, timeoutMs);
54
+ entry.status = ok ? 'up' : 'down';
55
+ entry.lastCheckedAt = new Date().toISOString();
56
+ }
57
+ return this.list();
58
+ }
59
+
60
+ async setActive(id, { timeoutMs = 1000 } = {}) {
61
+ const entry = this.entries.find(e => e.id === id);
62
+ if (!entry) throw new Error(`Instance not found: ${id}`);
63
+
64
+ const ok = await ping(entry.host, entry.port, timeoutMs);
65
+ if (!ok) throw new Error(`Instance unreachable: ${id}`);
66
+
67
+ const previousId = this.activeId;
68
+ this.activeId = id;
69
+ entry.status = 'up';
70
+ entry.lastCheckedAt = new Date().toISOString();
71
+ return { activeId: id, previousId };
72
+ }
73
+ }
@@ -162,21 +162,53 @@ unityConnection.on('error', error => {
162
162
  });
163
163
 
164
164
  // Initialize server
165
- export async function startServer() {
165
+ export async function startServer(options = {}) {
166
166
  try {
167
- // Create transport - no logging before connection
168
- const transport = new HybridStdioServerTransport();
167
+ const runtimeConfig = {
168
+ ...config,
169
+ http: { ...config.http, ...(options.http || {}) },
170
+ telemetry: { ...config.telemetry, ...(options.telemetry || {}) },
171
+ stdioEnabled: options.stdioEnabled !== undefined ? options.stdioEnabled : true
172
+ };
169
173
 
170
- // Connect to transport
171
- await server.connect(transport);
174
+ // Create transport - no logging before connection
175
+ let transport;
176
+ if (runtimeConfig.stdioEnabled !== false) {
177
+ console.error(`[unity-mcp-server] MCP transport connecting...`);
178
+ transport = new HybridStdioServerTransport();
179
+ await server.connect(transport);
180
+ console.error(`[unity-mcp-server] MCP transport connected`);
181
+ }
172
182
 
173
183
  // Now safe to log after connection established
174
184
  logger.info('MCP server started successfully');
175
185
 
186
+ // Optional HTTP transport
187
+ let httpServerInstance;
188
+ if (runtimeConfig.http?.enabled) {
189
+ const { createHttpServer } = await import('./httpServer.js');
190
+ httpServerInstance = createHttpServer({
191
+ handlers,
192
+ host: runtimeConfig.http.host,
193
+ port: runtimeConfig.http.port,
194
+ telemetryEnabled: runtimeConfig.telemetry.enabled,
195
+ healthPath: runtimeConfig.http.healthPath
196
+ });
197
+ try {
198
+ await httpServerInstance.start();
199
+ } catch (err) {
200
+ logger.error(`HTTP server failed to start: ${err.message}`);
201
+ throw err;
202
+ }
203
+ }
204
+
176
205
  // Attempt to connect to Unity
206
+ console.error(`[unity-mcp-server] Unity connection starting...`);
177
207
  try {
178
208
  await unityConnection.connect();
209
+ console.error(`[unity-mcp-server] Unity connection established`);
179
210
  } catch (error) {
211
+ console.error(`[unity-mcp-server] Unity connection failed: ${error.message}`);
180
212
  logger.error('Initial Unity connection failed:', error.message);
181
213
  logger.info('Unity connection will retry automatically');
182
214
  }
@@ -251,14 +283,16 @@ export async function startServer() {
251
283
  process.on('SIGINT', async () => {
252
284
  logger.info('Shutting down...');
253
285
  unityConnection.disconnect();
254
- await server.close();
286
+ if (transport) await server.close();
287
+ if (httpServerInstance) await httpServerInstance.close();
255
288
  process.exit(0);
256
289
  });
257
290
 
258
291
  process.on('SIGTERM', async () => {
259
292
  logger.info('Shutting down...');
260
293
  unityConnection.disconnect();
261
- await server.close();
294
+ if (transport) await server.close();
295
+ if (httpServerInstance) await httpServerInstance.close();
262
296
  process.exit(0);
263
297
  });
264
298
  } catch (error) {
@@ -48,6 +48,9 @@ export class UnityConnection extends EventEmitter {
48
48
  }
49
49
 
50
50
  const targetHost = config.unity.mcpHost || config.unity.unityHost;
51
+ console.error(
52
+ `[unity-mcp-server] Unity TCP connecting to ${targetHost}:${config.unity.port}...`
53
+ );
51
54
  logger.info(`Connecting to Unity at ${targetHost}:${config.unity.port}...`);
52
55
 
53
56
  this.socket = new net.Socket();
@@ -74,6 +77,7 @@ export class UnityConnection extends EventEmitter {
74
77
 
75
78
  // Set up event handlers
76
79
  this.socket.on('connect', () => {
80
+ console.error(`[unity-mcp-server] Unity TCP connected successfully`);
77
81
  logger.info('Connected to Unity Editor');
78
82
  this.connected = true;
79
83
  this.reconnectAttempts = 0;
@@ -93,6 +97,7 @@ export class UnityConnection extends EventEmitter {
93
97
  });
94
98
 
95
99
  this.socket.on('error', error => {
100
+ console.error(`[unity-mcp-server] Unity TCP error: ${error.message}`);
96
101
  logger.error('Socket error:', error.message);
97
102
  if (this.listenerCount('error') > 0) {
98
103
  this.emit('error', error);
@@ -121,9 +126,11 @@ export class UnityConnection extends EventEmitter {
121
126
 
122
127
  // Check if we're already handling disconnection
123
128
  if (this.isDisconnecting || !this.socket) {
129
+ console.error(`[unity-mcp-server] Unity TCP close event (already disconnecting)`);
124
130
  return;
125
131
  }
126
132
 
133
+ console.error(`[unity-mcp-server] Unity TCP disconnected`);
127
134
  logger.info('Disconnected from Unity Editor');
128
135
  this.connected = false;
129
136
  this.socket = null;
@@ -240,7 +247,7 @@ export class UnityConnection extends EventEmitter {
240
247
  // Check if this is an unframed Unity debug log
241
248
  if (data.length > 0 && !this.messageBuffer.length) {
242
249
  const dataStr = data.toString('utf8');
243
- if (dataStr.startsWith('[Unity MCP Server]') || dataStr.startsWith('[Unity]')) {
250
+ if (dataStr.startsWith('[unity-mcp-server]') || dataStr.startsWith('[Unity]')) {
244
251
  logger.debug(`[Unity] Received unframed debug log: ${dataStr.trim()}`);
245
252
  // Don't process unframed logs as messages
246
253
  return;
@@ -315,7 +322,7 @@ export class UnityConnection extends EventEmitter {
315
322
 
316
323
  // Check if this looks like a Unity log message
317
324
  const messageStr = messageData.toString();
318
- if (messageStr.includes('[Unity MCP Server]')) {
325
+ if (messageStr.includes('[unity-mcp-server]')) {
319
326
  logger.debug('[Unity] Received Unity log message instead of JSON response');
320
327
  // Don't treat this as a critical error
321
328
  }