2026-05-28 19:01:20 +09:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
|
|
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
|
|
|
import { z } from "zod";
|
|
|
|
|
|
2026-05-29 00:32:21 +09:00
|
|
|
const EXPLICIT_UNITY_BRIDGE_URL = process.env.UNITY_BRIDGE_URL;
|
|
|
|
|
let unityBridgeUrl = (EXPLICIT_UNITY_BRIDGE_URL || "http://127.0.0.1:19744").replace(/\/$/, "");
|
2026-05-28 19:01:20 +09:00
|
|
|
|
|
|
|
|
const server = new McpServer({
|
|
|
|
|
name: "vrbeats-unity",
|
|
|
|
|
version: "0.1.0",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function textResult(value) {
|
|
|
|
|
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
|
|
|
return {
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
type: "text",
|
|
|
|
|
text,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function callUnity(path, options = {}) {
|
2026-05-29 00:32:21 +09:00
|
|
|
const response = await fetchUnity(path, options);
|
2026-05-28 19:01:20 +09:00
|
|
|
|
|
|
|
|
const rawText = await response.text();
|
|
|
|
|
let parsed;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
parsed = rawText.length > 0 ? JSON.parse(rawText) : {};
|
|
|
|
|
} catch {
|
|
|
|
|
parsed = { ok: false, rawText };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const detail = typeof parsed === "object" ? JSON.stringify(parsed, null, 2) : rawText;
|
|
|
|
|
throw new Error(`Unity bridge HTTP ${response.status}: ${detail}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 00:32:21 +09:00
|
|
|
async function fetchUnity(path, options = {}) {
|
|
|
|
|
const urls = [unityBridgeUrl];
|
|
|
|
|
|
|
|
|
|
if (!EXPLICIT_UNITY_BRIDGE_URL) {
|
|
|
|
|
for (let port = 19744; port <= 19748; port += 1) {
|
|
|
|
|
const url = `http://127.0.0.1:${port}`;
|
|
|
|
|
if (!urls.includes(url)) {
|
|
|
|
|
urls.push(url);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lastError;
|
|
|
|
|
|
|
|
|
|
for (const url of urls) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${url}${path}`, {
|
|
|
|
|
method: options.method || "GET",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
unityBridgeUrl = url;
|
|
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw lastError;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 19:01:20 +09:00
|
|
|
function queryString(params) {
|
|
|
|
|
const query = new URLSearchParams();
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
|
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
|
|
|
query.set(key, String(value));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const value = query.toString();
|
|
|
|
|
return value.length > 0 ? `?${value}` : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const Vector3Schema = z.object({
|
|
|
|
|
x: z.number(),
|
|
|
|
|
y: z.number(),
|
|
|
|
|
z: z.number(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_health",
|
|
|
|
|
{
|
|
|
|
|
title: "Unity Bridge Health",
|
|
|
|
|
description: "Check whether the Unity Editor bridge is reachable and report play state.",
|
|
|
|
|
inputSchema: {},
|
|
|
|
|
},
|
|
|
|
|
async () => textResult(await callUnity("/health")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_capture_game_view",
|
|
|
|
|
{
|
|
|
|
|
title: "Capture Unity Game View",
|
|
|
|
|
description: "Capture the active Unity camera to Captures/latest.png and return the local file path.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
width: z.number().int().min(320).max(4096).default(1280),
|
|
|
|
|
height: z.number().int().min(180).max(4096).default(720),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ width = 1280, height = 720 }) => {
|
|
|
|
|
const result = await callUnity(`/capture${queryString({ width, height })}`);
|
|
|
|
|
return textResult(result);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_get_console_logs",
|
|
|
|
|
{
|
|
|
|
|
title: "Get Unity Console Logs",
|
|
|
|
|
description: "Read recent logs captured by the Unity Editor bridge.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
count: z.number().int().min(1).max(250).default(80),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ count = 80 }) => textResult(await callUnity(`/logs${queryString({ count })}`)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_list_scene_roots",
|
|
|
|
|
{
|
|
|
|
|
title: "List Unity Scene Roots",
|
|
|
|
|
description: "List root GameObjects in the active Unity scene.",
|
|
|
|
|
inputSchema: {},
|
|
|
|
|
},
|
|
|
|
|
async () => textResult(await callUnity("/scene/roots")),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_list_scene_objects",
|
|
|
|
|
{
|
|
|
|
|
title: "List Unity Scene Objects",
|
|
|
|
|
description: "List GameObjects in the active Unity scene, optionally filtered by hierarchy path text.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
query: z.string().optional().describe("Case-insensitive path filter."),
|
|
|
|
|
limit: z.number().int().min(1).max(500).default(120),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ query = "", limit = 120 }) =>
|
|
|
|
|
textResult(await callUnity(`/scene/objects${queryString({ query, limit })}`)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_get_object",
|
|
|
|
|
{
|
|
|
|
|
title: "Get Unity Object",
|
|
|
|
|
description: "Read transform and component information for a GameObject by hierarchy path.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
path: z.string().min(1).describe("Hierarchy path, for example Canvas/Panel/Button."),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ path }) => textResult(await callUnity(`/object${queryString({ path })}`)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_set_transform",
|
|
|
|
|
{
|
|
|
|
|
title: "Set Unity Object Transform",
|
|
|
|
|
description: "Set world/local position, rotation, or local scale for a GameObject by hierarchy path.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
path: z.string().min(1),
|
|
|
|
|
position: Vector3Schema.optional(),
|
|
|
|
|
localPosition: Vector3Schema.optional(),
|
|
|
|
|
rotationEuler: Vector3Schema.optional(),
|
|
|
|
|
localRotationEuler: Vector3Schema.optional(),
|
|
|
|
|
scale: Vector3Schema.optional(),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async (input) => textResult(await callUnity("/transform", { method: "POST", body: input })),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
server.registerTool(
|
|
|
|
|
"unity_set_play_state",
|
|
|
|
|
{
|
|
|
|
|
title: "Set Unity Play State",
|
|
|
|
|
description: "Start, pause, resume, or stop Unity Play Mode.",
|
|
|
|
|
inputSchema: {
|
|
|
|
|
state: z.enum(["play", "pause", "resume", "stop"]),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
async ({ state }) => {
|
|
|
|
|
const endpoint = state === "stop" ? "/stop" : state === "pause" ? "/pause" : "/play";
|
|
|
|
|
return textResult(await callUnity(endpoint));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const transport = new StdioServerTransport();
|
|
|
|
|
await server.connect(transport);
|