Files
BeatSaber/tools/unity-mcp-server/index.mjs
T

179 lines
4.9 KiB
JavaScript
Raw Normal View History

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";
const UNITY_BRIDGE_URL = (process.env.UNITY_BRIDGE_URL || "http://127.0.0.1:19744").replace(/\/$/, "");
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 = {}) {
const response = await fetch(`${UNITY_BRIDGE_URL}${path}`, {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
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;
}
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);