feat: polish VR gameplay and sync tools

This commit is contained in:
jongjae0305
2026-05-28 19:01:20 +09:00
parent ee34d79a66
commit 03105a4f85
50 changed files with 4986 additions and 328 deletions
+55
View File
@@ -0,0 +1,55 @@
# VRBeats Unity MCP Server
This MCP server connects Codex to the Unity Editor bridge in this project.
## Unity side
Open the project in Unity. The bridge auto-starts on domain reload and listens only on:
```text
http://127.0.0.1:19744
```
Manual controls are available in Unity:
```text
Tools > Codex Bridge > Start Server
Tools > Codex Bridge > Stop Server
Tools > Codex Bridge > Auto Start
Tools > Codex Bridge > Capture Game View
```
## Install
Run once from this folder:
```powershell
npm install
```
## Codex MCP config
Add a server entry like this to the Codex MCP config, then restart Codex/VS Code so the tool list refreshes:
```toml
[mcp_servers.vrbeats_unity]
command = "node"
args = ["C:\\Users\\User-40\\Desktop\\unity\\work\\BeatSabar\\VRBeatSaber\\tools\\unity-mcp-server\\index.mjs"]
startup_timeout_sec = 30
[mcp_servers.vrbeats_unity.env]
UNITY_BRIDGE_URL = "http://127.0.0.1:19744"
```
## Exposed tools
- `unity_health`
- `unity_capture_game_view`
- `unity_get_console_logs`
- `unity_list_scene_roots`
- `unity_list_scene_objects`
- `unity_get_object`
- `unity_set_transform`
- `unity_set_play_state`
`unity_capture_game_view` writes `Captures/latest.png` in the project root.
@@ -0,0 +1,7 @@
[mcp_servers.vrbeats_unity]
command = "node"
args = ["C:\\Users\\User-40\\Desktop\\unity\\work\\BeatSabar\\VRBeatSaber\\tools\\unity-mcp-server\\index.mjs"]
startup_timeout_sec = 30
[mcp_servers.vrbeats_unity.env]
UNITY_BRIDGE_URL = "http://127.0.0.1:19744"
+178
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "vrbeats-unity-mcp-server",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"vrbeats-unity-mcp": "./index.mjs"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.25.0"
},
"scripts": {
"start": "node ./index.mjs"
}
}