feat: polish VR gameplay and sync tools
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user