@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.
- package/.github/workflows/npm-publish.yml +34 -0
- package/.mcpbignore +9 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +69 -0
- package/README.md +368 -0
- package/claude-desktop-config.json +12 -0
- package/docs/unity-mcp-architecture.gif +0 -0
- package/docs/unity-mcp-features.gif +0 -0
- package/docs/unity-mcp-showcase-brickbreaker.gif +0 -0
- package/docs/unity-mcp-showcase-castle.gif +0 -0
- package/docs/unity-mcp-showcase-village.gif +0 -0
- package/icon.png +0 -0
- package/manifest.json +178 -0
- package/package.json +26 -0
- package/src/config.js +52 -0
- package/src/index.js +529 -0
- package/src/instance-discovery.js +501 -0
- package/src/state-persistence.js +97 -0
- package/src/tool-tiers.js +348 -0
- package/src/tools/context-tools.js +33 -0
- package/src/tools/editor-tools.js +4521 -0
- package/src/tools/hub-tools.js +96 -0
- package/src/tools/instance-tools.js +114 -0
- package/src/tools/uma-tools.js +627 -0
- package/src/uma-bridge.js +63 -0
- package/src/unity-editor-bridge.js +1690 -0
- package/src/unity-hub.js +125 -0
- package/tests/multi-agent-stress-test.mjs +400 -0
|
@@ -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
|
+
}
|