@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
package/src/index.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// AnkleBreaker Unity MCP Server — Main entry point
|
|
4
|
+
// Provides tools for Unity Hub management and Unity Editor control via MCP protocol
|
|
5
|
+
//
|
|
6
|
+
// Multi-agent support:
|
|
7
|
+
// Each MCP stdio process gets a unique agent ID (pid-based + random suffix).
|
|
8
|
+
// This lets the Unity plugin's queue system differentiate between agents for
|
|
9
|
+
// fair round-robin scheduling and session tracking.
|
|
10
|
+
//
|
|
11
|
+
// Multi-instance support:
|
|
12
|
+
// Discovers all running Unity Editor instances (via shared registry + port scanning).
|
|
13
|
+
// On first tool call, auto-selects if only one instance is found.
|
|
14
|
+
// If multiple instances are running, prompts the user to select one.
|
|
15
|
+
//
|
|
16
|
+
// Project Context:
|
|
17
|
+
// Exposes project-specific documentation via MCP Resources and a dedicated tool.
|
|
18
|
+
// Auto-injects context summary on the first tool call per session so agents
|
|
19
|
+
// receive project knowledge without needing to explicitly request it.
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from "crypto";
|
|
22
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
23
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
24
|
+
import {
|
|
25
|
+
CallToolRequestSchema,
|
|
26
|
+
ListToolsRequestSchema,
|
|
27
|
+
ListResourcesRequestSchema,
|
|
28
|
+
ReadResourceRequestSchema,
|
|
29
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
30
|
+
|
|
31
|
+
import { hubTools } from "./tools/hub-tools.js";
|
|
32
|
+
import { editorTools } from "./tools/editor-tools.js";
|
|
33
|
+
import { umaTools } from "./tools/uma-tools.js";
|
|
34
|
+
import { contextTools } from "./tools/context-tools.js";
|
|
35
|
+
import { instanceTools } from "./tools/instance-tools.js";
|
|
36
|
+
import { splitToolTiers } from "./tool-tiers.js";
|
|
37
|
+
import { setAgentId, getProjectContext } from "./unity-editor-bridge.js";
|
|
38
|
+
import {
|
|
39
|
+
autoSelectInstance,
|
|
40
|
+
getSelectedInstance,
|
|
41
|
+
isInstanceSelectionRequired,
|
|
42
|
+
validateSelectedInstance,
|
|
43
|
+
setCurrentAgent,
|
|
44
|
+
setPortOverride,
|
|
45
|
+
clearPortOverride,
|
|
46
|
+
} from "./instance-discovery.js";
|
|
47
|
+
import { debugLog } from "./state-persistence.js";
|
|
48
|
+
import { CONFIG } from "./config.js";
|
|
49
|
+
|
|
50
|
+
// ─── Response size protection ───
|
|
51
|
+
// Prevents "Write EOF" errors when tool responses exceed stdio transport limits.
|
|
52
|
+
// Large Unity projects (79K+ objects) can generate multi-MB responses that crash the pipe.
|
|
53
|
+
function truncateResponseIfNeeded(contentBlocks) {
|
|
54
|
+
// Estimate total size across all text blocks
|
|
55
|
+
let totalSize = 0;
|
|
56
|
+
for (const block of contentBlocks) {
|
|
57
|
+
if (block.type === "text") {
|
|
58
|
+
totalSize += (block.text || "").length;
|
|
59
|
+
} else if (block.type === "image") {
|
|
60
|
+
totalSize += (block.data || "").length;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const softLimit = CONFIG.responseSoftLimitBytes;
|
|
65
|
+
const hardLimit = CONFIG.responseHardLimitBytes;
|
|
66
|
+
|
|
67
|
+
if (totalSize > hardLimit) {
|
|
68
|
+
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
|
|
69
|
+
const limitMB = (hardLimit / (1024 * 1024)).toFixed(1);
|
|
70
|
+
console.error(`[MCP] Response truncated: ${sizeMB}MB exceeds hard limit of ${limitMB}MB`);
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text:
|
|
75
|
+
`⚠️ Response too large (${sizeMB} MB, limit: ${limitMB} MB) — truncated to prevent Write EOF error.\n\n` +
|
|
76
|
+
`The requested data was too large to return in a single response. ` +
|
|
77
|
+
`Use pagination parameters to request smaller chunks:\n` +
|
|
78
|
+
`• unity_scene_hierarchy: use maxNodes (default 5000) and/or lower maxDepth\n` +
|
|
79
|
+
`• unity_search_by_name/component/tag/layer: use limit parameter\n` +
|
|
80
|
+
`• unity_asset_list: use maxResults parameter\n` +
|
|
81
|
+
`• unity_console_log: use count parameter\n\n` +
|
|
82
|
+
`Tip: For very large scenes, start with unity_scene_stats to get an overview, ` +
|
|
83
|
+
`then use targeted searches (unity_search_by_name, unity_search_by_tag) instead of loading the full hierarchy.`,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (totalSize > softLimit) {
|
|
89
|
+
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
|
|
90
|
+
console.error(`[MCP] Large response warning: ${sizeMB}MB exceeds soft limit`);
|
|
91
|
+
// Still return the data but add a warning
|
|
92
|
+
contentBlocks.push({
|
|
93
|
+
type: "text",
|
|
94
|
+
text: `\n⚠️ Large response (${sizeMB} MB). Consider using pagination parameters for better performance.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return contentBlocks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Per-process agent identity ───
|
|
102
|
+
// Each MCP stdio process = one Cowork agent.
|
|
103
|
+
// Generate a unique ID so the Unity plugin can track and schedule fairly.
|
|
104
|
+
const PROCESS_AGENT_ID = `agent-${process.pid}-${randomBytes(3).toString("hex")}`;
|
|
105
|
+
setAgentId(PROCESS_AGENT_ID);
|
|
106
|
+
|
|
107
|
+
// ─── Combine all tools (two-tier system) ───
|
|
108
|
+
// Split editor tools into core (always exposed) and advanced (on-demand via meta-tool).
|
|
109
|
+
// This keeps the tool count under ~70, preventing MCP client rejection caused by
|
|
110
|
+
// oversized tool lists (268 tools / 125KB was ~5x beyond what clients handle).
|
|
111
|
+
const { coreTools, metaTools, advancedCount, coreCount } =
|
|
112
|
+
splitToolTiers([...editorTools, ...umaTools]);
|
|
113
|
+
const ALL_TOOLS = [
|
|
114
|
+
...instanceTools,
|
|
115
|
+
...hubTools,
|
|
116
|
+
...coreTools,
|
|
117
|
+
...metaTools,
|
|
118
|
+
...contextTools,
|
|
119
|
+
];
|
|
120
|
+
console.error(
|
|
121
|
+
`[MCP] Tool tiers: ${coreCount} core + ${advancedCount} advanced (via unity_advanced_tool) = ${coreCount + advancedCount} total, ${ALL_TOOLS.length} exposed`
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// ─── Per-Agent Session State ───
|
|
125
|
+
// A SINGLE MCP process serves ALL agents/tasks in the same Claude Desktop session.
|
|
126
|
+
// Without per-agent state, Agent A's context injection would prevent Agent B from
|
|
127
|
+
// getting its own context, and Agent A's instance discovery would be skipped for Agent B.
|
|
128
|
+
// We key state by agent ID to prevent cross-agent contamination.
|
|
129
|
+
|
|
130
|
+
// Context auto-inject: each agent gets project context on their first tool call.
|
|
131
|
+
const _contextInjectedPerAgent = new Map(); // agentId → boolean
|
|
132
|
+
let _contextCache = null; // Shared cache (same project context for all agents)
|
|
133
|
+
|
|
134
|
+
// Instance auto-discovery: each agent discovers instances on their first tool call.
|
|
135
|
+
const _discoveryDonePerAgent = new Map(); // agentId → boolean
|
|
136
|
+
|
|
137
|
+
async function getContextSummaryOnce() {
|
|
138
|
+
if (_contextInjectedPerAgent.get(PROCESS_AGENT_ID)) return null;
|
|
139
|
+
_contextInjectedPerAgent.set(PROCESS_AGENT_ID, true);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
if (!_contextCache) {
|
|
143
|
+
_contextCache = await getProjectContext();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Only inject if context is enabled and has content
|
|
147
|
+
if (
|
|
148
|
+
!_contextCache ||
|
|
149
|
+
!_contextCache.enabled ||
|
|
150
|
+
!_contextCache.categories ||
|
|
151
|
+
_contextCache.categories.length === 0
|
|
152
|
+
) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let summary =
|
|
157
|
+
"=== PROJECT CONTEXT (auto-provided by AB Unity MCP) ===\n\n";
|
|
158
|
+
for (const entry of _contextCache.categories) {
|
|
159
|
+
summary += `--- ${entry.category} ---\n`;
|
|
160
|
+
// Truncate very long files for auto-inject
|
|
161
|
+
let content = entry.content || "";
|
|
162
|
+
if (content.length > 2000) {
|
|
163
|
+
content =
|
|
164
|
+
content.substring(0, 2000) +
|
|
165
|
+
"\n... [truncated — use unity_get_project_context for full content]";
|
|
166
|
+
}
|
|
167
|
+
summary += content + "\n\n";
|
|
168
|
+
}
|
|
169
|
+
summary += "=== END PROJECT CONTEXT ===";
|
|
170
|
+
return summary;
|
|
171
|
+
} catch {
|
|
172
|
+
// Context fetch failed (Unity not connected yet, etc.) — silently skip
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Perform instance discovery on first tool call.
|
|
179
|
+
* Returns a prompt string if user needs to select an instance, or null.
|
|
180
|
+
*/
|
|
181
|
+
async function ensureInstanceDiscovery() {
|
|
182
|
+
const _instanceDiscoveryDone = _discoveryDonePerAgent.get(PROCESS_AGENT_ID) || false;
|
|
183
|
+
debugLog(`ensureInstanceDiscovery: _instanceDiscoveryDone=${_instanceDiscoveryDone}, selectedPort=${getSelectedInstance()?.port || 'null'}, selectionRequired=${isInstanceSelectionRequired()}`);
|
|
184
|
+
|
|
185
|
+
if (_instanceDiscoveryDone) {
|
|
186
|
+
// Discovery already done (likely restored from persistence).
|
|
187
|
+
// Validate that the persisted instance selection still points to the correct project.
|
|
188
|
+
// This detects port swaps: e.g. ProjectA was on port 7891 but now ProjectB is there.
|
|
189
|
+
const validated = await validateSelectedInstance();
|
|
190
|
+
if (validated) {
|
|
191
|
+
debugLog(`Persisted selection validated OK: ${validated.projectName} on port ${validated.port}`);
|
|
192
|
+
} else if (getSelectedInstance() === null) {
|
|
193
|
+
// Validation cleared the selection (project no longer running).
|
|
194
|
+
// Re-run discovery on next call.
|
|
195
|
+
debugLog(`Persisted selection invalidated — project no longer found. Will re-discover.`);
|
|
196
|
+
_discoveryDonePerAgent.set(PROCESS_AGENT_ID, false);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_discoveryDonePerAgent.set(PROCESS_AGENT_ID, true);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await autoSelectInstance();
|
|
205
|
+
|
|
206
|
+
if (result.autoSelected) {
|
|
207
|
+
// Single instance found and auto-selected
|
|
208
|
+
const inst = result.instance;
|
|
209
|
+
const cloneInfo = inst.isClone ? ` (ParrelSync clone #${inst.cloneIndex})` : "";
|
|
210
|
+
return (
|
|
211
|
+
`=== UNITY INSTANCE (auto-connected) ===\n` +
|
|
212
|
+
`Project: ${inst.projectName}${cloneInfo}\n` +
|
|
213
|
+
`Port: ${inst.port}\n` +
|
|
214
|
+
`Unity: ${inst.unityVersion || "unknown"}\n` +
|
|
215
|
+
`Path: ${inst.projectPath || "unknown"}\n` +
|
|
216
|
+
`=== END INSTANCE INFO ===`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (result.instances.length === 0) {
|
|
221
|
+
return (
|
|
222
|
+
`=== UNITY MCP WARNING ===\n` +
|
|
223
|
+
`No Unity Editor instances were detected.\n` +
|
|
224
|
+
`Make sure Unity is running with the MCP plugin enabled.\n` +
|
|
225
|
+
`You can still use Unity Hub tools (unity_hub_*).\n` +
|
|
226
|
+
`=== END WARNING ===`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Multiple instances found — check if one is already selected
|
|
231
|
+
const alreadySelected = getSelectedInstance();
|
|
232
|
+
if (alreadySelected) {
|
|
233
|
+
// User already selected an instance before discovery ran — just confirm
|
|
234
|
+
const cloneInfo = alreadySelected.isClone ? ` (ParrelSync clone #${alreadySelected.cloneIndex})` : "";
|
|
235
|
+
return (
|
|
236
|
+
`=== UNITY INSTANCE (user-selected) ===\n` +
|
|
237
|
+
`Project: ${alreadySelected.projectName}${cloneInfo}\n` +
|
|
238
|
+
`Port: ${alreadySelected.port}\n` +
|
|
239
|
+
`Unity: ${alreadySelected.unityVersion || "unknown"}\n` +
|
|
240
|
+
`Path: ${alreadySelected.projectPath || "unknown"}\n` +
|
|
241
|
+
`(${result.instances.length} instances available — use unity_select_instance to switch)\n` +
|
|
242
|
+
`=== END INSTANCE INFO ===`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No instance selected yet — prompt user to select
|
|
247
|
+
let prompt =
|
|
248
|
+
`=== MULTIPLE UNITY INSTANCES DETECTED ===\n` +
|
|
249
|
+
`Found ${result.instances.length} running Unity Editor instances.\n` +
|
|
250
|
+
`You MUST ask the user which instance to work with before proceeding.\n\n` +
|
|
251
|
+
`Available instances:\n`;
|
|
252
|
+
|
|
253
|
+
for (const inst of result.instances) {
|
|
254
|
+
const cloneInfo = inst.isClone ? ` [ParrelSync clone #${inst.cloneIndex}]` : "";
|
|
255
|
+
prompt += ` • Port ${inst.port}: ${inst.projectName}${cloneInfo} (Unity ${inst.unityVersion || "?"})\n`;
|
|
256
|
+
if (inst.projectPath) {
|
|
257
|
+
prompt += ` Path: ${inst.projectPath}\n`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
prompt +=
|
|
262
|
+
`\nCall unity_select_instance with the port number once the user has chosen.\n` +
|
|
263
|
+
`=== END INSTANCE SELECTION REQUIRED ===`;
|
|
264
|
+
|
|
265
|
+
return prompt;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error(`[MCP] Instance discovery failed: ${err.message}`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Create MCP Server ───
|
|
273
|
+
const server = new Server(
|
|
274
|
+
{
|
|
275
|
+
name: "unity-mcp",
|
|
276
|
+
version: "2.26.0",
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
capabilities: {
|
|
280
|
+
tools: {},
|
|
281
|
+
resources: {},
|
|
282
|
+
},
|
|
283
|
+
instructions: [
|
|
284
|
+
"IMPORTANT: Always use the MCP tools provided by this server (unity_*) to interact with Unity.",
|
|
285
|
+
"NEVER call the Unity HTTP bridge directly (e.g. http://127.0.0.1:7890/api/...).",
|
|
286
|
+
"The bridge is an internal communication layer between this MCP server and the Unity Editor plugin.",
|
|
287
|
+
"Direct HTTP calls bypass the multi-agent queue, agent tracking, and safety mechanisms.",
|
|
288
|
+
"Use the unity_* MCP tools for all Unity operations — they handle queuing, retries, and agent identity automatically.",
|
|
289
|
+
"",
|
|
290
|
+
"MULTI-INSTANCE: This MCP server supports multiple Unity Editor instances running simultaneously.",
|
|
291
|
+
"On your first tool call, instances are auto-discovered. If multiple instances are found,",
|
|
292
|
+
"you MUST ask the user which instance to work with and call unity_select_instance before proceeding.",
|
|
293
|
+
"Use unity_list_instances to see all available instances at any time.",
|
|
294
|
+
].join(" "),
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// ─── List Tools Handler ───
|
|
299
|
+
// Inject an optional `port` parameter into every unity_* tool schema (except
|
|
300
|
+
// unity_select_instance which already owns it, unity_list_instances which lists
|
|
301
|
+
// all instances, and unity_hub_* which talk to Unity Hub not an Editor instance).
|
|
302
|
+
// This lets agents pass `port` on every call for parallel-safe routing without
|
|
303
|
+
// having to modify each tool definition file individually.
|
|
304
|
+
const TOOLS_SKIP_PORT_INJECT = new Set([
|
|
305
|
+
"unity_select_instance",
|
|
306
|
+
"unity_list_instances",
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
310
|
+
return {
|
|
311
|
+
tools: ALL_TOOLS.map(({ name, description, inputSchema }) => {
|
|
312
|
+
// Inject port into unity_* tools that target an Editor instance
|
|
313
|
+
if (
|
|
314
|
+
name.startsWith("unity_") &&
|
|
315
|
+
!name.startsWith("unity_hub_") &&
|
|
316
|
+
!TOOLS_SKIP_PORT_INJECT.has(name)
|
|
317
|
+
) {
|
|
318
|
+
const augmented = {
|
|
319
|
+
...inputSchema,
|
|
320
|
+
properties: {
|
|
321
|
+
...(inputSchema.properties || {}),
|
|
322
|
+
port: {
|
|
323
|
+
type: "number",
|
|
324
|
+
description:
|
|
325
|
+
"Target Unity instance port for parallel-safe routing. " +
|
|
326
|
+
"Get this from unity_select_instance. When working with " +
|
|
327
|
+
"multiple Unity instances, ALWAYS include this parameter.",
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
return { name, description, inputSchema: augmented };
|
|
332
|
+
}
|
|
333
|
+
return { name, description, inputSchema };
|
|
334
|
+
}),
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ─── Call Tool Handler ───
|
|
339
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
340
|
+
const { name, arguments: args } = request.params;
|
|
341
|
+
|
|
342
|
+
const tool = ALL_TOOLS.find((t) => t.name === name);
|
|
343
|
+
if (!tool) {
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
346
|
+
isError: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
// Allow per-request agent ID override from MCP metadata, but default to
|
|
352
|
+
// the process-level ID which is more reliable for multi-agent scheduling.
|
|
353
|
+
const meta = request.params._meta || {};
|
|
354
|
+
if (meta.agentId || meta.agent_id) {
|
|
355
|
+
const overrideId = meta.agentId || meta.agent_id;
|
|
356
|
+
setAgentId(overrideId);
|
|
357
|
+
setCurrentAgent(overrideId);
|
|
358
|
+
} else {
|
|
359
|
+
// Ensure instance-discovery state targets this process's agent
|
|
360
|
+
setCurrentAgent(PROCESS_AGENT_ID);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ─── Per-request port override (parallel-agent safe routing) ───
|
|
364
|
+
// When multiple agents share this MCP process, the per-agent state can get
|
|
365
|
+
// overwritten between sequential requests. If the caller provides a `port`
|
|
366
|
+
// parameter (or _meta.port), we bypass the shared state entirely and route
|
|
367
|
+
// directly to that port for the duration of this request.
|
|
368
|
+
const portOverride = (args && typeof args.port === "number" && args.port)
|
|
369
|
+
|| (meta && typeof meta.port === "number" && meta.port)
|
|
370
|
+
|| null;
|
|
371
|
+
|
|
372
|
+
if (portOverride) {
|
|
373
|
+
setPortOverride(portOverride);
|
|
374
|
+
debugLog(`Port override active: ${portOverride} for tool ${name}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
// Auto-discover instances on first tool call (unless it's an instance tool itself)
|
|
379
|
+
// Skip auto-discovery when port override is active — the caller already knows where to route.
|
|
380
|
+
let instancePrompt = null;
|
|
381
|
+
if (!portOverride && name !== "unity_list_instances" && name !== "unity_select_instance") {
|
|
382
|
+
instancePrompt = await ensureInstanceDiscovery();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// If instance selection is required and this isn't an instance/hub tool, warn
|
|
386
|
+
// Skip this check when port override is active — the caller is explicitly routing.
|
|
387
|
+
const _selReq = !portOverride && isInstanceSelectionRequired();
|
|
388
|
+
const _selInst = getSelectedInstance();
|
|
389
|
+
debugLog(`Tool=${name}, portOverride=${portOverride || 'null'}, selectionRequired=${_selReq}, selectedPort=${_selInst?.port || 'null'}, instancePrompt=${instancePrompt ? 'SET' : 'null'}, discoveryDone=${_discoveryDonePerAgent.get(PROCESS_AGENT_ID) || false}`);
|
|
390
|
+
if (
|
|
391
|
+
_selReq &&
|
|
392
|
+
!name.startsWith("unity_hub_") &&
|
|
393
|
+
name !== "unity_list_instances" &&
|
|
394
|
+
name !== "unity_select_instance" &&
|
|
395
|
+
name !== "unity_get_project_context"
|
|
396
|
+
) {
|
|
397
|
+
debugLog(`BLOCKING tool ${name} due to selectionRequired=true`);
|
|
398
|
+
return {
|
|
399
|
+
content: [
|
|
400
|
+
{
|
|
401
|
+
type: "text",
|
|
402
|
+
text:
|
|
403
|
+
instancePrompt ||
|
|
404
|
+
"Multiple Unity instances are running. You must call unity_list_instances and then unity_select_instance before using other Unity tools.",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
isError: true,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Strip the `port` parameter before passing to the tool handler
|
|
412
|
+
// so tool implementations don't see unexpected params.
|
|
413
|
+
// Exception: unity_select_instance uses `port` as its own legitimate parameter.
|
|
414
|
+
const handlerArgs = args ? { ...args } : {};
|
|
415
|
+
if (handlerArgs.port !== undefined && name !== "unity_select_instance") {
|
|
416
|
+
delete handlerArgs.port;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const result = await tool.handler(handlerArgs);
|
|
420
|
+
|
|
421
|
+
// Build response content blocks
|
|
422
|
+
const contentBlocks = [];
|
|
423
|
+
|
|
424
|
+
// Instance info (first call only)
|
|
425
|
+
if (instancePrompt) {
|
|
426
|
+
contentBlocks.push({ type: "text", text: instancePrompt });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Auto-inject project context on the first successful tool call
|
|
430
|
+
const contextSummary = await getContextSummaryOnce();
|
|
431
|
+
if (contextSummary) {
|
|
432
|
+
contentBlocks.push({ type: "text", text: contextSummary });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Support content block arrays (for image-returning tools like graphics/*)
|
|
436
|
+
if (Array.isArray(result)) {
|
|
437
|
+
contentBlocks.push(...result);
|
|
438
|
+
} else {
|
|
439
|
+
contentBlocks.push({ type: "text", text: result });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { content: truncateResponseIfNeeded(contentBlocks) };
|
|
443
|
+
|
|
444
|
+
} finally {
|
|
445
|
+
// Always clear port override after request completes, even on error
|
|
446
|
+
clearPortOverride();
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
// Safety: ensure port override is always cleared, even on unexpected errors
|
|
450
|
+
clearPortOverride();
|
|
451
|
+
return {
|
|
452
|
+
content: [
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
text: `Error executing ${name}: ${error.message}`,
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
isError: true,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ─── MCP Resources: Expose project context files ───
|
|
464
|
+
|
|
465
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
466
|
+
try {
|
|
467
|
+
const contextData = await getProjectContext();
|
|
468
|
+
|
|
469
|
+
if (
|
|
470
|
+
!contextData ||
|
|
471
|
+
!contextData.enabled ||
|
|
472
|
+
!contextData.categories
|
|
473
|
+
) {
|
|
474
|
+
return { resources: [] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
resources: contextData.categories.map((entry) => ({
|
|
479
|
+
uri: `unity-context://${encodeURIComponent(entry.category)}`,
|
|
480
|
+
name: `Project Context: ${entry.category}`,
|
|
481
|
+
description: `Project-specific documentation for ${entry.category}`,
|
|
482
|
+
mimeType: "text/markdown",
|
|
483
|
+
})),
|
|
484
|
+
};
|
|
485
|
+
} catch {
|
|
486
|
+
return { resources: [] };
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
491
|
+
const uri = request.params.uri;
|
|
492
|
+
const match = uri.match(/^unity-context:\/\/(.+)$/);
|
|
493
|
+
|
|
494
|
+
if (!match) {
|
|
495
|
+
throw new Error(`Unknown resource URI: ${uri}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const category = decodeURIComponent(match[1]);
|
|
499
|
+
const contextData = await getProjectContext(category);
|
|
500
|
+
|
|
501
|
+
if (contextData.error) {
|
|
502
|
+
throw new Error(contextData.error);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
contents: [
|
|
507
|
+
{
|
|
508
|
+
uri,
|
|
509
|
+
mimeType: "text/markdown",
|
|
510
|
+
text: contextData.content || "",
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ─── Start Server ───
|
|
517
|
+
async function main() {
|
|
518
|
+
const transport = new StdioServerTransport();
|
|
519
|
+
await server.connect(transport);
|
|
520
|
+
debugLog(`=== SERVER START === v2.26.0, agent=${PROCESS_AGENT_ID}, discoveryDone=${_discoveryDonePerAgent.get(PROCESS_AGENT_ID) || false}, selectedPort=${getSelectedInstance()?.port || 'null'}`);
|
|
521
|
+
console.error(
|
|
522
|
+
`Unity MCP Server running on stdio (agent: ${PROCESS_AGENT_ID})`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
main().catch((error) => {
|
|
527
|
+
console.error("Fatal error:", error);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
});
|