@luoluo123/unity-mcp-server 0.6.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @luoluo123/unity-mcp-server
2
+
3
+ 这是 Unity MCP 的 MCP stdio server,负责把 MCP tool 调用转发到 Unity Editor Bridge。
4
+
5
+ ## 使用
6
+
7
+ MCP 客户端推荐通过 `npx` 启动:
8
+
9
+ ```bash
10
+ npx -y @luoluo123/unity-mcp-server@0.6.2
11
+ ```
12
+
13
+ 默认会自动探测本机 `127.0.0.1:8765-8775` 范围内的 Unity Bridge。
14
+
15
+ 运行 server 前,需要先在目标 Unity 工程中通过 UPM Git 安装并启动 `com.yys.unity-mcp-bridge`。
16
+
17
+ ## MCP 配置示例
18
+
19
+ ```json
20
+ {
21
+ "type": "stdio",
22
+ "command": "npx",
23
+ "args": [
24
+ "-y",
25
+ "@luoluo123/unity-mcp-server@0.6.2"
26
+ ],
27
+ "env": {
28
+ "UNITY_MCP_AUTO_DETECT": "true",
29
+ "UNITY_MCP_DETECT_HOST": "127.0.0.1",
30
+ "UNITY_MCP_DETECT_PORT_START": "8765",
31
+ "UNITY_MCP_DETECT_PORT_END": "8775",
32
+ "UNITY_MCP_TIMEOUT_MS": "30000"
33
+ },
34
+ "enabled": true
35
+ }
36
+ ```
37
+
38
+ ## 本地开发
39
+
40
+ ```bash
41
+ npm install
42
+ npm run typecheck
43
+ npm run build
44
+ ```
45
+
46
+ 本地运行:
47
+
48
+ ```bash
49
+ node dist/index.js
50
+ ```
51
+
52
+ 发布前检查 npm 包内容:
53
+
54
+ ```bash
55
+ npm pack --dry-run
56
+ ```
57
+
58
+ ## 环境变量
59
+
60
+ ```text
61
+ UNITY_MCP_BRIDGE_URL=http://127.0.0.1:8765
62
+ UNITY_MCP_TIMEOUT_MS=30000
63
+ UNITY_MCP_AUTO_DETECT=true
64
+ UNITY_MCP_DETECT_HOST=127.0.0.1
65
+ UNITY_MCP_DETECT_PORT_START=8765
66
+ UNITY_MCP_DETECT_PORT_END=8775
67
+ UNITY_MCP_PROJECT_PATH=
68
+ UNITY_MCP_PROJECT_NAME=
69
+ ```
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ export function loadConfig() {
2
+ return {
3
+ bridgeUrl: process.env.UNITY_MCP_BRIDGE_URL ?? "http://127.0.0.1:8765",
4
+ bridgeUrlExplicit: typeof process.env.UNITY_MCP_BRIDGE_URL === "string" && process.env.UNITY_MCP_BRIDGE_URL.length > 0,
5
+ timeoutMs: numberFromEnv("UNITY_MCP_TIMEOUT_MS", 30_000),
6
+ autoDetect: boolFromEnv("UNITY_MCP_AUTO_DETECT", true),
7
+ detectHost: process.env.UNITY_MCP_DETECT_HOST ?? "127.0.0.1",
8
+ detectPortStart: numberFromEnv("UNITY_MCP_DETECT_PORT_START", 8765),
9
+ detectPortEnd: numberFromEnv("UNITY_MCP_DETECT_PORT_END", 8775),
10
+ projectPath: stringFromEnv("UNITY_MCP_PROJECT_PATH"),
11
+ projectName: stringFromEnv("UNITY_MCP_PROJECT_NAME"),
12
+ };
13
+ }
14
+ function numberFromEnv(name, fallback) {
15
+ const value = process.env[name];
16
+ if (!value) {
17
+ return fallback;
18
+ }
19
+ const parsed = Number(value);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
21
+ }
22
+ function boolFromEnv(name, fallback) {
23
+ const value = process.env[name];
24
+ if (!value) {
25
+ return fallback;
26
+ }
27
+ return value !== "0" && value.toLowerCase() !== "false";
28
+ }
29
+ function stringFromEnv(name) {
30
+ const value = process.env[name];
31
+ if (typeof value !== "string") {
32
+ return null;
33
+ }
34
+ const trimmed = value.trim();
35
+ return trimmed.length > 0 ? trimmed : null;
36
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ export class BridgeError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details) {
5
+ super(message);
6
+ this.name = "BridgeError";
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ export function toErrorText(error) {
12
+ if (error instanceof BridgeError) {
13
+ if (error.details === undefined || error.details === null) {
14
+ return `${error.code}: ${error.message}`;
15
+ }
16
+ return `${error.code}: ${error.message}\n${JSON.stringify(error.details, null, 2)}`;
17
+ }
18
+ if (error instanceof Error) {
19
+ return error.message;
20
+ }
21
+ return String(error);
22
+ }
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ import { loadConfig } from "./config.js";
7
+ import { BridgeRegistry } from "./unity/BridgeRegistry.js";
8
+ import { UnityBridgeClient } from "./unity/UnityBridgeClient.js";
9
+ import { registerTools } from "./tools/registerTools.js";
10
+ async function main() {
11
+ const config = loadConfig();
12
+ const detectUrls = config.autoDetect ? buildDetectUrls(config.detectHost, config.detectPortStart, config.detectPortEnd) : [];
13
+ const cwd = process.cwd();
14
+ const cwdProjectPath = await findProjectFromCwd(cwd);
15
+ const registry = new BridgeRegistry({
16
+ detectUrls,
17
+ probeTimeoutMs: Math.min(config.timeoutMs, 1500),
18
+ bridgeUrl: config.bridgeUrl,
19
+ bridgeUrlExplicit: config.bridgeUrlExplicit,
20
+ projectPath: config.projectPath,
21
+ projectName: config.projectName,
22
+ cwd,
23
+ cwdProjectPath,
24
+ });
25
+ const bridge = new UnityBridgeClient(registry, config.timeoutMs);
26
+ const server = new McpServer({
27
+ name: "unity-mcp",
28
+ version: "0.6.2",
29
+ });
30
+ registerTools(server, bridge);
31
+ await server.connect(new StdioServerTransport());
32
+ }
33
+ main().catch(error => {
34
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
35
+ console.error(message);
36
+ process.exit(1);
37
+ });
38
+ function buildDetectUrls(host, start, end) {
39
+ const urls = [];
40
+ const first = Math.min(start, end);
41
+ const last = Math.max(start, end);
42
+ for (let port = first; port <= last; port += 1) {
43
+ urls.push(`http://${host}:${port}`);
44
+ }
45
+ return urls;
46
+ }
47
+ async function findProjectFromCwd(cwd) {
48
+ let current = path.resolve(cwd);
49
+ for (let i = 0; i < 16; i += 1) {
50
+ if (await isUnityProjectRoot(current)) {
51
+ return current;
52
+ }
53
+ const parent = path.dirname(current);
54
+ if (parent === current) {
55
+ return null;
56
+ }
57
+ current = parent;
58
+ }
59
+ return null;
60
+ }
61
+ async function isUnityProjectRoot(dir) {
62
+ try {
63
+ const [assets, projectSettings] = await Promise.all([
64
+ fs.stat(path.join(dir, "Assets")).then(s => s.isDirectory()).catch(() => false),
65
+ fs.stat(path.join(dir, "ProjectSettings")).then(s => s.isDirectory()).catch(() => false),
66
+ ]);
67
+ return assets && projectSettings;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
@@ -0,0 +1,67 @@
1
+ import { toErrorText } from "../errors.js";
2
+ import { assetCreateFolderSchema, assetFindSchema, assetPathSchema, bridgeSelectSchema, componentPropertyGetSchema, componentPropertySetSchema, componentSchema, emptySchema, gameObjectCreateSchema, gameObjectRenameSchema, hierarchyListSchema, pathSchema, prefabCreateSchema, prefabInstantiateSchema, sceneNewSchema, sceneOpenSchema, scriptAttachSchema, scriptCreateSchema, transformSetSchema, } from "./schemas.js";
3
+ export function registerTools(server, bridge) {
4
+ register(server, bridge, "unity_health", "Check whether the Unity Editor bridge is reachable.", emptySchema, async () => bridge.health());
5
+ register(server, bridge, "unity_bridge_list", "List all Unity bridges discovered on the configured port range.", emptySchema, async () => bridge.listBridges());
6
+ register(server, bridge, "unity_bridge_select", "Bind the current MCP session to a specific Unity bridge.", bridgeSelectSchema, async (args) => bridge.selectBridge(args));
7
+ register(server, bridge, "unity_bridge_current", "Get the Unity bridge currently bound to this MCP session.", emptySchema, async () => bridge.describeCurrent());
8
+ register(server, bridge, "unity_bridge_get_config", "Get Unity bridge safety configuration.", emptySchema, async () => bridge.command("bridge.getConfig"));
9
+ register(server, bridge, "unity_bridge_get_log_path", "Get Unity bridge log file path.", emptySchema, async () => bridge.command("bridge.getLogPath"));
10
+ register(server, bridge, "unity_project_get_info", "Get Unity version and current project information.", emptySchema, async () => bridge.command("project.getInfo"));
11
+ register(server, bridge, "unity_scene_get_active", "Get the active Unity scene.", emptySchema, async () => bridge.command("scene.getActive"));
12
+ register(server, bridge, "unity_scene_new", "Create a new Unity scene.", sceneNewSchema, async (args) => bridge.command("scene.new", args));
13
+ register(server, bridge, "unity_scene_open", "Open a Unity scene asset.", sceneOpenSchema, async (args) => bridge.command("scene.open", args));
14
+ register(server, bridge, "unity_scene_save", "Save the active Unity scene.", emptySchema, async () => bridge.command("scene.save"));
15
+ register(server, bridge, "unity_scene_save_as", "Save the active Unity scene to a path.", pathSchema, async (args) => bridge.command("scene.saveAs", args));
16
+ register(server, bridge, "unity_scene_get_dirty", "Check whether the active Unity scene has unsaved changes.", emptySchema, async () => bridge.command("scene.getDirty"));
17
+ register(server, bridge, "unity_hierarchy_list", "List GameObjects in the active scene hierarchy.", hierarchyListSchema, async (args) => bridge.command("hierarchy.list", args));
18
+ register(server, bridge, "unity_gameobject_create", "Create a GameObject in the active scene.", gameObjectCreateSchema, async (args) => bridge.command("gameObject.create", args));
19
+ register(server, bridge, "unity_gameobject_delete", "Delete a GameObject by hierarchy path.", pathSchema, async (args) => bridge.command("gameObject.delete", args));
20
+ register(server, bridge, "unity_gameobject_find", "Find a GameObject by hierarchy path.", pathSchema, async (args) => bridge.command("gameObject.find", args));
21
+ register(server, bridge, "unity_gameobject_rename", "Rename a GameObject by hierarchy path.", gameObjectRenameSchema, async (args) => bridge.command("gameObject.rename", args));
22
+ register(server, bridge, "unity_transform_get", "Get a GameObject transform.", pathSchema, async (args) => bridge.command("transform.get", args));
23
+ register(server, bridge, "unity_transform_set", "Set a GameObject transform.", transformSetSchema, async (args) => bridge.command("transform.set", args));
24
+ register(server, bridge, "unity_component_list", "List components on a GameObject.", pathSchema, async (args) => bridge.command("component.list", args));
25
+ register(server, bridge, "unity_component_add", "Add a component to a GameObject.", componentSchema, async (args) => bridge.command("component.add", args));
26
+ register(server, bridge, "unity_component_remove", "Remove a component from a GameObject.", componentSchema, async (args) => bridge.command("component.remove", args));
27
+ register(server, bridge, "unity_component_get", "Get a component on a GameObject.", componentSchema, async (args) => bridge.command("component.get", args));
28
+ register(server, bridge, "unity_component_get_property", "Get a SerializedProperty value from a component.", componentPropertyGetSchema, async (args) => bridge.command("component.getProperty", args));
29
+ register(server, bridge, "unity_component_set_property", "Set a SerializedProperty value on a component.", componentPropertySetSchema, async (args) => bridge.command("component.setProperty", args));
30
+ register(server, bridge, "unity_script_create", "Create a C# MonoBehaviour script under Assets.", scriptCreateSchema, async (args) => bridge.command("script.create", args));
31
+ register(server, bridge, "unity_script_attach", "Attach a compiled script component to a GameObject.", scriptAttachSchema, async (args) => bridge.command("script.attach", args));
32
+ register(server, bridge, "unity_asset_refresh", "Refresh the Unity AssetDatabase.", emptySchema, async () => bridge.command("asset.refresh"));
33
+ register(server, bridge, "unity_asset_find", "Find assets with an AssetDatabase filter.", assetFindSchema, async (args) => bridge.command("asset.find", args));
34
+ register(server, bridge, "unity_asset_load", "Load asset metadata by path.", assetPathSchema, async (args) => bridge.command("asset.load", args));
35
+ register(server, bridge, "unity_asset_create_folder", "Create a folder under Assets.", assetCreateFolderSchema, async (args) => bridge.command("asset.createFolder", args));
36
+ register(server, bridge, "unity_asset_delete", "Delete an asset under Assets.", assetPathSchema, async (args) => bridge.command("asset.delete", args));
37
+ register(server, bridge, "unity_prefab_create", "Create a prefab asset from a scene GameObject.", prefabCreateSchema, async (args) => bridge.command("prefab.create", args));
38
+ register(server, bridge, "unity_prefab_instantiate", "Instantiate a prefab asset into the active scene.", prefabInstantiateSchema, async (args) => bridge.command("prefab.instantiate", args));
39
+ register(server, bridge, "unity_prefab_apply", "Apply a prefab instance back to its prefab asset.", pathSchema, async (args) => bridge.command("prefab.apply", args));
40
+ }
41
+ function register(server, bridge, name, description, schema, handler) {
42
+ server.tool(name, description, schema, async (args) => {
43
+ try {
44
+ const result = await handler(args);
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text: JSON.stringify(result, null, 2),
50
+ },
51
+ ],
52
+ };
53
+ }
54
+ catch (error) {
55
+ return {
56
+ isError: true,
57
+ content: [
58
+ {
59
+ type: "text",
60
+ text: toErrorText(error),
61
+ },
62
+ ],
63
+ };
64
+ }
65
+ });
66
+ void bridge;
67
+ }
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ export const emptySchema = {};
3
+ export const pathSchema = {
4
+ path: z.string().min(1),
5
+ };
6
+ export const vector3Schema = z.object({
7
+ x: z.number(),
8
+ y: z.number(),
9
+ z: z.number(),
10
+ });
11
+ export const hierarchyListSchema = {
12
+ rootPath: z.string().optional(),
13
+ recursive: z.boolean().optional(),
14
+ };
15
+ export const gameObjectCreateSchema = {
16
+ name: z.string().optional(),
17
+ parentPath: z.string().optional(),
18
+ };
19
+ export const gameObjectRenameSchema = {
20
+ path: z.string().min(1),
21
+ name: z.string().min(1),
22
+ };
23
+ export const transformSetSchema = {
24
+ path: z.string().min(1),
25
+ position: vector3Schema.optional(),
26
+ localPosition: vector3Schema.optional(),
27
+ rotation: vector3Schema.optional(),
28
+ localRotation: vector3Schema.optional(),
29
+ scale: vector3Schema.optional(),
30
+ };
31
+ export const componentSchema = {
32
+ path: z.string().min(1),
33
+ typeName: z.string().min(1),
34
+ };
35
+ const propertyValueSchema = z.union([
36
+ z.string(),
37
+ z.number(),
38
+ z.boolean(),
39
+ z.null(),
40
+ z.object({ x: z.number(), y: z.number() }),
41
+ z.object({ x: z.number(), y: z.number(), z: z.number() }),
42
+ z.object({ r: z.number(), g: z.number(), b: z.number(), a: z.number().optional() }),
43
+ ]);
44
+ export const componentPropertyGetSchema = {
45
+ path: z.string().min(1),
46
+ typeName: z.string().min(1),
47
+ propertyPath: z.string().min(1),
48
+ };
49
+ export const componentPropertySetSchema = {
50
+ path: z.string().min(1),
51
+ typeName: z.string().min(1),
52
+ propertyPath: z.string().min(1),
53
+ value: propertyValueSchema.optional(),
54
+ objectReferenceAssetPath: z.string().optional(),
55
+ };
56
+ export const scriptCreateSchema = {
57
+ assetPath: z.string().min(1),
58
+ className: z.string().optional(),
59
+ content: z.string().optional(),
60
+ overwrite: z.boolean().optional(),
61
+ };
62
+ export const scriptAttachSchema = {
63
+ path: z.string().min(1),
64
+ typeName: z.string().min(1),
65
+ compileTimeoutMs: z.number().int().positive().optional(),
66
+ };
67
+ export const sceneNewSchema = {
68
+ setup: z.enum(["DefaultGameObjects", "EmptyScene"]).optional(),
69
+ mode: z.enum(["Single", "Additive"]).optional(),
70
+ };
71
+ export const sceneOpenSchema = {
72
+ path: z.string().min(1),
73
+ mode: z.enum(["Single", "Additive"]).optional(),
74
+ };
75
+ export const prefabCreateSchema = {
76
+ path: z.string().min(1),
77
+ assetPath: z.string().min(1),
78
+ };
79
+ export const prefabInstantiateSchema = {
80
+ assetPath: z.string().min(1),
81
+ parentPath: z.string().optional(),
82
+ };
83
+ export const assetFindSchema = {
84
+ filter: z.string().min(1),
85
+ folders: z.array(z.string().min(1)).optional(),
86
+ };
87
+ export const assetPathSchema = {
88
+ assetPath: z.string().min(1),
89
+ };
90
+ export const assetCreateFolderSchema = {
91
+ parentPath: z.string().optional(),
92
+ name: z.string().min(1),
93
+ };
94
+ export const bridgeSelectSchema = {
95
+ url: z.string().min(1).optional(),
96
+ projectPath: z.string().min(1).optional(),
97
+ projectName: z.string().min(1).optional(),
98
+ instanceId: z.string().min(1).optional(),
99
+ };
@@ -0,0 +1,167 @@
1
+ import { BridgeError } from "../errors.js";
2
+ export class BridgeRegistry {
3
+ options;
4
+ probeUrls;
5
+ entries = [];
6
+ lastDiscoveredAt = 0;
7
+ discoverPromise = null;
8
+ constructor(options) {
9
+ this.options = options;
10
+ const all = new Set();
11
+ all.add(stripTrailingSlash(options.bridgeUrl));
12
+ for (const url of options.detectUrls) {
13
+ all.add(stripTrailingSlash(url));
14
+ }
15
+ this.probeUrls = Array.from(all);
16
+ }
17
+ async list(force = false) {
18
+ if (!force && this.entries.length > 0 && Date.now() - this.lastDiscoveredAt < 1500) {
19
+ return this.entries;
20
+ }
21
+ return this.discover();
22
+ }
23
+ async resolve(force = false) {
24
+ if (this.options.bridgeUrlExplicit) {
25
+ const url = stripTrailingSlash(this.options.bridgeUrl);
26
+ const entry = await this.probeOne(url);
27
+ return {
28
+ url,
29
+ reason: "UNITY_MCP_BRIDGE_URL is set explicitly.",
30
+ entry,
31
+ };
32
+ }
33
+ const entries = await this.list(force);
34
+ if (entries.length === 0) {
35
+ throw new BridgeError("BRIDGE_UNAVAILABLE", `No Unity bridge found in range ${this.probeRange()}. Open the Unity project and ensure the bridge is running via the Tools > Unity MCP window.`);
36
+ }
37
+ if (this.options.projectPath) {
38
+ const target = normalizePath(this.options.projectPath);
39
+ const match = entries.find(e => e.projectPath && normalizePath(e.projectPath) === target);
40
+ if (match) {
41
+ return { url: match.url, reason: `Matched UNITY_MCP_PROJECT_PATH=${this.options.projectPath}.`, entry: match };
42
+ }
43
+ throw new BridgeError("PROJECT_NOT_FOUND", `No bridge matched UNITY_MCP_PROJECT_PATH=${this.options.projectPath}. Candidates: ${describeEntries(entries)}`);
44
+ }
45
+ if (this.options.projectName) {
46
+ const target = this.options.projectName.toLowerCase();
47
+ const match = entries.find(e => e.productName && e.productName.toLowerCase() === target);
48
+ if (match) {
49
+ return { url: match.url, reason: `Matched UNITY_MCP_PROJECT_NAME=${this.options.projectName}.`, entry: match };
50
+ }
51
+ throw new BridgeError("PROJECT_NOT_FOUND", `No bridge matched UNITY_MCP_PROJECT_NAME=${this.options.projectName}. Candidates: ${describeEntries(entries)}`);
52
+ }
53
+ if (this.options.cwdProjectPath) {
54
+ const target = normalizePath(this.options.cwdProjectPath);
55
+ const match = entries.find(e => e.projectPath && normalizePath(e.projectPath) === target);
56
+ if (match) {
57
+ return { url: match.url, reason: `Matched current working directory project ${this.options.cwdProjectPath}.`, entry: match };
58
+ }
59
+ }
60
+ if (entries.length === 1) {
61
+ return { url: entries[0].url, reason: "Only one bridge online.", entry: entries[0] };
62
+ }
63
+ throw new BridgeError("BRIDGE_AMBIGUOUS", `Multiple Unity bridges online and no selector matched. Set UNITY_MCP_PROJECT_PATH or UNITY_MCP_PROJECT_NAME, or call unity_bridge_select. Candidates: ${describeEntries(entries)}`, { candidates: entries.map(toCandidateDto) });
64
+ }
65
+ async select(filter) {
66
+ const entries = await this.list(true);
67
+ const url = filter.url ? stripTrailingSlash(filter.url) : null;
68
+ const projectPath = filter.projectPath ? normalizePath(filter.projectPath) : null;
69
+ const projectName = filter.projectName ? filter.projectName.toLowerCase() : null;
70
+ const instanceId = filter.instanceId ?? null;
71
+ const match = entries.find(e => {
72
+ if (url && e.url !== url)
73
+ return false;
74
+ if (projectPath && (!e.projectPath || normalizePath(e.projectPath) !== projectPath))
75
+ return false;
76
+ if (projectName && (!e.productName || e.productName.toLowerCase() !== projectName))
77
+ return false;
78
+ if (instanceId && e.instanceId !== instanceId)
79
+ return false;
80
+ return true;
81
+ });
82
+ if (!match) {
83
+ throw new BridgeError("PROJECT_NOT_FOUND", `No bridge matches the requested selector. Candidates: ${describeEntries(entries)}`, { candidates: entries.map(toCandidateDto) });
84
+ }
85
+ return { url: match.url, reason: "Selected via unity_bridge_select.", entry: match };
86
+ }
87
+ async probeOne(url) {
88
+ try {
89
+ const health = await fetchHealth(url, this.options.probeTimeoutMs);
90
+ return entryFromHealth(url, health);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ async discover() {
97
+ if (this.discoverPromise) {
98
+ return this.discoverPromise;
99
+ }
100
+ this.discoverPromise = (async () => {
101
+ const results = await Promise.all(this.probeUrls.map(url => this.probeOne(url)));
102
+ const entries = results.filter((entry) => entry !== null);
103
+ this.entries = entries;
104
+ this.lastDiscoveredAt = Date.now();
105
+ return entries;
106
+ })();
107
+ try {
108
+ return await this.discoverPromise;
109
+ }
110
+ finally {
111
+ this.discoverPromise = null;
112
+ }
113
+ }
114
+ probeRange() {
115
+ if (this.probeUrls.length === 0)
116
+ return "(none)";
117
+ if (this.probeUrls.length === 1)
118
+ return this.probeUrls[0];
119
+ return `${this.probeUrls[0]} .. ${this.probeUrls[this.probeUrls.length - 1]}`;
120
+ }
121
+ }
122
+ async function fetchHealth(url, timeoutMs) {
123
+ const controller = new AbortController();
124
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
125
+ try {
126
+ const response = await fetch(`${url}/health`, { method: "GET", signal: controller.signal });
127
+ if (!response.ok) {
128
+ throw new Error(`HTTP ${response.status}`);
129
+ }
130
+ return (await response.json());
131
+ }
132
+ finally {
133
+ clearTimeout(timer);
134
+ }
135
+ }
136
+ function entryFromHealth(url, health) {
137
+ return {
138
+ url,
139
+ projectPath: typeof health.projectPath === "string" ? health.projectPath : null,
140
+ productName: typeof health.productName === "string" ? health.productName : null,
141
+ instanceId: typeof health.instanceId === "string" ? health.instanceId : null,
142
+ unityVersion: typeof health.unityVersion === "string" ? health.unityVersion : null,
143
+ rawHealth: health,
144
+ };
145
+ }
146
+ function stripTrailingSlash(url) {
147
+ return url.replace(/\/$/, "");
148
+ }
149
+ function normalizePath(value) {
150
+ return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
151
+ }
152
+ function describeEntries(entries) {
153
+ if (entries.length === 0)
154
+ return "(none)";
155
+ return entries
156
+ .map(e => `[${e.url} ${e.productName ?? "?"} (${e.projectPath ?? "?"})]`)
157
+ .join(", ");
158
+ }
159
+ export function toCandidateDto(entry) {
160
+ return {
161
+ url: entry.url,
162
+ projectPath: entry.projectPath,
163
+ productName: entry.productName,
164
+ instanceId: entry.instanceId,
165
+ unityVersion: entry.unityVersion,
166
+ };
167
+ }
@@ -0,0 +1,136 @@
1
+ import { BridgeError } from "../errors.js";
2
+ export class UnityBridgeClient {
3
+ registry;
4
+ timeoutMs;
5
+ currentSelection = null;
6
+ resolvePromise = null;
7
+ constructor(registry, timeoutMs) {
8
+ this.registry = registry;
9
+ this.timeoutMs = timeoutMs;
10
+ }
11
+ async health() {
12
+ const selection = await this.ensureSelection();
13
+ const url = `${selection.url}/health`;
14
+ try {
15
+ return await this.fetchJson(url, { method: "GET" });
16
+ }
17
+ catch (error) {
18
+ if (error instanceof BridgeError && error.code === "BRIDGE_UNAVAILABLE") {
19
+ await this.refreshSelection();
20
+ const next = await this.ensureSelection();
21
+ return this.fetchJson(`${next.url}/health`, { method: "GET" });
22
+ }
23
+ throw error;
24
+ }
25
+ }
26
+ async command(command, params = {}) {
27
+ const request = {
28
+ id: crypto.randomUUID(),
29
+ command,
30
+ params,
31
+ };
32
+ const init = {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify(request),
36
+ };
37
+ let selection = await this.ensureSelection();
38
+ let response;
39
+ try {
40
+ response = await this.fetchJson(`${selection.url}/command`, init);
41
+ }
42
+ catch (error) {
43
+ if (!(error instanceof BridgeError) || error.code !== "BRIDGE_UNAVAILABLE") {
44
+ throw error;
45
+ }
46
+ await this.refreshSelection();
47
+ selection = await this.ensureSelection();
48
+ response = await this.fetchJson(`${selection.url}/command`, init);
49
+ }
50
+ if (!response.ok) {
51
+ throw new BridgeError(response.error?.code ?? "OPERATION_FAILED", response.error?.message ?? "Unity bridge command failed.", response.error?.details);
52
+ }
53
+ return response.result;
54
+ }
55
+ async listBridges() {
56
+ const entries = await this.registry.list(true);
57
+ const current = this.currentSelection;
58
+ return entries.map(entry => ({
59
+ url: entry.url,
60
+ projectPath: entry.projectPath,
61
+ productName: entry.productName,
62
+ instanceId: entry.instanceId,
63
+ unityVersion: entry.unityVersion,
64
+ isCurrent: current ? current.url === entry.url : false,
65
+ }));
66
+ }
67
+ async selectBridge(filter) {
68
+ const selection = await this.registry.select(filter);
69
+ this.currentSelection = selection;
70
+ return {
71
+ url: selection.url,
72
+ reason: selection.reason,
73
+ projectPath: selection.entry?.projectPath ?? null,
74
+ productName: selection.entry?.productName ?? null,
75
+ instanceId: selection.entry?.instanceId ?? null,
76
+ unityVersion: selection.entry?.unityVersion ?? null,
77
+ };
78
+ }
79
+ async describeCurrent() {
80
+ const selection = await this.ensureSelection();
81
+ return {
82
+ url: selection.url,
83
+ reason: selection.reason,
84
+ projectPath: selection.entry?.projectPath ?? null,
85
+ productName: selection.entry?.productName ?? null,
86
+ instanceId: selection.entry?.instanceId ?? null,
87
+ unityVersion: selection.entry?.unityVersion ?? null,
88
+ };
89
+ }
90
+ async ensureSelection() {
91
+ if (this.currentSelection) {
92
+ return this.currentSelection;
93
+ }
94
+ if (!this.resolvePromise) {
95
+ this.resolvePromise = this.registry.resolve();
96
+ }
97
+ try {
98
+ this.currentSelection = await this.resolvePromise;
99
+ return this.currentSelection;
100
+ }
101
+ finally {
102
+ this.resolvePromise = null;
103
+ }
104
+ }
105
+ async refreshSelection() {
106
+ this.currentSelection = null;
107
+ this.resolvePromise = this.registry.resolve(true);
108
+ try {
109
+ this.currentSelection = await this.resolvePromise;
110
+ }
111
+ finally {
112
+ this.resolvePromise = null;
113
+ }
114
+ }
115
+ async fetchJson(url, init) {
116
+ const controller = new AbortController();
117
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
118
+ try {
119
+ const response = await fetch(url, { ...init, signal: controller.signal });
120
+ if (!response.ok) {
121
+ throw new BridgeError("BRIDGE_UNAVAILABLE", `Unity bridge returned HTTP ${response.status}.`);
122
+ }
123
+ return (await response.json());
124
+ }
125
+ catch (error) {
126
+ if (error instanceof BridgeError) {
127
+ throw error;
128
+ }
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ throw new BridgeError("BRIDGE_UNAVAILABLE", `Could not reach Unity bridge at ${url}. Open the Unity project and start the bridge from the Tools > Unity MCP window. ${message}`);
131
+ }
132
+ finally {
133
+ clearTimeout(timeout);
134
+ }
135
+ }
136
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@luoluo123/unity-mcp-server",
3
+ "version": "0.6.2",
4
+ "description": "MCP stdio server for Unity Editor MCP Bridge.",
5
+ "type": "module",
6
+ "bin": {
7
+ "unity-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/FRZ5201314/UnityEditorMCP.git",
20
+ "directory": "server"
21
+ },
22
+ "homepage": "https://github.com/FRZ5201314/UnityEditorMCP#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/FRZ5201314/UnityEditorMCP/issues"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "unity",
29
+ "unity-editor",
30
+ "model-context-protocol"
31
+ ],
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "typecheck": "tsc -p tsconfig.json --noEmit",
39
+ "prepack": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.12.1",
43
+ "zod": "^3.24.4"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^20.12.12",
47
+ "typescript": "^5.4.5"
48
+ }
49
+ }