@akiojin/unity-mcp-server 2.37.1 → 2.38.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": "2.37.1",
3
+ "version": "2.38.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",
@@ -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,
@@ -123,15 +123,24 @@ export class InputGamepadToolHandler extends BaseToolHandler {
123
123
  required: ['action']
124
124
  }
125
125
  }
126
- },
127
- anyOf: [{ required: ['action'] }, { required: ['actions'] }]
126
+ }
128
127
  }
129
128
  );
130
129
  this.unityConnection = unityConnection;
131
130
  }
132
131
 
133
132
  validate(params) {
134
- const { actions } = params;
133
+ const { action, actions } = params;
134
+
135
+ // Either 'action' or 'actions' must be provided
136
+ if (!action && !actions) {
137
+ throw new Error('Either "action" or "actions" parameter is required');
138
+ }
139
+
140
+ // But not both
141
+ if (action && actions) {
142
+ throw new Error('Cannot specify both "action" and "actions" parameters');
143
+ }
135
144
 
136
145
  if (Array.isArray(actions)) {
137
146
  if (actions.length === 0) {
@@ -88,15 +88,24 @@ export class InputKeyboardToolHandler extends BaseToolHandler {
88
88
  required: ['action']
89
89
  }
90
90
  }
91
- },
92
- anyOf: [{ required: ['action'] }, { required: ['actions'] }]
91
+ }
93
92
  }
94
93
  );
95
94
  this.unityConnection = unityConnection;
96
95
  }
97
96
 
98
97
  validate(params) {
99
- const { actions } = params;
98
+ const { action, actions } = params;
99
+
100
+ // Either 'action' or 'actions' must be provided
101
+ if (!action && !actions) {
102
+ throw new Error('Either "action" or "actions" parameter is required');
103
+ }
104
+
105
+ // But not both
106
+ if (action && actions) {
107
+ throw new Error('Cannot specify both "action" and "actions" parameters');
108
+ }
100
109
 
101
110
  if (Array.isArray(actions)) {
102
111
  if (actions.length === 0) {
@@ -149,15 +149,24 @@ export class InputMouseToolHandler extends BaseToolHandler {
149
149
  required: ['action']
150
150
  }
151
151
  }
152
- },
153
- anyOf: [{ required: ['action'] }, { required: ['actions'] }]
152
+ }
154
153
  }
155
154
  );
156
155
  this.unityConnection = unityConnection;
157
156
  }
158
157
 
159
158
  validate(params) {
160
- const { actions } = params;
159
+ const { action, actions } = params;
160
+
161
+ // Either 'action' or 'actions' must be provided
162
+ if (!action && !actions) {
163
+ throw new Error('Either "action" or "actions" parameter is required');
164
+ }
165
+
166
+ // But not both
167
+ if (action && actions) {
168
+ throw new Error('Cannot specify both "action" and "actions" parameters');
169
+ }
161
170
 
162
171
  if (Array.isArray(actions)) {
163
172
  if (actions.length === 0) {
@@ -140,14 +140,23 @@ export class InputTouchToolHandler extends BaseToolHandler {
140
140
  required: ['action']
141
141
  }
142
142
  }
143
- },
144
- anyOf: [{ required: ['action'] }, { required: ['actions'] }]
143
+ }
145
144
  });
146
145
  this.unityConnection = unityConnection;
147
146
  }
148
147
 
149
148
  validate(params) {
150
- const { actions } = params;
149
+ const { action, actions } = params;
150
+
151
+ // Either 'action' or 'actions' must be provided
152
+ if (!action && !actions) {
153
+ throw new Error('Either "action" or "actions" parameter is required');
154
+ }
155
+
156
+ // But not both
157
+ if (action && actions) {
158
+ throw new Error('Cannot specify both "action" and "actions" parameters');
159
+ }
151
160
 
152
161
  if (Array.isArray(actions)) {
153
162
  if (actions.length === 0) {
@@ -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
 
@@ -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
+ }