@akiojin/unity-mcp-server 2.37.2 → 2.38.1

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.37.2",
3
+ "version": "2.38.1",
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",
@@ -17,6 +17,32 @@ function merge(a, b) {
17
17
  return out;
18
18
  }
19
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
+
20
46
  /**
21
47
  * Base configuration for Unity MCP Server Server
22
48
  */
@@ -44,7 +70,7 @@ const baseConfig = {
44
70
  // Server settings
45
71
  server: {
46
72
  name: 'unity-mcp-server',
47
- version: '0.1.0',
73
+ version: resolvePackageVersion(),
48
74
  description: 'MCP server for Unity Editor integration'
49
75
  },
50
76
 
@@ -11,6 +11,11 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
11
11
  {
12
12
  type: 'object',
13
13
  properties: {
14
+ raw: {
15
+ type: 'boolean',
16
+ description:
17
+ 'When true, do not expand/validate logTypes and pass them directly to Unity. Use with caution.'
18
+ },
14
19
  count: {
15
20
  type: 'number',
16
21
  description: 'Number of logs to retrieve (1-1000, default: 100)',
@@ -19,10 +24,11 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
19
24
  },
20
25
  logTypes: {
21
26
  type: 'array',
22
- description: 'Filter by log types (default: ["All"])',
27
+ description:
28
+ 'Filter by log types. Allowed: Log, Warning, Error. Error expands to include Exception/Assert internally. Default: all three.',
23
29
  items: {
24
30
  type: 'string',
25
- enum: ['Info', 'Warning', 'Error', 'All']
31
+ enum: ['Log', 'Warning', 'Error']
26
32
  }
27
33
  },
28
34
  filterText: {
@@ -88,11 +94,10 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
88
94
  throw new Error('logTypes must be an array');
89
95
  }
90
96
 
91
- const validTypes = ['Info', 'Warning', 'Error', 'All'];
97
+ const validTypes = ['Log', 'Warning', 'Error'];
92
98
  for (const type of logTypes) {
93
99
  if (!validTypes.includes(type)) {
94
- // Invalid types are treated as 'All' later, so just log a warning
95
- console.warn(`Invalid log type: ${type}. Will be treated as 'All'.`);
100
+ throw new Error(`logTypes must be one of: ${validTypes.join(', ')}`);
96
101
  }
97
102
  }
98
103
  }
@@ -177,34 +182,31 @@ export class ConsoleReadToolHandler extends BaseToolHandler {
177
182
  groupBy
178
183
  } = params;
179
184
 
180
- // Convert simplified log types to Unity log types
185
+ // Allowed: Log / Warning / Error. Error expands to include Exception/Assert.
181
186
  let expandedLogTypes = [];
182
-
183
- if (!logTypes || logTypes.length === 0 || logTypes.includes('All')) {
184
- // Default to all types
187
+ if (!logTypes || logTypes.length === 0) {
185
188
  expandedLogTypes = ['Log', 'Warning', 'Error', 'Exception', 'Assert'];
186
189
  } else {
187
- // Expand each simplified type to Unity types
188
190
  logTypes.forEach(type => {
189
191
  switch (type) {
190
- case 'Info':
192
+ case 'Log':
191
193
  expandedLogTypes.push('Log');
192
194
  break;
193
195
  case 'Warning':
194
196
  expandedLogTypes.push('Warning');
195
197
  break;
196
198
  case 'Error':
197
- // Error includes all error-related types
198
199
  expandedLogTypes.push('Error', 'Exception', 'Assert');
199
200
  break;
200
- case 'All':
201
- expandedLogTypes.push('Log', 'Warning', 'Error', 'Exception', 'Assert');
201
+ default:
202
+ // ignore (validate already enforces)
202
203
  break;
203
204
  }
204
205
  });
205
-
206
- // Remove duplicates
207
206
  expandedLogTypes = [...new Set(expandedLogTypes)];
207
+ if (expandedLogTypes.length === 0) {
208
+ expandedLogTypes = ['Log', 'Warning', 'Error', 'Exception', 'Assert'];
209
+ }
208
210
  }
209
211
 
210
212
  // Ensure connection to Unity
@@ -0,0 +1,26 @@
1
+ import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
+
3
+ /**
4
+ * Handler to quit Unity Editor safely.
5
+ */
6
+ export class EditorQuitToolHandler extends BaseToolHandler {
7
+ constructor(unityConnection) {
8
+ super('editor_quit', 'Quit Unity Editor', {
9
+ type: 'object',
10
+ properties: {}
11
+ });
12
+
13
+ this.unityConnection = unityConnection;
14
+ }
15
+
16
+ async execute() {
17
+ if (!this.unityConnection.isConnected()) {
18
+ await this.unityConnection.connect();
19
+ }
20
+ const response = await this.unityConnection.sendCommand('quit_editor', {});
21
+ if (response.error) {
22
+ throw new Error(response.error);
23
+ }
24
+ return response;
25
+ }
26
+ }
@@ -94,6 +94,7 @@ import { EditorLayersManageToolHandler } from './editor/EditorLayersManageToolHa
94
94
  import { EditorSelectionManageToolHandler } from './editor/EditorSelectionManageToolHandler.js';
95
95
  import { EditorWindowsManageToolHandler } from './editor/EditorWindowsManageToolHandler.js';
96
96
  import { EditorToolsManageToolHandler } from './editor/EditorToolsManageToolHandler.js';
97
+ import { EditorQuitToolHandler } from './editor/EditorQuitToolHandler.js';
97
98
  import { SettingsGetToolHandler } from './settings/SettingsGetToolHandler.js';
98
99
  import { SettingsUpdateToolHandler } from './settings/SettingsUpdateToolHandler.js';
99
100
  import PackageManagerToolHandler from './package/PackageManagerToolHandler.js';
@@ -392,6 +393,7 @@ const HANDLER_CLASSES = [
392
393
  EditorSelectionManageToolHandler,
393
394
  EditorWindowsManageToolHandler,
394
395
  EditorToolsManageToolHandler,
396
+ EditorQuitToolHandler,
395
397
 
396
398
  // Settings handlers
397
399
  SettingsGetToolHandler,
@@ -1,5 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import * as testResultsCache from '../../utils/testResultsCache.js';
3
+ import * as testRunState from '../../utils/testRunState.js';
3
4
 
4
5
  /**
5
6
  * Handler for getting Unity test execution status
@@ -33,13 +34,32 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
33
34
  * @returns {Promise<Object>} Test execution status and results
34
35
  */
35
36
  async execute(params) {
36
- // Ensure connection to Unity
37
- if (!this.unityConnection.isConnected()) {
38
- await this.unityConnection.connect();
39
- }
37
+ let response;
38
+ try {
39
+ // Ensure connection to Unity
40
+ if (!this.unityConnection.isConnected()) {
41
+ await this.unityConnection.connect();
42
+ }
40
43
 
41
- // Send command to Unity
42
- const response = await this.unityConnection.sendCommand('get_test_status', params || {});
44
+ // Send command to Unity
45
+ response = await this.unityConnection.sendCommand('get_test_status', params || {});
46
+ } catch (error) {
47
+ // 一度リトライしてみる(ドメインリロード直後を考慮)
48
+ try {
49
+ await this.unityConnection.connect();
50
+ response = await this.unityConnection.sendCommand('get_test_status', params || {});
51
+ } catch (retryError) {
52
+ // Connection or transport error persists: fall back to cached run state so callers
53
+ // can still distinguish "テスト起因のPlayMode" かどうか。
54
+ const cached = testRunState.getState();
55
+ return {
56
+ ...cached,
57
+ status: cached.status || 'error',
58
+ error: retryError.message,
59
+ connectionState: 'disconnected'
60
+ };
61
+ }
62
+ }
43
63
 
44
64
  // Handle Unity response
45
65
  if (response.error) {
@@ -48,12 +68,14 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
48
68
 
49
69
  // Return status directly if still running or idle
50
70
  if (response.status === 'running') {
51
- return response;
71
+ const merged = { ...testRunState.updateFromStatus(response), ...response };
72
+ return merged;
52
73
  }
53
74
 
54
75
  if (response.status === 'idle') {
76
+ testRunState.markIdle();
55
77
  const cached = await this.testResultsCache.loadCachedTestResults();
56
- return cached || response;
78
+ return cached || testRunState.getState() || response;
57
79
  }
58
80
 
59
81
  // Format completed results
@@ -68,13 +90,16 @@ export class TestGetStatusToolHandler extends BaseToolHandler {
68
90
  inconclusiveTests: response.inconclusiveTests,
69
91
  summary: `${response.passedTests}/${response.totalTests} tests passed`,
70
92
  failures: response.failures || [],
71
- tests: response.tests || []
93
+ tests: response.tests || [],
94
+ testMode: response.testMode || testRunState.getState().testMode,
95
+ runId: testRunState.getState().runId
72
96
  };
73
97
 
74
98
  if (response.latestResult) {
75
99
  result.latestResult = response.latestResult;
76
100
  }
77
101
 
102
+ testRunState.updateFromStatus({ ...result, status: 'completed' });
78
103
  const artifactPath = await this.testResultsCache.persistTestResults(result);
79
104
  const cachedResult = await this.testResultsCache.loadCachedTestResults(artifactPath);
80
105
 
@@ -1,5 +1,6 @@
1
1
  import { BaseToolHandler } from '../base/BaseToolHandler.js';
2
2
  import { resetTestResultsCache } from '../../utils/testResultsCache.js';
3
+ import * as testRunState from '../../utils/testRunState.js';
3
4
 
4
5
  /**
5
6
  * Handler for running Unity NUnit tests via Test Runner API
@@ -67,10 +68,14 @@ export class TestRunToolHandler extends BaseToolHandler {
67
68
 
68
69
  // Handle new non-blocking response format (status: "running")
69
70
  if (response.status === 'running') {
71
+ const runState = testRunState.startRun(response.testMode || params.testMode, response.runId);
70
72
  return {
71
73
  status: 'running',
72
74
  message:
73
- response.message || 'Test execution started. Use get_test_status to check progress.'
75
+ response.message || 'Test execution started. Use get_test_status to check progress.',
76
+ testMode: runState.testMode || response.testMode,
77
+ runId: runState.runId || response.runId,
78
+ startedAt: runState.startedAt
74
79
  };
75
80
  }
76
81
 
@@ -134,17 +134,38 @@ export class CSharpLspUtils {
134
134
  if (!desired) throw new Error('mcp-server version not found; cannot resolve LSP tag');
135
135
  const current = this.readLocalVersion(rid);
136
136
  if (fs.existsSync(p) && current === desired) return p;
137
- await this.autoDownload(rid, desired);
137
+ const resolved = await this.autoDownload(rid, desired);
138
138
  if (!fs.existsSync(p)) throw new Error('csharp-lsp binary not found after download');
139
- this.writeLocalVersion(rid, desired);
139
+ this.writeLocalVersion(rid, resolved || desired);
140
140
  return p;
141
141
  }
142
142
 
143
143
  async autoDownload(rid, version) {
144
144
  const repo = process.env.GITHUB_REPOSITORY || 'akiojin/unity-mcp-server';
145
- const tag = `v${version}`;
146
- const manifestUrl = `https://github.com/${repo}/releases/download/${tag}/csharp-lsp-manifest.json`;
147
- const manifest = await this.fetchJson(manifestUrl);
145
+
146
+ const fetchManifest = async ver => {
147
+ const tag = `v${ver}`;
148
+ const manifestUrl = `https://github.com/${repo}/releases/download/${tag}/csharp-lsp-manifest.json`;
149
+ const manifest = await this.fetchJson(manifestUrl);
150
+ return { manifest, tag };
151
+ };
152
+
153
+ let targetVersion = version;
154
+ let manifest;
155
+ try {
156
+ ({ manifest } = await fetchManifest(targetVersion));
157
+ } catch (e) {
158
+ // Gracefully fall back to the latest release when the requested manifest is missing (404).
159
+ if (String(e?.message || '').includes('HTTP 404')) {
160
+ const latest = await this.fetchLatestReleaseVersion(repo);
161
+ if (!latest) throw e;
162
+ targetVersion = latest;
163
+ ({ manifest } = await fetchManifest(targetVersion));
164
+ } else {
165
+ throw e;
166
+ }
167
+ }
168
+
148
169
  const entry = manifest?.assets?.[rid];
149
170
  if (!entry?.url || !entry?.sha256) throw new Error(`manifest missing entry for ${rid}`);
150
171
 
@@ -154,17 +175,35 @@ export class CSharpLspUtils {
154
175
  await this.downloadTo(entry.url, tmp);
155
176
  const actual = await this.sha256File(tmp);
156
177
  if (String(actual).toLowerCase() !== String(entry.sha256).toLowerCase()) {
157
- try { fs.unlinkSync(tmp); } catch {}
178
+ try {
179
+ fs.unlinkSync(tmp);
180
+ } catch {}
158
181
  throw new Error('checksum mismatch for csharp-lsp asset');
159
182
  }
160
183
  // atomic replace
161
- try { fs.renameSync(tmp, dest); } catch (e) {
184
+ try {
185
+ fs.renameSync(tmp, dest);
186
+ } catch (e) {
162
187
  // Windows may need removal before rename
163
- try { fs.unlinkSync(dest); } catch {}
188
+ try {
189
+ fs.unlinkSync(dest);
190
+ } catch {}
164
191
  fs.renameSync(tmp, dest);
165
192
  }
166
- try { if (process.platform !== 'win32') fs.chmodSync(dest, 0o755); } catch {}
193
+ try {
194
+ if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
195
+ } catch {}
167
196
  logger.info(`[csharp-lsp] downloaded: ${path.basename(dest)} @ ${path.dirname(dest)}`);
197
+ return targetVersion;
198
+ }
199
+
200
+ async fetchLatestReleaseVersion(repo) {
201
+ const url = `https://api.github.com/repos/${repo}/releases/latest`;
202
+ const json = await this.fetchJson(url);
203
+ const tag = json?.tag_name || '';
204
+ const version = tag.replace(/^v/, '');
205
+ if (!version) throw new Error('latest release version not found');
206
+ return version;
168
207
  }
169
208
 
170
209
  async fetchJson(url) {
@@ -0,0 +1,90 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { WORKSPACE_ROOT, logger } from '../core/config.js';
5
+
6
+ // Simple in-memory state for the latest test run.
7
+ // Persisting is unnecessary for the current use case because the Node process
8
+ // stays alive across Unity domain reloads, which is where we lose connection.
9
+ const state = {
10
+ status: 'idle', // idle | running | completed | error
11
+ runId: null,
12
+ testMode: null,
13
+ startedAt: null,
14
+ completedAt: null,
15
+ lastUpdate: null
16
+ };
17
+
18
+ const workspaceUnityRoot = path.join(WORKSPACE_ROOT || process.cwd(), '.unity');
19
+ const runStatePath = path.join(workspaceUnityRoot, 'tests', 'test-run-state.json');
20
+
21
+ // Load persisted state once at module load
22
+ await (async () => {
23
+ try {
24
+ const data = await fs.readFile(runStatePath, 'utf8');
25
+ const parsed = JSON.parse(data);
26
+ Object.assign(state, parsed);
27
+ logger.info(`[testRunState] Loaded persisted state from ${runStatePath}`);
28
+ } catch (error) {
29
+ if (error.code !== 'ENOENT') {
30
+ logger.warn(`[testRunState] Failed to load state from ${runStatePath}: ${error.message}`);
31
+ }
32
+ }
33
+ })();
34
+
35
+ export function startRun(testMode, runId) {
36
+ const now = new Date().toISOString();
37
+ state.status = 'running';
38
+ state.runId = runId || crypto.randomUUID();
39
+ state.testMode = testMode || state.testMode;
40
+ state.startedAt = now;
41
+ state.completedAt = null;
42
+ state.lastUpdate = now;
43
+ persist();
44
+ return { ...state };
45
+ }
46
+
47
+ export function updateFromStatus(response) {
48
+ const now = new Date().toISOString();
49
+ if (response?.status) {
50
+ state.status = response.status;
51
+ }
52
+ if (response?.testMode) {
53
+ state.testMode = response.testMode;
54
+ }
55
+ if (response?.runId) {
56
+ state.runId = response.runId;
57
+ }
58
+ state.lastUpdate = now;
59
+
60
+ if (response?.status === 'completed') {
61
+ state.completedAt = now;
62
+ }
63
+ if (response?.status === 'idle') {
64
+ state.runId = null;
65
+ state.testMode = null;
66
+ state.startedAt = null;
67
+ state.completedAt = null;
68
+ }
69
+ persist();
70
+ return { ...state };
71
+ }
72
+
73
+ export function markIdle() {
74
+ const res = updateFromStatus({ status: 'idle' });
75
+ return res;
76
+ }
77
+
78
+ export function getState() {
79
+ return { ...state };
80
+ }
81
+
82
+ async function persist() {
83
+ try {
84
+ const dir = path.dirname(runStatePath);
85
+ await fs.mkdir(dir, { recursive: true });
86
+ await fs.writeFile(runStatePath, JSON.stringify(state, null, 2) + '\n', 'utf8');
87
+ } catch (error) {
88
+ logger.warn(`[testRunState] Failed to persist state to ${runStatePath}: ${error.message}`);
89
+ }
90
+ }