@mseep/anklebreaker-unity-mcp 2.30.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.
@@ -0,0 +1,501 @@
1
+ // Unity MCP — Multi-Instance Discovery
2
+ // Discovers running Unity Editor instances via:
3
+ // 1. Shared registry file (%LOCALAPPDATA%/UnityMCP/instances.json)
4
+ // 2. Port scanning fallback (7890-7899)
5
+ //
6
+ // Also manages instance selection state for the current MCP session.
7
+
8
+ import { readFileSync } from "fs";
9
+ import { CONFIG } from "./config.js";
10
+ import { debugLog } from "./state-persistence.js";
11
+
12
+ // ─── Per-Agent Session State ───
13
+ // Tracks which Unity instance each agent is targeting.
14
+ // State is stored in Maps keyed by agent ID because a SINGLE MCP process serves
15
+ // ALL agents/tasks in the same Claude Desktop session. Without per-agent state,
16
+ // Agent A selecting ProjectA would cause Agent B's commands to also route to
17
+ // ProjectA — the classic "cross-agent contamination" bug.
18
+ //
19
+ // The MCP stdio transport processes requests sequentially (no concurrency),
20
+ // so we use a "set current agent before handler" pattern: index.js calls
21
+ // setCurrentAgent(agentId) before each tool handler, and all state functions
22
+ // read/write from the Map entry for _currentAgentId.
23
+ const _agentInstances = new Map(); // agentId → { port, projectName, projectPath, ... }
24
+ const _agentSelectionRequired = new Map(); // agentId → boolean
25
+ let _currentAgentId = "default";
26
+
27
+ // ─── Per-Request Port Override ───
28
+ // When multiple agents share a single MCP process (e.g. parallel Cowork tasks),
29
+ // the per-agent state above can get overwritten between sequential requests.
30
+ // The port override provides a stateless routing mechanism: each tool call can
31
+ // include a `port` parameter, and ALL HTTP requests during that handler execution
32
+ // will be routed to that port — bypassing the shared agent state entirely.
33
+ // This is safe because stdio transport is sequential (one request at a time).
34
+ let _portOverride = null;
35
+
36
+ /**
37
+ * Set a per-request port override. All bridge URL lookups will use this port
38
+ * until clearPortOverride() is called. Must be called before the tool handler
39
+ * and cleared in a finally block after it completes.
40
+ * @param {number} port - The port to route to for this request.
41
+ */
42
+ export function setPortOverride(port) {
43
+ _portOverride = port;
44
+ debugLog(`setPortOverride: routing to port ${port} for this request`);
45
+ }
46
+
47
+ /**
48
+ * Clear the per-request port override. Must be called after tool handler completes.
49
+ */
50
+ export function clearPortOverride() {
51
+ if (_portOverride !== null) {
52
+ debugLog(`clearPortOverride: cleared (was ${_portOverride})`);
53
+ _portOverride = null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Set the current agent context for subsequent state operations.
59
+ * Must be called before any tool handler execution.
60
+ * @param {string} agentId - The agent ID for the current request.
61
+ */
62
+ export function setCurrentAgent(agentId) {
63
+ _currentAgentId = agentId || "default";
64
+ }
65
+
66
+ /**
67
+ * Get the currently selected Unity instance for the current agent.
68
+ * @returns {object|null} Selected instance info, or null if none selected.
69
+ */
70
+ export function getSelectedInstance() {
71
+ return _agentInstances.get(_currentAgentId) || null;
72
+ }
73
+
74
+ /**
75
+ * Validate that the currently selected instance is still alive and hosts the expected project.
76
+ * Called on first tool execution to catch cases where Unity was closed or port changed.
77
+ *
78
+ * Compile-time resilience:
79
+ * During long Unity compilations the main thread is blocked, so the HTTP bridge
80
+ * can't respond to pings. We use the instance registry file (written at startup,
81
+ * persists across compiles) as a secondary signal. If a port is unresponsive but
82
+ * the registry still claims our project is on that port, we keep the selection —
83
+ * Unity is likely just compiling. We only clear the selection when we have positive
84
+ * evidence the project is gone (not in registry AND not responding).
85
+ *
86
+ * @returns {object|null} Validated instance, or null if validation cleared the selection.
87
+ */
88
+ export async function validateSelectedInstance() {
89
+ const currentInstance = _agentInstances.get(_currentAgentId);
90
+ if (!currentInstance) {
91
+ return null;
92
+ }
93
+
94
+ const saved = currentInstance;
95
+ const savedPath = saved.projectPath;
96
+ const savedPort = saved.port;
97
+
98
+ // Ping the saved port and check what project is actually there
99
+ const alive = await pingInstance(savedPort);
100
+ if (alive) {
101
+ const info = await getInstanceInfo(savedPort);
102
+ if (info && info.projectPath && info.projectPath === savedPath) {
103
+ return currentInstance;
104
+ }
105
+
106
+ if (info && info.projectPath) {
107
+ // PORT SWAP DETECTED: a different project is on the saved port
108
+ debugLog(`⚠ Port swap detected! Port ${savedPort} now hosts "${info.projectName}" (expected "${saved.projectName}")`);
109
+ console.error(
110
+ `[MCP Discovery] Port swap detected: port ${savedPort} now hosts "${info.projectName}" instead of "${saved.projectName}". Re-discovering...`
111
+ );
112
+ }
113
+ // Fall through to re-discovery (swap or info unavailable)
114
+ } else {
115
+ // Port not responding — could be compiling, could be shut down.
116
+ // Check the registry file as a secondary signal before assuming the worst.
117
+ const registryEntries = readRegistryFile();
118
+ const registryMatch = registryEntries.find(
119
+ (entry) =>
120
+ entry.port === savedPort &&
121
+ entry.projectPath &&
122
+ entry.projectPath === savedPath
123
+ );
124
+
125
+ if (registryMatch) {
126
+ if (isRegistryEntryStale(registryMatch)) {
127
+ debugLog(
128
+ `Port ${savedPort} unresponsive and registry entry is STALE (lastSeen: ${registryMatch.lastSeen}). Unity likely crashed. Proceeding to re-discovery.`
129
+ );
130
+ } else {
131
+ // Entry is fresh — Unity is very likely just compiling
132
+ debugLog(
133
+ `Port ${savedPort} unresponsive but registry entry is fresh — likely compiling. Keeping selection.`
134
+ );
135
+ return currentInstance;
136
+ }
137
+ }
138
+
139
+ debugLog(`Port ${savedPort} unresponsive and not in registry — re-discovering...`);
140
+ }
141
+
142
+ // Re-discover all instances and find the one matching our saved projectPath
143
+ const instances = await discoverInstances();
144
+ const match = instances.find(
145
+ (inst) => inst.projectPath && inst.projectPath === savedPath
146
+ );
147
+
148
+ if (match) {
149
+ debugLog(`Re-selected ${saved.projectName} on new port ${match.port} (was ${savedPort})`);
150
+ _agentInstances.set(_currentAgentId, match);
151
+ _agentSelectionRequired.set(_currentAgentId, false);
152
+ return match;
153
+ }
154
+
155
+ // Last resort: check if the registry has our project on ANY port (could be compiling on a new port)
156
+ const registryFallback = readRegistryFile().find(
157
+ (entry) => entry.projectPath && entry.projectPath === savedPath
158
+ );
159
+ if (registryFallback && registryFallback.port) {
160
+ if (isRegistryEntryStale(registryFallback)) {
161
+ debugLog(
162
+ `Project "${saved.projectName}" found in registry but entry is STALE. Clearing selection.`
163
+ );
164
+ } else {
165
+ debugLog(
166
+ `Project "${saved.projectName}" found in registry on port ${registryFallback.port} (fresh) — likely compiling. Keeping selection.`
167
+ );
168
+ const updated = { ...saved, port: registryFallback.port };
169
+ _agentInstances.set(_currentAgentId, updated);
170
+ return updated;
171
+ }
172
+ }
173
+
174
+ // Project truly gone — not responding AND not in registry
175
+ debugLog(`Project "${saved.projectName}" no longer found. Clearing selection for agent ${_currentAgentId}.`);
176
+ _agentInstances.delete(_currentAgentId);
177
+ _agentSelectionRequired.set(_currentAgentId, false);
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Check whether the session still needs the user to select an instance.
183
+ */
184
+ export function isInstanceSelectionRequired() {
185
+ return _agentSelectionRequired.get(_currentAgentId) || false;
186
+ }
187
+
188
+ /**
189
+ * Mark that instance selection is required (multiple instances found, none selected).
190
+ */
191
+ export function setInstanceSelectionRequired(required) {
192
+ _agentSelectionRequired.set(_currentAgentId, required);
193
+ }
194
+
195
+ /**
196
+ * Select a Unity instance by port number.
197
+ * All subsequent bridge commands will be routed to this port.
198
+ * @param {number} port - The port of the instance to select.
199
+ * @returns {object} The selected instance info, or error.
200
+ */
201
+ export async function selectInstance(port) {
202
+ const instances = await discoverInstances();
203
+ const match = instances.find((inst) => inst.port === port);
204
+
205
+ if (!match) {
206
+ return {
207
+ success: false,
208
+ error: `No Unity instance found on port ${port}. Use unity_list_instances to see available instances.`,
209
+ };
210
+ }
211
+
212
+ // Verify the instance is actually reachable
213
+ const alive = await pingInstance(port);
214
+ if (!alive) {
215
+ return {
216
+ success: false,
217
+ error: `Unity instance on port ${port} (${match.projectName}) is not responding. It may have shut down.`,
218
+ };
219
+ }
220
+
221
+ _agentInstances.set(_currentAgentId, match);
222
+ _agentSelectionRequired.set(_currentAgentId, false);
223
+ debugLog(`selectInstance: agent ${_currentAgentId} selected port ${port} (${match.projectName})`);
224
+
225
+ return {
226
+ success: true,
227
+ message: `Selected Unity instance: ${match.projectName} (port ${port})`,
228
+ instance: match,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Get the bridge URL for the currently selected instance.
234
+ * Priority: per-request port override > per-agent selection > default CONFIG port.
235
+ * @returns {string} The base URL for HTTP bridge commands.
236
+ */
237
+ export function getActiveBridgeUrl() {
238
+ const host = CONFIG.editorBridgeHost;
239
+ // Per-request override takes highest priority (stateless routing for parallel agents)
240
+ if (_portOverride !== null) {
241
+ return `http://${host}:${_portOverride}`;
242
+ }
243
+ const selected = _agentInstances.get(_currentAgentId);
244
+ if (selected) {
245
+ return `http://${host}:${selected.port}`;
246
+ }
247
+ return `http://${host}:${CONFIG.editorBridgePort}`;
248
+ }
249
+
250
+ /**
251
+ * Get the port of the currently selected instance, or the default.
252
+ * Priority: per-request port override > per-agent selection > default CONFIG port.
253
+ */
254
+ export function getActivePort() {
255
+ if (_portOverride !== null) {
256
+ return _portOverride;
257
+ }
258
+ const selected = _agentInstances.get(_currentAgentId);
259
+ if (selected) {
260
+ return selected.port;
261
+ }
262
+ return CONFIG.editorBridgePort;
263
+ }
264
+
265
+ /**
266
+ * Discover all running Unity instances.
267
+ * Reads the shared registry file first, then validates each entry is alive.
268
+ * Falls back to port scanning if the registry is empty/missing.
269
+ *
270
+ * @returns {Array<object>} List of discovered instances with their metadata.
271
+ */
272
+ export async function discoverInstances() {
273
+ let instances = [];
274
+
275
+ // Step 1: Read registry file
276
+ try {
277
+ const registryData = readRegistryFile();
278
+ if (registryData.length > 0) {
279
+ // Validate each entry by pinging it
280
+ const validated = await Promise.all(
281
+ registryData.map(async (entry) => {
282
+ const port = entry.port;
283
+ if (!port) return null;
284
+
285
+ const alive = await pingInstance(port);
286
+ return alive ? { ...entry, alive: true, source: "registry" } : null;
287
+ })
288
+ );
289
+
290
+ instances = validated.filter((inst) => inst !== null);
291
+ }
292
+ } catch (err) {
293
+ console.error(`[MCP Discovery] Error reading registry: ${err.message}`);
294
+ }
295
+
296
+ // Step 2: Port scan fallback (find instances not in registry)
297
+ const registeredPorts = new Set(instances.map((i) => i.port));
298
+
299
+ const scanPromises = [];
300
+ for (let port = CONFIG.portRangeStart; port <= CONFIG.portRangeEnd; port++) {
301
+ if (registeredPorts.has(port)) continue; // Already found via registry
302
+
303
+ scanPromises.push(
304
+ (async () => {
305
+ const alive = await pingInstance(port);
306
+ if (alive) {
307
+ // Try to get project info from the instance
308
+ const info = await getInstanceInfo(port);
309
+ return {
310
+ port,
311
+ projectName: info?.projectName || `Unknown (port ${port})`,
312
+ projectPath: info?.projectPath || "",
313
+ unityVersion: info?.unityVersion || "",
314
+ isClone: info?.isClone || false,
315
+ cloneIndex: info?.cloneIndex ?? -1,
316
+ alive: true,
317
+ source: "portscan",
318
+ };
319
+ }
320
+ return null;
321
+ })()
322
+ );
323
+ }
324
+
325
+ const scanned = await Promise.all(scanPromises);
326
+ for (const inst of scanned) {
327
+ if (inst) instances.push(inst);
328
+ }
329
+
330
+ return instances;
331
+ }
332
+
333
+ /**
334
+ * Auto-select an instance if exactly one is available.
335
+ * If multiple are found, marks selection as required.
336
+ * If none are found, tries the default port.
337
+ * @returns {object} Result with auto-selected instance or selection requirement.
338
+ */
339
+ export async function autoSelectInstance() {
340
+ const instances = await discoverInstances();
341
+
342
+ if (instances.length === 0) {
343
+ // No instances found — try default port as last resort
344
+ const defaultAlive = await pingInstance(CONFIG.editorBridgePort);
345
+ if (defaultAlive) {
346
+ const info = await getInstanceInfo(CONFIG.editorBridgePort);
347
+ const defaultInstance = {
348
+ port: CONFIG.editorBridgePort,
349
+ projectName: info?.projectName || "Unity Editor",
350
+ projectPath: info?.projectPath || "",
351
+ unityVersion: info?.unityVersion || "",
352
+ isClone: false,
353
+ cloneIndex: -1,
354
+ alive: true,
355
+ source: "default",
356
+ };
357
+ _agentInstances.set(_currentAgentId, defaultInstance);
358
+ _agentSelectionRequired.set(_currentAgentId, false);
359
+ debugLog(`autoSelect: agent ${_currentAgentId} → single default instance on port ${CONFIG.editorBridgePort}`);
360
+ return {
361
+ autoSelected: true,
362
+ instance: defaultInstance,
363
+ instances: [defaultInstance],
364
+ message: `Auto-connected to Unity Editor: ${defaultInstance.projectName} (port ${CONFIG.editorBridgePort})`,
365
+ };
366
+ }
367
+
368
+ _agentSelectionRequired.set(_currentAgentId, false);
369
+ return {
370
+ autoSelected: false,
371
+ instances: [],
372
+ message: "No Unity Editor instances found. Make sure Unity is running with the MCP plugin enabled.",
373
+ };
374
+ }
375
+
376
+ if (instances.length === 1) {
377
+ // Exactly one instance — auto-select it
378
+ _agentInstances.set(_currentAgentId, instances[0]);
379
+ _agentSelectionRequired.set(_currentAgentId, false);
380
+ debugLog(`autoSelect: agent ${_currentAgentId} → single instance on port ${instances[0].port}`);
381
+ return {
382
+ autoSelected: true,
383
+ instance: instances[0],
384
+ instances,
385
+ message: `Auto-connected to Unity Editor: ${instances[0].projectName} (port ${instances[0].port})`,
386
+ };
387
+ }
388
+
389
+ // Multiple instances — require user selection (but only if none already selected for this agent)
390
+ const agentSelected = _agentInstances.get(_currentAgentId);
391
+ if (!agentSelected) {
392
+ _agentSelectionRequired.set(_currentAgentId, true);
393
+ debugLog(`autoSelect: agent ${_currentAgentId} → ${instances.length} instances found, selection required`);
394
+ }
395
+ return {
396
+ autoSelected: false,
397
+ instances,
398
+ message: `Found ${instances.length} Unity Editor instances. Please use unity_select_instance to choose which one to work with.`,
399
+ };
400
+ }
401
+
402
+ // ─── Internal helpers ───
403
+
404
+ /**
405
+ * Check if a registry entry is stale (Unity likely crashed).
406
+ * The plugin updates `lastSeen` every ~30s via a heartbeat. If the entry's
407
+ * lastSeen timestamp is older than the staleness timeout, Unity likely crashed
408
+ * without calling OnDisable (which would have cleaned up the entry).
409
+ *
410
+ * If the entry has no `lastSeen` field (old plugin version), we fall back to
411
+ * `registeredAt`. If neither is present, we treat it as stale (no way to verify).
412
+ *
413
+ * @param {object} entry - A registry entry object.
414
+ * @returns {boolean} True if the entry is considered stale.
415
+ */
416
+ function isRegistryEntryStale(entry) {
417
+ const timestamp = entry.lastSeen || entry.registeredAt;
418
+ if (!timestamp) {
419
+ // No timestamp at all — can't verify freshness, assume stale
420
+ return true;
421
+ }
422
+
423
+ try {
424
+ const entryTime = new Date(timestamp).getTime();
425
+ if (isNaN(entryTime)) return true; // Unparseable timestamp
426
+
427
+ const ageMs = Date.now() - entryTime;
428
+ const isStale = ageMs > CONFIG.registryStalenessTimeoutMs;
429
+
430
+ if (isStale) {
431
+ const ageMinutes = Math.round(ageMs / 60000);
432
+ debugLog(`Registry entry staleness check: age=${ageMinutes}min, threshold=${CONFIG.registryStalenessTimeoutMs / 60000}min → STALE`);
433
+ }
434
+
435
+ return isStale;
436
+ } catch {
437
+ return true; // Error parsing — assume stale
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Read the instance registry file.
443
+ * @returns {Array<object>} Parsed instance entries.
444
+ */
445
+ function readRegistryFile() {
446
+ try {
447
+ const raw = readFileSync(CONFIG.instanceRegistryPath, "utf-8");
448
+ const data = JSON.parse(raw);
449
+ if (Array.isArray(data)) return data;
450
+ return [];
451
+ } catch {
452
+ // File doesn't exist or can't be parsed — that's fine
453
+ return [];
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Ping a Unity instance at a specific port (fast timeout for discovery).
459
+ * @param {number} port
460
+ * @returns {boolean} True if the instance is alive.
461
+ */
462
+ async function pingInstance(port) {
463
+ try {
464
+ const url = `http://${CONFIG.editorBridgeHost}:${port}/api/ping`;
465
+ const response = await fetch(url, {
466
+ method: "GET",
467
+ signal: AbortSignal.timeout(1500), // Short timeout for discovery
468
+ });
469
+ return response.ok;
470
+ } catch {
471
+ return false;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Get project information from a Unity instance via its ping endpoint.
477
+ * @param {number} port
478
+ * @returns {object|null} Project info, or null if unavailable.
479
+ */
480
+ async function getInstanceInfo(port) {
481
+ try {
482
+ const url = `http://${CONFIG.editorBridgeHost}:${port}/api/ping`;
483
+ const response = await fetch(url, {
484
+ method: "GET",
485
+ signal: AbortSignal.timeout(2000),
486
+ });
487
+
488
+ if (!response.ok) return null;
489
+
490
+ const data = await response.json();
491
+ return {
492
+ projectName: data.projectName || data.project || null,
493
+ projectPath: data.projectPath || null,
494
+ unityVersion: data.unityVersion || data.version || null,
495
+ isClone: data.isClone || false,
496
+ cloneIndex: data.cloneIndex ?? -1,
497
+ };
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
@@ -0,0 +1,97 @@
1
+ // AnkleBreaker Unity MCP — File-based State Persistence
2
+ // Persists critical session state (selected instance, discovery flags) to disk
3
+ // so it survives MCP server process restarts by the host (Claude Desktop).
4
+ //
5
+ // The MCP host may kill and restart the server process between tool calls,
6
+ // which wipes all in-memory module-level state. This module provides a
7
+ // transparent persistence layer to maintain continuity across restarts.
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from "fs";
10
+ import { join, dirname } from "path";
11
+ import { CONFIG } from "./config.js";
12
+
13
+ // State file lives alongside the instance registry
14
+ const STATE_DIR = dirname(CONFIG.instanceRegistryPath);
15
+ const STATE_FILE = join(STATE_DIR, "mcp-session-state.json");
16
+ const DEBUG_LOG = join(STATE_DIR, "mcp-debug.log");
17
+
18
+ // Time-to-live: persisted state expires after this duration (ms).
19
+ // Prevents stale state from a previous session being picked up.
20
+ const STATE_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
21
+
22
+ /**
23
+ * Persist a key-value pair to the session state file.
24
+ * @param {string} key
25
+ * @param {*} value — must be JSON-serializable
26
+ */
27
+ export function persistState(key, value) {
28
+ try {
29
+ let state = {};
30
+ if (existsSync(STATE_FILE)) {
31
+ try {
32
+ state = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
33
+ } catch {
34
+ state = {};
35
+ }
36
+ }
37
+ state[key] = value;
38
+ state._updatedAt = Date.now();
39
+ mkdirSync(STATE_DIR, { recursive: true });
40
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
41
+ } catch (err) {
42
+ debugLog(`persistState(${key}) FAILED: ${err.message}`);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Load a value from the persisted session state.
48
+ * Returns undefined if the key doesn't exist or the state has expired.
49
+ * @param {string} key
50
+ * @returns {*} The persisted value, or undefined.
51
+ */
52
+ export function loadState(key) {
53
+ try {
54
+ if (!existsSync(STATE_FILE)) return undefined;
55
+ const state = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
56
+
57
+ // Check TTL — expire old state to avoid cross-session bleed
58
+ if (state._updatedAt && Date.now() - state._updatedAt > STATE_TTL_MS) {
59
+ debugLog(`loadState(${key}): state expired (age=${Date.now() - state._updatedAt}ms)`);
60
+ return undefined;
61
+ }
62
+
63
+ return state[key];
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Clear all persisted session state.
71
+ */
72
+ export function clearState() {
73
+ try {
74
+ if (existsSync(STATE_FILE)) {
75
+ writeFileSync(STATE_FILE, "{}");
76
+ }
77
+ } catch {
78
+ // Ignore cleanup errors
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Append a debug message to the file-based debug log.
84
+ * Unlike console.error (which may not be visible), this always writes to disk.
85
+ * @param {string} message
86
+ */
87
+ export function debugLog(message) {
88
+ try {
89
+ const ts = new Date().toISOString();
90
+ const pid = process.pid;
91
+ mkdirSync(STATE_DIR, { recursive: true });
92
+ appendFileSync(DEBUG_LOG, `[${ts}] [PID:${pid}] ${message}\n`);
93
+ } catch {
94
+ // Last-resort: try console.error
95
+ console.error(`[MCP Debug] ${message}`);
96
+ }
97
+ }