Merge branch 'CoplayDev:main' into main

main
dsarno 2025-09-29 20:01:54 -07:00 committed by GitHub
commit bce6afaf24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 5484 additions and 1532 deletions

View File

@ -70,6 +70,9 @@ jobs:
echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION"
sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION"
echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
- name: Commit and push changes
env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }}
@ -78,7 +81,7 @@ jobs:
set -euo pipefail
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml"
git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt"
if git diff --cached --quiet; then
echo "No version changes to commit."
else

20
.github/workflows/github-repo-stats.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: github-repo-stats
on:
schedule:
# Run this once per day, towards the end of the day for keeping the most
# recent data point most meaningful (hours are interpreted in UTC).
- cron: "0 23 * * *"
workflow_dispatch: # Allow for running this manually.
jobs:
j1:
name: github-repo-stats
runs-on: ubuntu-latest
steps:
- name: run-ghrs
# Use latest release.
uses: jgehrcke/github-repo-stats@RELEASE
with:
ghtoken: ${{ secrets.ghrs_github_api_token }}

215
README-DEV-zh.md Normal file
View File

@ -0,0 +1,215 @@
# MCP for Unity 开发工具
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|---------------------------|------------------------------|
欢迎来到 MCP for Unity 开发环境!此目录包含简化 MCP for Unity 核心开发的工具和实用程序。
## 🚀 可用开发功能
### ✅ 开发部署脚本
用于 MCP for Unity 核心更改的快速部署和测试工具。
### 🔄 即将推出
- **开发模式切换**:内置 Unity 编辑器开发功能
- **热重载系统**:无需重启 Unity 的实时代码更新
- **插件开发工具包**:用于创建自定义 MCP for Unity 扩展的工具
- **自动化测试套件**:用于贡献的综合测试框架
- **调试面板**:高级调试和监控工具
---
## 快速切换 MCP 包源
从 unity-mcp 仓库运行,而不是从游戏的根目录。使用 `mcp_source.py` 在不同的 MCP for Unity 包源之间快速切换:
**用法:**
```bash
python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3]
```
**选项:**
- **1** 上游主分支 (CoplayDev/unity-mcp)
- **2** 远程当前分支 (origin + branch)
- **3** 本地工作区 (file: UnityMcpBridge)
切换后,打开包管理器并刷新以重新解析包。
## 开发部署脚本
这些部署脚本帮助您快速测试 MCP for Unity 核心代码的更改。
## 脚本
### `deploy-dev.bat`
将您的开发代码部署到实际安装位置进行测试。
**作用:**
1. 将原始文件备份到带时间戳的文件夹
2. 将 Unity Bridge 代码复制到 Unity 的包缓存
3. 将 Python 服务器代码复制到 MCP 安装文件夹
**用法:**
1. 运行 `deploy-dev.bat`
2. 输入 Unity 包缓存路径(提供示例)
3. 输入服务器路径(或使用默认:`%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`
4. 输入备份位置(或使用默认:`%USERPROFILE%\Desktop\unity-mcp-backup`
**注意:** 开发部署跳过 `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`;减少变动并避免复制虚拟环境。
### `restore-dev.bat`
从备份恢复原始文件。
**作用:**
1. 列出可用的带时间戳的备份
2. 允许您选择要恢复的备份
3. 恢复 Unity Bridge 和 Python 服务器文件
### `prune_tool_results.py`
将对话 JSON 中的大型 `tool_result` 块压缩为简洁的单行摘要。
**用法:**
```bash
python3 prune_tool_results.py < reports/claude-execution-output.json > reports/claude-execution-output.pruned.json
```
脚本从 `stdin` 读取对话并将修剪版本写入 `stdout`,使日志更容易检查或存档。
这些默认设置在不影响基本信息的情况下大幅减少了令牌使用量。
## 查找 Unity 包缓存路径
Unity 将 Git 包存储在版本或哈希文件夹下。期望类似于:
```
X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@<version-or-hash>
```
示例(哈希):
```
X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e
```
可靠找到它:
1. 打开 Unity 包管理器
2. 选择"MCP for Unity"包
3. 右键单击包并选择"在资源管理器中显示"
4. 这将打开 Unity 为您的项目使用的确切缓存文件夹
注意在最新版本中Python 服务器源代码也打包在包内的 `UnityMcpServer~/src` 下。这对于本地测试或将 MCP 客户端直接指向打包服务器很方便。
## MCP Bridge 压力测试
按需压力测试实用程序通过多个并发客户端测试 MCP bridge同时通过立即脚本编辑触发真实脚本重载无需菜单调用
### 脚本
- `tools/stress_mcp.py`
### 作用
- 对 Unity MCP bridge 启动 N 个 TCP 客户端(默认端口从 `~/.unity-mcp/unity-mcp-status-*.json` 自动发现)。
- 发送轻量级帧 `ping` 保活以维持并发。
- 并行地,使用 `manage_script.apply_text_edits` 向目标 C# 文件追加唯一标记注释:
- `options.refresh = "immediate"` 立即强制导入/编译(触发域重载),以及
- 从当前文件内容计算的 `precondition_sha256` 以避免漂移。
- 使用 EOF 插入避免头部/`using` 保护编辑。
### 用法(本地)
```bash
# 推荐:使用测试项目中包含的大型脚本
python3 tools/stress_mcp.py \
--duration 60 \
--clients 8 \
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
```
标志:
- `--project` Unity 项目路径(默认自动检测到包含的测试项目)
- `--unity-file` 要编辑的 C# 文件(默认为长测试脚本)
- `--clients` 并发客户端数量(默认 10
- `--duration` 运行秒数(默认 60
### 预期结果
- 重载过程中 Unity 编辑器不崩溃
- 每次应用编辑后立即重载(无 `Assets/Refresh` 菜单调用)
- 域重载期间可能发生一些暂时断开连接或少数失败调用;工具会重试并继续
- 最后打印 JSON 摘要,例如:
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`
### 注意事项和故障排除
- 立即 vs 防抖:
- 工具设置 `options.refresh = "immediate"` 使更改立即编译。如果您只需要变动(不需要每次编辑确认),切换到防抖以减少重载中失败。
- 需要前置条件:
- `apply_text_edits` 在较大文件上需要 `precondition_sha256`。工具首先读取文件以计算 SHA。
- 编辑位置:
- 为避免头部保护或复杂范围,工具在每个周期的 EOF 处追加单行标记。
- 读取 API
- bridge 当前支持 `manage_script.read` 进行文件读取。您可能看到弃用警告;对于此内部工具无害。
- 暂时失败:
- 偶尔的 `apply_errors` 通常表示连接在回复过程中重载。编辑通常仍会应用;循环在下次迭代时继续。
### CI 指导
- 由于 Unity/编辑器要求和运行时变化,将此排除在默认 PR CI 之外。
- 可选择在具有 Unity 功能的运行器上作为手动工作流或夜间作业运行。
## CI 测试工作流GitHub Actions
我们提供 CI 作业来对 Unity 测试项目运行自然语言编辑套件。它启动无头 Unity 容器并通过 MCP bridge 连接。要从您的 fork 运行,您需要以下 GitHub "secrets"`ANTHROPIC_API_KEY` 和 Unity 凭据(通常是 `UNITY_EMAIL` + `UNITY_PASSWORD``UNITY_LICENSE` / `UNITY_SERIAL`)。这些在日志中被编辑所以永远不可见。
***运行方法***
- 触发:在仓库的 GitHub "Actions" 中,触发 `workflow dispatch``Claude NL/T Full Suite (Unity live)`)。
- 镜像:`UNITY_IMAGE`UnityCI按标签拉取作业在运行时解析摘要。日志已清理。
- 执行:单次通过,立即按测试片段发射(严格的每个文件单个 `<testcase>`)。如果任何片段是裸 ID占位符保护会快速失败。暂存`reports/_staging`)被提升到 `reports/` 以减少部分写入。
- 报告JUnit 在 `reports/junit-nl-suite.xml`Markdown 在 `reports/junit-nl-suite.md`
- 发布JUnit 规范化为 `reports/junit-for-actions.xml` 并发布;工件上传 `reports/` 下的所有文件。
### 测试目标脚本
- 仓库包含一个长的独立 C# 脚本,用于练习较大的编辑和窗口:
- `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs`
在本地和 CI 中使用此文件来验证多编辑批次、锚插入和大型脚本上的窗口读取。
### 调整测试/提示
- 编辑 `.claude/prompts/nl-unity-suite-t.md` 来修改 NL/T 步骤。遵循约定:在 `reports/<TESTID>_results.xml` 下为每个测试发射一个 XML 片段,每个包含恰好一个以测试 ID 开头的 `name``<testcase>`。无序言/尾声或代码围栏。
- 保持编辑最小且可逆;包含简洁证据。
### 运行套件
1) 推送您的分支,然后从 Actions 标签手动运行工作流。
2) 作业将报告写入 `reports/` 并上传工件。
3) "JUnit Test Report" 检查总结结果;打开作业摘要查看完整 markdown。
### 查看结果
- 作业摘要GitHub Actions 标签中运行的内联 markdown 摘要
- 检查PR/提交上的"JUnit Test Report"。
- 工件:`claude-nl-suite-artifacts` 包含 XML 和 MD。
### MCP 连接调试
- *在 Unity MCP 窗口(编辑器内)启用调试日志* 以查看连接状态、自动设置结果和 MCP 客户端路径。它显示:
- bridge 启动/端口、客户端连接、严格帧协商和解析的帧
- 自动配置路径检测Windows/macOS/Linux、uv/claude 解析和显示的错误
- 在 CI 中,如果启动失败,作业会尾随 Unity 日志(序列号/许可证/密码/令牌已编辑)并打印套接字/状态 JSON 诊断。
## 工作流程
1. **进行更改** 到此目录中的源代码
2. **部署** 使用 `deploy-dev.bat`
3. **测试** 在 Unity 中(首先重启 Unity 编辑器)
4. **迭代** - 根据需要重复步骤 1-3
5. **恢复** 完成后使用 `restore-dev.bat` 恢复原始文件
## 故障排除
### 运行 .bat 文件时出现"路径未找到"错误
- 验证 Unity 包缓存路径是否正确
- 检查是否实际安装了 MCP for Unity 包
- 确保通过 MCP 客户端安装了服务器
### "权限被拒绝"错误
- 以管理员身份运行 cmd
- 部署前关闭 Unity 编辑器
- 部署前关闭任何 MCP 客户端
### "备份未找到"错误
- 首先运行 `deploy-dev.bat` 创建初始备份
- 检查备份目录权限
- 验证备份目录路径是否正确
### Windows uv 路径问题
- 在 Windows 上测试 GUI 客户端时,优先选择 WinGet Links `uv.exe`;如果存在多个 `uv.exe`,使用"Choose `uv` Install Location"来固定 Links shim。

View File

@ -1,5 +1,8 @@
# MCP for Unity Development Tools
| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) |
|---------------------------|------------------------------|
Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development.
## 🚀 Available Development Features

353
README-zh.md Normal file
View File

@ -0,0 +1,353 @@
<img width="676" height="380" alt="MCP for Unity" src="https://github.com/user-attachments/assets/b712e41d-273c-48b2-9041-82bd17ace267" />
| [English](README.md) | [简体中文](README-zh.md) |
|----------------------|---------------------------------|
#### 由 [Coplay](https://www.coplay.dev/?ref=unity-mcp) 荣誉赞助和维护 -- Unity 最好的 AI 助手。[在此阅读背景故事。](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces)
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive)
[![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction)
![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/CoplayDev/unity-mcp)
[![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT)
[![](https://img.shields.io/badge/Sponsor-Coplay-red.svg 'Coplay')](https://www.coplay.dev/?ref=unity-mcp)
**使用大语言模型创建您的 Unity 应用!**
MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor通过本地 **MCP模型上下文协议客户端** 直接与您的 Unity 编辑器交互。为您的大语言模型提供管理资源、控制场景、编辑脚本和自动化 Unity 任务的工具。
---
### 💬 加入我们的 [Discord](https://discord.gg/y4p8KfzrN4)
**获得帮助、分享想法,与其他 MCP for Unity 开发者协作!**
---
## 主要功能 🚀
* **🗣️ 自然语言操控:** 指示您的大语言模型执行 Unity 任务。
* **🛠️ 强大工具:** 管理资源、场景、材质、脚本和编辑器功能。
* **🤖 自动化:** 自动化重复的 Unity 工作流程。
* **🧩 可扩展:** 设计为与各种 MCP 客户端协作。
<details open>
<summary><strong> 可用工具 </strong></summary>
您的大语言模型可以使用以下功能:
* `read_console`: 获取控制台消息或清除控制台。
* `manage_script`: 管理 C# 脚本(创建、读取、更新、删除)。
* `manage_editor`: 控制和查询编辑器的状态和设置。
* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。
* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。
* `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。
* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。
* `manage_menu_item`: 列出 Unity 编辑器菜单项;检查其存在性或执行它们(例如,执行"File/Save Project")。
* `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。
* `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。
* `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。
</details>
---
## 工作原理
MCP for Unity 使用两个组件连接您的工具:
1. **MCP for Unity Bridge** 在编辑器内运行的 Unity 包。(通过包管理器安装)。
2. **MCP for Unity Server** 本地运行的 Python 服务器,在 Unity Bridge 和您的 MCP 客户端之间进行通信。(首次运行时由包自动安装或通过自动设置;手动设置作为备选方案)。
<img width="562" height="121" alt="image" src="https://github.com/user-attachments/assets/9abf9c66-70d1-4b82-9587-658e0d45dc3e" />
---
## 安装 ⚙️
### 前置要求
* **Python** 版本 3.12 或更新。[下载 Python](https://www.python.org/downloads/)
* **Unity Hub 和编辑器:** 版本 2021.3 LTS 或更新。[下载 Unity](https://unity.com/download)
* **uvPython 工具链管理器):**
```bash
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
winget install --id=astral-sh.uv -e
# 文档: https://docs.astral.sh/uv/getting-started/installation/
```
* **MCP 客户端:** [Claude Desktop](https://claude.ai/download) | [Claude Code](https://github.com/anthropics/claude-code) | [Cursor](https://www.cursor.com/en/downloads) | [Visual Studio Code Copilot](https://code.visualstudio.com/docs/copilot/overview) | [Windsurf](https://windsurf.com) | 其他客户端可通过手动配置使用
* <details> <summary><strong>[可选] Roslyn 用于高级脚本验证</strong></summary>
对于捕获未定义命名空间、类型和方法的**严格**验证级别:
**方法 1Unity 的 NuGet推荐**
1. 安装 [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)
2. 前往 `Window > NuGet Package Manager`
3. 搜索 `Microsoft.CodeAnalysis.CSharp`,选择版本 3.11.0 并安装包
5. 前往 `Player Settings > Scripting Define Symbols`
6. 添加 `USE_ROSLYN`
7. 重启 Unity
**方法 2手动 DLL 安装**
1. 从 [NuGet](https://www.nuget.org/packages/Microsoft.CodeAnalysis.CSharp/) 下载 Microsoft.CodeAnalysis.CSharp.dll 和依赖项
2. 将 DLL 放置在 `Assets/Plugins/` 文件夹中
3. 确保 .NET 兼容性设置正确
4. 将 `USE_ROSLYN` 添加到脚本定义符号
5. 重启 Unity
**注意:** 没有 Roslyn 时脚本验证会回退到基本结构检查。Roslyn 启用完整的 C# 编译器诊断和精确错误报告。</details>
---
### 🌟 步骤 1安装 Unity 包
#### 通过 Git URL 安装
1. 打开您的 Unity 项目。
2. 前往 `Window > Package Manager`
3. 点击 `+` -> `Add package from git URL...`
4. 输入:
```
https://github.com/CoplayDev/unity-mcp.git?path=/UnityMcpBridge
```
5. 点击 `Add`
6. MCP 服务器在首次运行时或通过自动设置由包自动安装。如果失败,请使用手动配置(如下)。
#### 通过 OpenUPM 安装
1. 安装 [OpenUPM CLI](https://openupm.com/docs/getting-started-cli.html)
2. 打开终端PowerShell、Terminal 等)并导航到您的 Unity 项目目录
3. 运行 `openupm add com.coplaydev.unity-mcp`
**注意:** 如果您在 Coplay 维护之前安装了 MCP 服务器,您需要在重新安装新版本之前卸载旧包。
### 🛠️ 步骤 2配置您的 MCP 客户端
将您的 MCP 客户端Claude、Cursor 等)连接到步骤 1自动设置的 Python 服务器或通过手动配置(如下)。
<img width="648" height="599" alt="MCPForUnity-Readme-Image" src="https://github.com/user-attachments/assets/b4a725da-5c43-4bd6-80d6-ee2e3cca9596" />
**选项 A自动设置推荐用于 Claude/Cursor/VSC Copilot**
1. 在 Unity 中,前往 `Window > MCP for Unity`
2. 点击 `Auto-Setup`
3. 寻找绿色状态指示器 🟢 和"Connected ✓"。*(这会尝试自动修改 MCP 客户端的配置文件)。*
<details><summary><strong>客户端特定故障排除</strong></summary>
- **VSCode**:使用 `Code/User/mcp.json` 和顶级 `servers.unityMCP` 以及 `"type": "stdio"`。在 Windows 上MCP for Unity 写入绝对路径 `uv.exe`(优先选择 WinGet Links shim以避免 PATH 问题。
- **Cursor / Windsurf** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf):如果缺少 `uv`MCP for Unity 窗口会显示"uv Not Found"和快速 [HELP] 链接以及"Choose `uv` Install Location"按钮。
- **Claude Code** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code):如果找不到 `claude`,窗口会显示"Claude Not Found"和 [HELP] 以及"Choose Claude Location"按钮。注销现在会立即更新 UI。</details>
**选项 B手动配置**
如果自动设置失败或您使用不同的客户端:
1. **找到您的 MCP 客户端配置文件。**(查看客户端文档)。
* *Claude 示例macOS* `~/Library/Application Support/Claude/claude_desktop_config.json`
* *Claude 示例Windows* `%APPDATA%\Claude\claude_desktop_config.json`
2. **编辑文件** 以添加/更新 `mcpServers` 部分,使用步骤 1 中的*精确*路径。
<details>
<summary><strong>点击查看客户端特定的 JSON 配置片段...</strong></summary>
---
**Claude Code**
如果您正在使用 Claude Code您可以使用以下命令注册 MCP 服务器:
🚨**确保从您的 Unity 项目主目录运行这些命令**🚨
**macOS**
```bash
claude mcp add UnityMCP -- uv --directory /Users/USERNAME/Library/AppSupport/UnityMCP/UnityMcpServer/src run server.py
```
**Windows**
```bash
claude mcp add UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microsoft/WinGet/Links/uv.exe" --directory "C:/Users/USERNAME/AppData/Local/UnityMCP/UnityMcpServer/src" run server.py
```
**VSCode所有操作系统**
```json
{
"servers": {
"unityMCP": {
"command": "uv",
"args": ["--directory","<ABSOLUTE_PATH_TO>/UnityMcpServer/src","run","server.py"],
"type": "stdio"
}
}
}
```
在 Windows 上,将 `command` 设置为绝对 shim例如 `C:\\Users\\YOU\\AppData\\Local\\Microsoft\\WinGet\\Links\\uv.exe`
**Windows**
```json
{
"mcpServers": {
"UnityMCP": {
"command": "uv",
"args": [
"run",
"--directory",
"C:\\Users\\YOUR_USERNAME\\AppData\\Local\\UnityMCP\\UnityMcpServer\\src",
"server.py"
]
}
// ... 其他服务器可能在这里 ...
}
}
```
(记得替换 YOUR_USERNAME 并使用双反斜杠 \\
**macOS**
```json
{
"mcpServers": {
"UnityMCP": {
"command": "uv",
"args": [
"run",
"--directory",
"/Users/YOUR_USERNAME/Library/AppSupport/UnityMCP/UnityMcpServer/src",
"server.py"
]
}
// ... 其他服务器可能在这里 ...
}
}
```
(替换 YOUR_USERNAME。注意AppSupport 是"Application Support"的符号链接,以避免引号问题)
**Linux**
```json
{
"mcpServers": {
"UnityMCP": {
"command": "uv",
"args": [
"run",
"--directory",
"/home/YOUR_USERNAME/.local/share/UnityMCP/UnityMcpServer/src",
"server.py"
]
}
// ... 其他服务器可能在这里 ...
}
}
```
(替换 YOUR_USERNAME
</details>
---
## 使用方法 ▶️
1. **打开您的 Unity 项目。** MCP for Unity 包应该自动连接。通过 Window > MCP for Unity 检查状态。
2. **启动您的 MCP 客户端**Claude、Cursor 等)。它应该使用安装步骤 2 中的配置自动启动 MCP for Unity 服务器Python
3. **交互!** Unity 工具现在应该在您的 MCP 客户端中可用。
示例提示:`创建一个 3D 玩家控制器``创建一个 3D 井字游戏``创建一个酷炫的着色器并应用到立方体上`。
---
## 开发和贡献 🛠️
### 开发者
如果您正在为 MCP for Unity 做贡献或想要测试核心更改,我们有开发工具来简化您的工作流程:
- **开发部署脚本**:快速部署和测试您对 MCP for Unity Bridge 和 Python 服务器的更改
- **自动备份系统**:具有简单回滚功能的安全测试
- **热重载工作流程**:核心开发的快速迭代周期
📖 **查看 [README-DEV.md](README-DEV.md)** 获取完整的开发设置和工作流程文档。
### 贡献 🤝
帮助改进 MCP for Unity
1. **Fork** 主仓库。
2. **创建分支**`feature/your-idea` 或 `bugfix/your-fix`)。
3. **进行更改。**
4. **提交**feat: Add cool new feature
5. **推送** 您的分支。
6. **对主分支开启拉取请求**。
---
## 📊 遥测和隐私
Unity MCP 包含**注重隐私的匿名遥测**来帮助我们改进产品。我们收集使用分析和性能数据,但**绝不**收集您的代码、项目名称或个人信息。
- **🔒 匿名**:仅随机 UUID无个人数据
- **🚫 轻松退出**:设置 `DISABLE_TELEMETRY=true` 环境变量
- **📖 透明**:查看 [TELEMETRY.md](TELEMETRY.md) 获取完整详情
您的隐私对我们很重要。所有遥测都是可选的,旨在尊重您的工作流程。
---
## 故障排除 ❓
<details>
<summary><strong>点击查看常见问题和修复方法...</strong></summary>
- **Unity Bridge 未运行/连接:**
- 确保 Unity 编辑器已打开。
- 检查状态窗口Window > MCP for Unity。
- 重启 Unity。
- **MCP 客户端未连接/服务器未启动:**
- **验证服务器路径:** 双重检查您的 MCP 客户端 JSON 配置中的 --directory 路径。它必须完全匹配安装位置:
- **Windows** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src`
- **macOS** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src`
- **Linux** `~/.local/share/UnityMCP/UnityMcpServer\src`
- **验证 uv** 确保 `uv` 已安装并正常工作(`uv --version`)。
- **手动运行:** 尝试直接从终端运行服务器以查看错误:
```bash
cd /path/to/your/UnityMCP/UnityMcpServer/src
uv run server.py
```
- **自动配置失败:**
- 使用手动配置步骤。自动配置可能缺乏写入 MCP 客户端配置文件的权限。
</details>
仍然卡住?[开启问题](https://github.com/CoplayDev/unity-mcp/issues) 或 [加入 Discord](https://discord.gg/y4p8KfzrN4)
---
## 许可证 📜
MIT 许可证。查看 [LICENSE](LICENSE) 文件。
---
## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=CoplayDev/unity-mcp&type=Date)](https://www.star-history.com/#CoplayDev/unity-mcp&Date)
## 赞助
<p align="center">
<a href="https://www.coplay.dev/?ref=unity-mcp" target="_blank" rel="noopener noreferrer">
<img src="logo.png" alt="Coplay Logo" width="100%">
</a>
</p>

View File

@ -1,5 +1,8 @@
<img width="676" height="380" alt="MCP for Unity" src="https://github.com/user-attachments/assets/b712e41d-273c-48b2-9041-82bd17ace267" />
| [English](README.md) | [简体中文](README-zh.md) |
|----------------------|---------------------------------|
#### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp) -- the best AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces)
[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4)
@ -73,7 +76,7 @@ MCP for Unity connects your tools using two components:
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
winget install Astral.Sh.Uv
winget install --id=astral-sh.uv -e
# Docs: https://docs.astral.sh/uv/getting-started/installation/
```
@ -87,7 +90,8 @@ MCP for Unity connects your tools using two components:
**Method 1: NuGet for Unity (Recommended)**
1. Install [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)
2. Go to `Window > NuGet Package Manager`
3. Search for `Microsoft.CodeAnalysis.CSharp` and install the package
3. Search for `Microsoft.CodeAnalysis`, select version 4.14.0, and install the package
4. Also install package `SQLitePCLRaw.core` and `SQLitePCLRaw.bundle_e_sqlite3`.
5. Go to `Player Settings > Scripting Define Symbols`
6. Add `USE_ROSLYN`
7. Restart Unity

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d539787bf8f6a426e94bfffb32a36d4f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,103 @@
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
public class CodexConfigHelperTests
{
[Test]
public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should detect server definition");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [",
" \"run\",",
" \"--directory\",",
" \"/abs/path\",",
" \"server.py\",",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [",
" \"run\", # launch command",
" \"--directory\",",
" \"/abs/path\",",
" \"server.py\"",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should tolerate comments within the array block");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_HeaderWithComment_StillDetected()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP] # annotated header",
"command = \"uv\"",
"args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should recognize section headers even with inline comments");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = 'uv'",
"args = ['run', '--directory', '/Users/O''Connor/codex', 'server.py']"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/Users/O'Connor/codex", "server.py" }, args);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 013424dea29744a98b3dc01618f4e95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -8,18 +8,29 @@ namespace MCPForUnityTests.Editor.Tools
public class CommandRegistryTests
{
[Test]
public void GetHandler_ReturnsNull_ForUnknownCommand()
public void GetHandler_ThrowException_ForUnknownCommand()
{
var unknown = "HandleDoesNotExist";
try
{
var handler = CommandRegistry.GetHandler(unknown);
Assert.IsNull(handler, "Expected null handler for unknown command name.");
Assert.Fail("Should throw InvalidOperation for unknown handler.");
}
catch (InvalidOperationException)
{
}
catch
{
Assert.Fail("Should throw InvalidOperation for unknown handler.");
}
}
[Test]
public void GetHandler_ReturnsManageGameObjectHandler()
{
var handler = CommandRegistry.GetHandler("HandleManageGameObject");
Assert.IsNotNull(handler, "Expected a handler for HandleManageGameObject.");
var handler = CommandRegistry.GetHandler("manage_gameobject");
Assert.IsNotNull(handler, "Expected a handler for manage_gameobject.");
var methodInfo = handler.Method;
Assert.AreEqual("HandleCommand", methodInfo.Name, "Handler method name should be HandleCommand.");

View File

@ -0,0 +1,255 @@
using System.IO;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Editor.Tools.Prefabs;
using MCPForUnity.Editor.Tools;
namespace MCPForUnityTests.Editor.Tools
{
public class ManagePrefabsTests
{
private const string TempDirectory = "Assets/Temp/ManagePrefabsTests";
[SetUp]
public void SetUp()
{
StageUtility.GoToMainStage();
EnsureTempDirectoryExists();
}
[TearDown]
public void TearDown()
{
StageUtility.GoToMainStage();
}
[OneTimeTearDown]
public void CleanupAll()
{
StageUtility.GoToMainStage();
if (AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.DeleteAsset(TempDirectory);
}
}
[Test]
public void OpenStage_OpensPrefabInIsolation()
{
string prefabPath = CreateTestPrefab("OpenStageCube");
try
{
var openParams = new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
};
var openResult = ToJObject(ManagePrefabs.HandleCommand(openParams));
Assert.IsTrue(openResult.Value<bool>("success"), "open_stage should succeed for a valid prefab.");
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
Assert.IsNotNull(stage, "Prefab stage should be open after open_stage.");
Assert.AreEqual(prefabPath, stage.assetPath, "Opened stage should match prefab path.");
var stageInfo = ToJObject(ManageEditor.HandleCommand(new JObject { ["action"] = "get_prefab_stage" }));
Assert.IsTrue(stageInfo.Value<bool>("success"), "get_prefab_stage should succeed when stage is open.");
var data = stageInfo["data"] as JObject;
Assert.IsNotNull(data, "Stage info should include data payload.");
Assert.IsTrue(data.Value<bool>("isOpen"));
Assert.AreEqual(prefabPath, data.Value<string>("assetPath"));
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void CloseStage_ReturnsSuccess_WhenNoStageOpen()
{
StageUtility.GoToMainStage();
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage"
}));
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed even if no stage is open.");
}
[Test]
public void CloseStage_ClosesOpenPrefabStage()
{
string prefabPath = CreateTestPrefab("CloseStageCube");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
var closeResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "close_stage"
}));
Assert.IsTrue(closeResult.Value<bool>("success"), "close_stage should succeed when stage is open.");
Assert.IsNull(PrefabStageUtility.GetCurrentPrefabStage(), "Prefab stage should be closed after close_stage.");
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void SaveOpenStage_SavesDirtyChanges()
{
string prefabPath = CreateTestPrefab("SaveStageCube");
try
{
ManagePrefabs.HandleCommand(new JObject
{
["action"] = "open_stage",
["prefabPath"] = prefabPath
});
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
Assert.IsNotNull(stage, "Stage should be open before modifying.");
stage.prefabContentsRoot.transform.localScale = new Vector3(2f, 2f, 2f);
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage"
}));
Assert.IsTrue(saveResult.Value<bool>("success"), "save_open_stage should succeed when stage is open.");
Assert.IsFalse(stage.scene.isDirty, "Stage scene should not be dirty after saving.");
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.AreEqual(new Vector3(2f, 2f, 2f), reloaded.transform.localScale, "Saved prefab asset should include changes from open stage.");
}
finally
{
StageUtility.GoToMainStage();
AssetDatabase.DeleteAsset(prefabPath);
}
}
[Test]
public void SaveOpenStage_ReturnsError_WhenNoStageOpen()
{
StageUtility.GoToMainStage();
var saveResult = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "save_open_stage"
}));
Assert.IsFalse(saveResult.Value<bool>("success"), "save_open_stage should fail when no stage is open.");
}
[Test]
public void CreateFromGameObject_CreatesPrefabAndLinksInstance()
{
EnsureTempDirectoryExists();
StageUtility.GoToMainStage();
string prefabPath = Path.Combine(TempDirectory, "SceneObjectSaved.prefab").Replace('\\', '/');
GameObject sceneObject = new GameObject("ScenePrefabSource");
try
{
var result = ToJObject(ManagePrefabs.HandleCommand(new JObject
{
["action"] = "create_from_gameobject",
["target"] = sceneObject.name,
["prefabPath"] = prefabPath
}));
Assert.IsTrue(result.Value<bool>("success"), "create_from_gameobject should succeed for a valid scene object.");
var data = result["data"] as JObject;
Assert.IsNotNull(data, "Response data should include prefab information.");
string savedPath = data.Value<string>("prefabPath");
Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path.");
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(savedPath);
Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path.");
int instanceId = data.Value<int>("instanceId");
var linkedInstance = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
Assert.IsNotNull(linkedInstance, "Linked instance should resolve from instanceId.");
Assert.AreEqual(savedPath, PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(linkedInstance), "Instance should be connected to the new prefab.");
sceneObject = linkedInstance;
}
finally
{
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(prefabPath) != null)
{
AssetDatabase.DeleteAsset(prefabPath);
}
if (sceneObject != null)
{
if (PrefabUtility.IsPartOfPrefabInstance(sceneObject))
{
PrefabUtility.UnpackPrefabInstance(
sceneObject,
PrefabUnpackMode.Completely,
InteractionMode.AutomatedAction
);
}
UnityEngine.Object.DestroyImmediate(sceneObject, true);
}
}
}
private static string CreateTestPrefab(string name)
{
EnsureTempDirectoryExists();
GameObject temp = GameObject.CreatePrimitive(PrimitiveType.Cube);
temp.name = name;
string path = Path.Combine(TempDirectory, name + ".prefab").Replace('\\', '/');
PrefabUtility.SaveAsPrefabAsset(temp, path, out bool success);
UnityEngine.Object.DestroyImmediate(temp);
Assert.IsTrue(success, "PrefabUtility.SaveAsPrefabAsset should succeed for test prefab.");
return path;
}
private static void EnsureTempDirectoryExists()
{
if (!AssetDatabase.IsValidFolder("Assets/Temp"))
{
AssetDatabase.CreateFolder("Assets", "Temp");
}
if (!AssetDatabase.IsValidFolder(TempDirectory))
{
AssetDatabase.CreateFolder("Assets/Temp", "ManagePrefabsTests");
}
}
private static JObject ToJObject(object result)
{
return result as JObject ?? JObject.FromObject(result);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e7a7e542325421ba6de4992ddb3f5db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -46,6 +46,8 @@ namespace MCPForUnityTests.Editor.Windows
EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir);
// Ensure no lock is enabled
EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false);
}
[TearDown]
@ -54,6 +56,7 @@ namespace MCPForUnityTests.Editor.Windows
// Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey("MCPForUnity.ServerSrc");
EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig");
EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled");
// Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }

View File

@ -159,6 +159,28 @@ namespace MCPForUnity.Editor.Data
mcpType = McpTypes.Kiro,
configStatus = "Not Configured",
},
// 4) Codex CLI
new()
{
name = "Codex CLI",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
mcpType = McpTypes.Codex,
configStatus = "Not Configured",
},
};
// Initialize status enums after construction
@ -174,4 +196,3 @@ namespace MCPForUnity.Editor.Data
}
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c11944bcfb9ec4576bab52874b7df584
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

2138
UnityMcpBridge/Editor/External/Tommy.cs vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea652131dcdaa44ca8cb35cd1191be3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,29 @@
using System;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Provides common utility methods for working with Unity asset paths.
/// </summary>
public static class AssetPathUtility
{
/// <summary>
/// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/".
/// </summary>
public static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
path = path.Replace('\\', '/');
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using MCPForUnity.External.Tommy;
using Newtonsoft.Json;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Codex CLI specific configuration helpers. Handles TOML snippet
/// generation and lightweight parsing so Codex can join the auto-setup
/// flow alongside JSON-based clients.
/// </summary>
public static class CodexConfigHelper
{
public static bool IsCodexConfigured(string pythonDir)
{
try
{
string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(basePath)) return false;
string configPath = Path.Combine(basePath, ".codex", "config.toml");
if (!File.Exists(configPath)) return false;
string toml = File.ReadAllText(configPath);
if (!TryParseCodexServer(toml, out _, out var args)) return false;
string dir = McpConfigFileHelper.ExtractDirectoryArg(args);
if (string.IsNullOrEmpty(dir)) return false;
return McpConfigFileHelper.PathsEqual(dir, pythonDir);
}
catch
{
return false;
}
}
public static string BuildCodexServerBlock(string uvPath, string serverSrc)
{
string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" });
return $"[mcp_servers.unityMCP]{Environment.NewLine}" +
$"command = \"{EscapeTomlString(uvPath)}\"{Environment.NewLine}" +
$"args = {argsArray}";
}
public static string UpsertCodexServerBlock(string existingToml, string newBlock)
{
if (string.IsNullOrWhiteSpace(existingToml))
{
return newBlock.TrimEnd() + Environment.NewLine;
}
StringBuilder sb = new StringBuilder();
using StringReader reader = new StringReader(existingToml);
string line;
bool inTarget = false;
bool replaced = false;
while ((line = reader.ReadLine()) != null)
{
string trimmed = line.Trim();
bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[[");
if (isSection)
{
bool isTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase);
if (isTarget)
{
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
replaced = true;
}
inTarget = true;
continue;
}
if (inTarget)
{
inTarget = false;
}
}
if (inTarget)
{
continue;
}
sb.AppendLine(line);
}
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
}
return sb.ToString().TrimEnd() + Environment.NewLine;
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args)
{
command = null;
args = null;
if (string.IsNullOrWhiteSpace(toml)) return false;
try
{
using var reader = new StringReader(toml);
TomlTable root = TOML.Parse(reader);
if (root == null) return false;
if (!TryGetTable(root, "mcp_servers", out var servers)
&& !TryGetTable(root, "mcpServers", out servers))
{
return false;
}
if (!TryGetTable(servers, "unityMCP", out var unity))
{
return false;
}
command = GetTomlString(unity, "command");
args = GetTomlStringArray(unity, "args");
return !string.IsNullOrEmpty(command) && args != null;
}
catch (TomlParseException)
{
return false;
}
catch (TomlSyntaxException)
{
return false;
}
catch (FormatException)
{
return false;
}
}
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
{
table = null;
if (parent == null) return false;
if (parent.TryGetNode(key, out var node))
{
if (node is TomlTable tbl)
{
table = tbl;
return true;
}
if (node is TomlArray array)
{
var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault();
if (firstTable != null)
{
table = firstTable;
return true;
}
}
}
return false;
}
private static string GetTomlString(TomlTable table, string key)
{
if (table != null && table.TryGetNode(key, out var node))
{
if (node is TomlString str) return str.Value;
if (node.HasValue) return node.ToString();
}
return null;
}
private static string[] GetTomlStringArray(TomlTable table, string key)
{
if (table == null) return null;
if (!table.TryGetNode(key, out var node)) return null;
if (node is TomlArray array)
{
List<string> values = new List<string>();
foreach (TomlNode element in array.Children)
{
if (element is TomlString str)
{
values.Add(str.Value);
}
else if (element.HasValue)
{
values.Add(element.ToString());
}
}
return values.Count > 0 ? values.ToArray() : Array.Empty<string>();
}
if (node is TomlString single)
{
return new[] { single.Value };
}
return null;
}
private static string FormatTomlStringArray(IEnumerable<string> values)
{
if (values == null) return "[]";
StringBuilder sb = new StringBuilder();
sb.Append('[');
bool first = true;
foreach (string value in values)
{
if (!first)
{
sb.Append(", ");
}
sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"');
first = false;
}
sb.Append(']');
return sb.ToString();
}
private static string EscapeTomlString(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e68082ffc0b4cd39d3747673a4cc22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,187 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Shared helpers for reading and writing MCP client configuration files.
/// Consolidates file atomics and server directory resolution so the editor
/// window can focus on UI concerns only.
/// </summary>
public static class McpConfigFileHelper
{
public static string ExtractDirectoryArg(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
public static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = Path.GetFullPath(a.Trim());
string nb = Path.GetFullPath(b.Trim());
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(na, nb, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Resolves the server directory to use for MCP tools, preferring
/// existing config values and falling back to installed/embedded copies.
/// </summary>
public static string ResolveServerDirectory(string pythonDir, string[] existingArgs)
{
string serverSrc = ExtractDirectoryArg(existingArgs);
bool serverValid = !string.IsNullOrEmpty(serverSrc)
&& File.Exists(Path.Combine(serverSrc, "server.py"));
if (!serverValid)
{
if (!string.IsNullOrEmpty(pythonDir)
&& File.Exists(Path.Combine(pythonDir, "server.py")))
{
serverSrc = pythonDir;
}
else
{
serverSrc = ResolveServerSource();
}
}
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc))
{
string norm = serverSrc.Replace('\\', '/');
int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal);
if (idx >= 0)
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
string suffix = norm.Substring(idx + "/.local/share/".Length);
serverSrc = Path.Combine(home, "Library", "Application Support", suffix);
}
}
}
catch
{
// Ignore failures and fall back to the original path.
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& !string.IsNullOrEmpty(serverSrc)
&& serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0
&& !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
{
serverSrc = ServerInstaller.GetServerPath();
}
return serverSrc;
}
public static void WriteAtomicFile(string path, string contents)
{
string tmp = path + ".tmp";
string backup = path + ".backup";
bool writeDone = false;
try
{
File.WriteAllText(tmp, contents, new UTF8Encoding(false));
try
{
File.Replace(tmp, path, backup);
writeDone = true;
}
catch (FileNotFoundException)
{
File.Move(tmp, path);
writeDone = true;
}
catch (PlatformNotSupportedException)
{
if (File.Exists(path))
{
try
{
if (File.Exists(backup)) File.Delete(backup);
}
catch { }
File.Move(path, backup);
}
File.Move(tmp, path);
writeDone = true;
}
}
catch (Exception ex)
{
try
{
if (!writeDone && File.Exists(backup))
{
try { File.Copy(backup, path, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { }
}
}
public static string ResolveServerSource()
{
try
{
string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty);
if (!string.IsNullOrEmpty(remembered)
&& File.Exists(Path.Combine(remembered, "server.py")))
{
return remembered;
}
ServerInstaller.EnsureServerInstalled();
string installed = ServerInstaller.GetServerPath();
if (File.Exists(Path.Combine(installed, "server.py")))
{
return installed;
}
bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false);
if (useEmbedded
&& ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)
&& File.Exists(Path.Combine(embedded, "server.py")))
{
return embedded;
}
return installed;
}
catch
{
return ServerInstaller.GetServerPath();
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f69ad468942b74c0ea24e3e8e5f21a4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -15,6 +15,7 @@ using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor
{
@ -1039,26 +1040,7 @@ namespace MCPForUnity.Editor
// Use JObject for parameters as the new handlers likely expect this
JObject paramsObject = command.@params ?? new JObject();
// Route command based on the new tool structure from the refactor plan
object result = command.type switch
{
// Maps the command type (tool name) to the corresponding handler's static HandleCommand method
// Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters
"manage_script" => ManageScript.HandleCommand(paramsObject),
// Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag)
"manage_scene" => HandleManageScene(paramsObject)
?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"),
"manage_editor" => ManageEditor.HandleCommand(paramsObject),
"manage_gameobject" => ManageGameObject.HandleCommand(paramsObject),
"manage_asset" => ManageAsset.HandleCommand(paramsObject),
"manage_shader" => ManageShader.HandleCommand(paramsObject),
"read_console" => ReadConsole.HandleCommand(paramsObject),
"manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject),
_ => throw new ArgumentException(
$"Unknown or unsupported command type: {command.type}"
),
};
object result = CommandRegistry.GetHandler(command.type)(paramsObject);
// Standard success response format
var response = new { status = "success", result };

View File

@ -4,10 +4,10 @@ namespace MCPForUnity.Editor.Models
{
ClaudeCode,
ClaudeDesktop,
Codex,
Cursor,
Kiro,
VSCode,
Windsurf,
Kiro,
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor.Tools
{
@ -14,14 +15,15 @@ namespace MCPForUnity.Editor.Tools
// to the corresponding static HandleCommand method in the appropriate tool class.
private static readonly Dictionary<string, Func<JObject, object>> _handlers = new()
{
{ "HandleManageScript", ManageScript.HandleCommand },
{ "HandleManageScene", ManageScene.HandleCommand },
{ "HandleManageEditor", ManageEditor.HandleCommand },
{ "HandleManageGameObject", ManageGameObject.HandleCommand },
{ "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleManageMenuItem", ManageMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand},
{ "manage_script", ManageScript.HandleCommand },
{ "manage_scene", ManageScene.HandleCommand },
{ "manage_editor", ManageEditor.HandleCommand },
{ "manage_gameobject", ManageGameObject.HandleCommand },
{ "manage_asset", ManageAsset.HandleCommand },
{ "read_console", ReadConsole.HandleCommand },
{ "manage_menu_item", ManageMenuItem.HandleCommand },
{ "manage_shader", ManageShader.HandleCommand},
{ "manage_prefabs", ManagePrefabs.HandleCommand},
};
/// <summary>
@ -31,17 +33,18 @@ namespace MCPForUnity.Editor.Tools
/// <returns>The command handler function if found, null otherwise.</returns>
public static Func<JObject, object> GetHandler(string commandName)
{
// Use case-insensitive comparison for flexibility, although Python side should be consistent
return _handlers.TryGetValue(commandName, out var handler) ? handler : null;
// Consider adding logging here if a handler is not found
/*
if (_handlers.TryGetValue(commandName, out var handler)) {
return handler;
} else {
UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\");
return null;
if (!_handlers.TryGetValue(commandName, out var handler))
{
throw new InvalidOperationException(
$"Unknown or unsupported command type: {commandName}");
}
*/
return handler;
}
public static void Add(string commandName, Func<JObject, object> handler)
{
_handlers.Add(commandName, handler);
}
}
}

View File

@ -115,7 +115,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for reimport.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -154,7 +154,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(assetType))
return Response.Error("'assetType' is required for create.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
@ -280,7 +280,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for create_folder.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string parentDir = Path.GetDirectoryName(fullPath);
string folderName = Path.GetFileName(fullPath);
@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools
if (properties == null || !properties.HasValues)
return Response.Error("'properties' are required for modify.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -495,7 +495,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for delete.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -526,7 +526,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for duplicate.");
string sourcePath = SanitizeAssetPath(path);
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}");
@ -538,7 +538,7 @@ namespace MCPForUnity.Editor.Tools
}
else
{
destPath = SanitizeAssetPath(destinationPath);
destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (AssetExists(destPath))
return Response.Error($"Asset already exists at destination path: {destPath}");
// Ensure destination directory exists
@ -576,8 +576,8 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(destinationPath))
return Response.Error("'destination' path is required for move/rename.");
string sourcePath = SanitizeAssetPath(path);
string destPath = SanitizeAssetPath(destinationPath);
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}");
@ -642,7 +642,7 @@ namespace MCPForUnity.Editor.Tools
string[] folderScope = null;
if (!string.IsNullOrEmpty(pathScope))
{
folderScope = new string[] { SanitizeAssetPath(pathScope) };
folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) };
if (!AssetDatabase.IsValidFolder(folderScope[0]))
{
// Maybe the user provided a file path instead of a folder?
@ -732,7 +732,7 @@ namespace MCPForUnity.Editor.Tools
{
if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for get_info.");
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -761,7 +761,7 @@ namespace MCPForUnity.Editor.Tools
return Response.Error("'path' is required for get_components.");
// 2. Sanitize and check existence
string fullPath = SanitizeAssetPath(path);
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}");
@ -829,18 +829,6 @@ namespace MCPForUnity.Editor.Tools
/// <summary>
/// Ensures the asset path starts with "Assets/".
/// </summary>
private static string SanitizeAssetPath(string path)
{
if (string.IsNullOrEmpty(path))
return path;
path = path.Replace('\\', '/'); // Normalize separators
if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return "Assets/" + path.TrimStart('/');
}
return path;
}
/// <summary>
/// Checks if an asset exists at the given path (file or folder).
/// </summary>
@ -930,10 +918,12 @@ namespace MCPForUnity.Editor.Tools
);
}
}
} else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
}
else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
string propName = "_Color";
try {
try
{
if (colorArr.Count >= 3)
{
Color newColor = new Color(
@ -949,7 +939,8 @@ namespace MCPForUnity.Editor.Tools
}
}
}
catch (Exception ex) {
catch (Exception ex)
{
Debug.LogWarning(
$"Error parsing color property '{propName}': {ex.Message}"
);
@ -989,7 +980,7 @@ namespace MCPForUnity.Editor.Tools
if (!string.IsNullOrEmpty(texPath))
{
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(
SanitizeAssetPath(texPath)
AssetPathUtility.SanitizeAssetPath(texPath)
);
if (
newTex != null
@ -1217,7 +1208,7 @@ namespace MCPForUnity.Editor.Tools
&& token.Type == JTokenType.String
)
{
string assetPath = SanitizeAssetPath(token.ToString());
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(
assetPath,
targetType
@ -1337,4 +1328,3 @@ namespace MCPForUnity.Editor.Tools
}
}
}

View File

@ -5,8 +5,9 @@ using System.IO;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal; // Required for tag management
using UnityEditor.SceneManagement;
using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools
{
@ -98,6 +99,8 @@ namespace MCPForUnity.Editor.Tools
return GetActiveTool();
case "get_selection":
return GetSelection();
case "get_prefab_stage":
return GetPrefabStageInfo();
case "set_active_tool":
string toolName = @params["toolName"]?.ToString();
if (string.IsNullOrEmpty(toolName))
@ -140,7 +143,7 @@ namespace MCPForUnity.Editor.Tools
default:
return Response.Error(
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
$"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers."
);
}
}
@ -244,6 +247,35 @@ namespace MCPForUnity.Editor.Tools
}
}
private static object GetPrefabStageInfo()
{
try
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Success
("No prefab stage is currently open.", new { isOpen = false });
}
return Response.Success(
"Prefab stage info retrieved.",
new
{
isOpen = true,
assetPath = stage.assetPath,
prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
mode = stage.mode.ToString(),
isDirty = stage.scene.isDirty
}
);
}
catch (Exception e)
{
return Response.Error($"Error getting prefab stage info: {e.Message}");
}
}
private static object GetActiveTool()
{
try
@ -610,4 +642,3 @@ namespace MCPForUnity.Editor.Tools
}
}
}

View File

@ -942,8 +942,10 @@ namespace MCPForUnity.Editor.Tools
if (c == '\'') { inChr = true; esc = false; continue; }
if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; }
if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; }
if (c == '{') brace++; else if (c == '}') brace--;
else if (c == '(') paren++; else if (c == ')') paren--;
if (c == '{') brace++;
else if (c == '}') brace--;
else if (c == '(') paren++;
else if (c == ')') paren--;
else if (c == '[') bracket++; else if (c == ']') bracket--;
// Allow temporary negative balance - will check tolerance at end
}

View File

@ -1,15 +1,9 @@
using System;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems
{
/// <summary>
/// Facade handler for managing Unity Editor menu items.
/// Routes actions to read or execute implementations.
/// </summary>
public static class ManageMenuItem
{
/// <summary>

View File

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems
@ -35,25 +34,14 @@ namespace MCPForUnity.Editor.Tools.MenuItems
return Response.Error($"Execution of menu item '{menuPath}' is blocked for safety reasons.");
}
try
{
// Execute on main thread using delayCall
EditorApplication.delayCall += () =>
{
try
{
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (!executed)
{
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent.");
McpLog.Error($"[MenuItemExecutor] Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
return Response.Error($"Failed to execute menu item '{menuPath}'. It might be invalid, disabled, or context-dependent.");
}
}
catch (Exception delayEx)
{
McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}");
}
};
return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
}
catch (Exception e)

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1bd48a1b7555c46bba168078ce0291cc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,274 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MCPForUnity.Editor.Tools.Prefabs
{
public static class ManagePrefabs
{
private const string SupportedActions = "open_stage, close_stage, save_open_stage, create_from_gameobject";
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return Response.Error("Parameters cannot be null.");
}
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return Response.Error($"Action parameter is required. Valid actions are: {SupportedActions}.");
}
try
{
switch (action)
{
case "open_stage":
return OpenStage(@params);
case "close_stage":
return CloseStage(@params);
case "save_open_stage":
return SaveOpenStage();
case "create_from_gameobject":
return CreatePrefabFromGameObject(@params);
default:
return Response.Error($"Unknown action: '{action}'. Valid actions are: {SupportedActions}.");
}
}
catch (Exception e)
{
McpLog.Error($"[ManagePrefabs] Action '{action}' failed: {e}");
return Response.Error($"Internal error: {e.Message}");
}
}
private static object OpenStage(JObject @params)
{
string prefabPath = @params["prefabPath"]?.ToString();
if (string.IsNullOrEmpty(prefabPath))
{
return Response.Error("'prefabPath' parameter is required for open_stage.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(prefabPath);
GameObject prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(sanitizedPath);
if (prefabAsset == null)
{
return Response.Error($"No prefab asset found at path '{sanitizedPath}'.");
}
string modeValue = @params["mode"]?.ToString();
if (!string.IsNullOrEmpty(modeValue) && !modeValue.Equals(PrefabStage.Mode.InIsolation.ToString(), StringComparison.OrdinalIgnoreCase))
{
return Response.Error("Only PrefabStage mode 'InIsolation' is supported at this time.");
}
PrefabStage stage = PrefabStageUtility.OpenPrefab(sanitizedPath);
if (stage == null)
{
return Response.Error($"Failed to open prefab stage for '{sanitizedPath}'.");
}
return Response.Success($"Opened prefab stage for '{sanitizedPath}'.", SerializeStage(stage));
}
private static object CloseStage(JObject @params)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Success("No prefab stage was open.");
}
bool saveBeforeClose = @params["saveBeforeClose"]?.ToObject<bool>() ?? false;
if (saveBeforeClose && stage.scene.isDirty)
{
SaveStagePrefab(stage);
AssetDatabase.SaveAssets();
}
StageUtility.GoToMainStage();
return Response.Success($"Closed prefab stage for '{stage.assetPath}'.");
}
private static object SaveOpenStage()
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return Response.Error("No prefab stage is currently open.");
}
SaveStagePrefab(stage);
AssetDatabase.SaveAssets();
return Response.Success($"Saved prefab stage for '{stage.assetPath}'.", SerializeStage(stage));
}
private static void SaveStagePrefab(PrefabStage stage)
{
if (stage?.prefabContentsRoot == null)
{
throw new InvalidOperationException("Cannot save prefab stage without a prefab root.");
}
bool saved = PrefabUtility.SaveAsPrefabAsset(stage.prefabContentsRoot, stage.assetPath);
if (!saved)
{
throw new InvalidOperationException($"Failed to save prefab asset at '{stage.assetPath}'.");
}
}
private static object CreatePrefabFromGameObject(JObject @params)
{
string targetName = @params["target"]?.ToString() ?? @params["name"]?.ToString();
if (string.IsNullOrEmpty(targetName))
{
return Response.Error("'target' parameter is required for create_from_gameobject.");
}
bool includeInactive = @params["searchInactive"]?.ToObject<bool>() ?? false;
GameObject sourceObject = FindSceneObjectByName(targetName, includeInactive);
if (sourceObject == null)
{
return Response.Error($"GameObject '{targetName}' not found in the active scene.");
}
if (PrefabUtility.IsPartOfPrefabAsset(sourceObject))
{
return Response.Error(
$"GameObject '{sourceObject.name}' is part of a prefab asset. Open the prefab stage to save changes instead."
);
}
PrefabInstanceStatus status = PrefabUtility.GetPrefabInstanceStatus(sourceObject);
if (status != PrefabInstanceStatus.NotAPrefab)
{
return Response.Error(
$"GameObject '{sourceObject.name}' is already linked to an existing prefab instance."
);
}
string requestedPath = @params["prefabPath"]?.ToString();
if (string.IsNullOrWhiteSpace(requestedPath))
{
return Response.Error("'prefabPath' parameter is required for create_from_gameobject.");
}
string sanitizedPath = AssetPathUtility.SanitizeAssetPath(requestedPath);
if (!sanitizedPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase))
{
sanitizedPath += ".prefab";
}
bool allowOverwrite = @params["allowOverwrite"]?.ToObject<bool>() ?? false;
string finalPath = sanitizedPath;
if (!allowOverwrite && AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(finalPath) != null)
{
finalPath = AssetDatabase.GenerateUniqueAssetPath(finalPath);
}
EnsureAssetDirectoryExists(finalPath);
try
{
GameObject connectedInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(
sourceObject,
finalPath,
InteractionMode.AutomatedAction
);
if (connectedInstance == null)
{
return Response.Error($"Failed to save prefab asset at '{finalPath}'.");
}
Selection.activeGameObject = connectedInstance;
return Response.Success(
$"Prefab created at '{finalPath}' and instance linked.",
new
{
prefabPath = finalPath,
instanceId = connectedInstance.GetInstanceID()
}
);
}
catch (Exception e)
{
return Response.Error($"Error saving prefab asset at '{finalPath}': {e.Message}");
}
}
private static void EnsureAssetDirectoryExists(string assetPath)
{
string directory = Path.GetDirectoryName(assetPath);
if (string.IsNullOrEmpty(directory))
{
return;
}
string fullDirectory = Path.Combine(Directory.GetCurrentDirectory(), directory);
if (!Directory.Exists(fullDirectory))
{
Directory.CreateDirectory(fullDirectory);
AssetDatabase.Refresh();
}
}
private static GameObject FindSceneObjectByName(string name, bool includeInactive)
{
PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage?.prefabContentsRoot != null)
{
foreach (Transform transform in stage.prefabContentsRoot.GetComponentsInChildren<Transform>(includeInactive))
{
if (transform.name == name)
{
return transform.gameObject;
}
}
}
Scene activeScene = SceneManager.GetActiveScene();
foreach (GameObject root in activeScene.GetRootGameObjects())
{
foreach (Transform transform in root.GetComponentsInChildren<Transform>(includeInactive))
{
GameObject candidate = transform.gameObject;
if (candidate.name == name)
{
return candidate;
}
}
}
return null;
}
private static object SerializeStage(PrefabStage stage)
{
if (stage == null)
{
return new { isOpen = false };
}
return new
{
isOpen = true,
assetPath = stage.assetPath,
prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null,
mode = stage.mode.ToString(),
isDirty = stage.scene.isDirty
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c14e76b2aa7bb4570a88903b061e946e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -568,8 +568,9 @@ namespace MCPForUnity.Editor.Windows
}
else
{
// For Cursor/others, skip if already configured
if (!IsCursorConfigured(pythonDir))
CheckMcpConfiguration(client);
bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{
ConfigureMcpClient(client);
anyRegistered = true;
@ -581,7 +582,10 @@ namespace MCPForUnity.Editor.Windows
MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}");
}
}
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured();
lastClientRegisteredOk = anyRegistered
|| IsCursorConfigured(pythonDir)
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|| IsClaudeConfigured();
}
// Ensure the bridge is listening and has a fresh saved port
@ -658,7 +662,9 @@ namespace MCPForUnity.Editor.Windows
}
else
{
if (!IsCursorConfigured(pythonDir))
CheckMcpConfiguration(client);
bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{
ConfigureMcpClient(client);
anyRegistered = true;
@ -670,7 +676,10 @@ namespace MCPForUnity.Editor.Windows
UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}");
}
}
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured();
lastClientRegisteredOk = anyRegistered
|| IsCursorConfigured(pythonDir)
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|| IsClaudeConfigured();
// Restart/ensure bridge
MCPForUnityBridge.StartAutoConnect();
@ -708,24 +717,9 @@ namespace MCPForUnity.Editor.Windows
string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args)
.Select(x => x?.ToString() ?? string.Empty)
.ToArray();
string dir = ExtractDirectoryArg(strArgs);
string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs);
if (string.IsNullOrEmpty(dir)) return false;
return PathsEqual(dir, pythonDir);
}
catch { return false; }
}
private static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = System.IO.Path.GetFullPath(a.Trim());
string nb = System.IO.Path.GetFullPath(b.Trim());
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
// Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed
return string.Equals(na, nb, StringComparison.Ordinal);
return McpConfigFileHelper.PathsEqual(dir, pythonDir);
}
catch { return false; }
}
@ -1136,19 +1130,6 @@ namespace MCPForUnity.Editor.Windows
catch { return false; }
}
private static string ExtractDirectoryArg(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static bool ArgsEqual(string[] a, string[] b)
{
if (a == null || b == null) return a == b;
@ -1236,48 +1217,7 @@ namespace MCPForUnity.Editor.Windows
}
catch { }
if (uvPath == null) return "UV package manager not found. Please install UV first.";
string serverSrc = ExtractDirectoryArg(existingArgs);
bool serverValid = !string.IsNullOrEmpty(serverSrc)
&& System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py"));
if (!serverValid)
{
// Prefer the provided pythonDir if valid; fall back to resolver
if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py")))
{
serverSrc = pythonDir;
}
else
{
serverSrc = ResolveServerSrc();
}
}
// macOS normalization: map XDG-style ~/.local/share to canonical Application Support
try
{
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)
&& !string.IsNullOrEmpty(serverSrc))
{
string norm = serverSrc.Replace('\\', '/');
int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal);
if (idx >= 0)
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/...
serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix);
}
}
}
catch { }
// Hard-block PackageCache on Windows unless dev override is set
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& !string.IsNullOrEmpty(serverSrc)
&& serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0
&& !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
{
serverSrc = ServerInstaller.GetServerPath();
}
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
// 2) Canonical args order
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
@ -1301,60 +1241,7 @@ namespace MCPForUnity.Editor.Windows
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
// Robust atomic write without redundant backup or race on existence
string tmp = configPath + ".tmp";
string backup = configPath + ".backup";
bool writeDone = false;
try
{
// Write to temp file first (in same directory for atomicity)
System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false));
try
{
// Try atomic replace; creates 'backup' only on success (platform-dependent)
System.IO.File.Replace(tmp, configPath, backup);
writeDone = true;
}
catch (System.IO.FileNotFoundException)
{
// Destination didn't exist; fall back to move
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
catch (System.PlatformNotSupportedException)
{
// Fallback: rename existing to backup, then move tmp into place
if (System.IO.File.Exists(configPath))
{
try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
System.IO.File.Move(configPath, backup);
}
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
}
catch (Exception ex)
{
// If write did not complete, attempt restore from backup without deleting current file first
try
{
if (!writeDone && System.IO.File.Exists(backup))
{
try { System.IO.File.Copy(backup, configPath, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex);
}
finally
{
// Best-effort cleanup of temp
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
// Only remove backup after a confirmed successful write
try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
}
McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson);
try
{
@ -1389,42 +1276,15 @@ namespace MCPForUnity.Editor.Windows
return;
}
string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient);
}
private static string ResolveServerSrc()
{
try
{
string remembered = UnityEditor.EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty);
if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py")))
{
return remembered;
}
ServerInstaller.EnsureServerInstalled();
string installed = ServerInstaller.GetServerPath();
if (File.Exists(Path.Combine(installed, "server.py")))
{
return installed;
}
bool useEmbedded = UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false);
if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)
&& File.Exists(Path.Combine(embedded, "server.py")))
{
return embedded;
}
return installed;
}
catch { return ServerInstaller.GetServerPath(); }
string manualConfig = mcpClient?.mcpType == McpTypes.Codex
? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine
: ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient);
}
private string FindPackagePythonDirectory()
{
string pythonDir = ResolveServerSrc();
string pythonDir = McpConfigFileHelper.ResolveServerSource();
try
{
@ -1550,7 +1410,9 @@ namespace MCPForUnity.Editor.Windows
return "Manual Configuration Required";
}
string result = WriteToConfig(pythonDir, configPath, mcpClient);
string result = mcpClient.mcpType == McpTypes.Codex
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
: WriteToConfig(pythonDir, configPath, mcpClient);
// Update the client status after successful configuration
if (result == "Configured successfully")
@ -1591,6 +1453,80 @@ namespace MCPForUnity.Editor.Windows
}
}
private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient)
{
try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
string existingToml = string.Empty;
if (File.Exists(configPath))
{
try
{
existingToml = File.ReadAllText(configPath);
}
catch (Exception e)
{
if (debugLogsEnabled)
{
UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
}
existingToml = string.Empty;
}
}
string existingCommand = null;
string[] existingArgs = null;
if (!string.IsNullOrWhiteSpace(existingToml))
{
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
}
string uvPath = ServerInstaller.FindUvPath();
try
{
var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
{
uvPath = existingCommand;
}
}
catch { }
if (uvPath == null)
{
return "UV package manager not found. Please install UV first.";
}
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
bool changed = true;
if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null)
{
changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|| !ArgsEqual(existingArgs, newArgs);
}
if (!changed)
{
return "Configured successfully";
}
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);
try
{
if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
}
catch { }
return "Configured successfully";
}
private void ShowCursorManualConfigurationInstructions(
string configPath,
McpClient mcpClient
@ -1740,6 +1676,14 @@ namespace MCPForUnity.Editor.Windows
}
break;
case McpTypes.Codex:
if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
{
args = codexArgs;
configExists = true;
}
break;
default:
// Standard MCP configuration check for Claude Desktop, Cursor, etc.
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
@ -1755,8 +1699,8 @@ namespace MCPForUnity.Editor.Windows
// Common logic for checking configuration status
if (configExists)
{
string configuredDir = ExtractDirectoryArg(args);
bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir);
string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args);
bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir);
if (matches)
{
mcpClient.SetStatus(McpStatus.Configured);
@ -1766,7 +1710,9 @@ namespace MCPForUnity.Editor.Windows
// Attempt auto-rewrite once if the package path changed
try
{
string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient);
string rewriteResult = mcpClient.mcpType == McpTypes.Codex
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
: WriteToConfig(pythonDir, configPath, mcpClient);
if (rewriteResult == "Configured successfully")
{
if (debugLogsEnabled)

View File

@ -101,6 +101,13 @@ namespace MCPForUnity.Editor.Windows
instructionStyle
);
}
else if (mcpClient?.mcpType == McpTypes.Codex)
{
EditorGUILayout.LabelField(
" a) Running `codex config edit` in a terminal",
instructionStyle
);
}
EditorGUILayout.LabelField(" OR", instructionStyle);
EditorGUILayout.LabelField(
" b) Opening the configuration file at:",
@ -201,10 +208,10 @@ namespace MCPForUnity.Editor.Windows
EditorGUILayout.Space(10);
EditorGUILayout.LabelField(
"2. Paste the following JSON configuration:",
instructionStyle
);
string configLabel = mcpClient?.mcpType == McpTypes.Codex
? "2. Paste the following TOML configuration:"
: "2. Paste the following JSON configuration:";
EditorGUILayout.LabelField(configLabel, instructionStyle);
// JSON section with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox);

View File

@ -5,6 +5,7 @@ This file contains all configurable parameters for the server.
from dataclasses import dataclass
@dataclass
class ServerConfig:
"""Main configuration class for the MCP server."""
@ -15,11 +16,14 @@ class ServerConfig:
mcp_port: int = 6500
# Connection settings
connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts
# short initial timeout; retries use shorter timeouts
connection_timeout: float = 1.0
buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Framed receive behavior
framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only
max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up
# max seconds to wait while consuming heartbeats only
framed_receive_timeout: float = 2.0
# cap heartbeat frames consumed before giving up
max_heartbeat_frames: int = 16
# Logging settings
log_level: str = "INFO"
@ -37,7 +41,8 @@ class ServerConfig:
# Telemetry settings
telemetry_enabled: bool = True
# Align with telemetry.py default Cloud Run endpoint
telemetry_endpoint: str = "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events"
telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events"
# Create a global config instance
config = ServerConfig()

View File

@ -11,16 +11,16 @@ What changed and why:
(quick socket connect + ping) before choosing it.
"""
import glob
import json
import os
import logging
from pathlib import Path
from typing import Optional, List
import glob
import socket
from typing import Optional, List
logger = logging.getLogger("mcp-for-unity-server")
class PortDiscovery:
"""Handles port discovery from Unity Bridge registry"""
REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file
@ -78,7 +78,8 @@ class PortDiscovery:
try:
base = PortDiscovery.get_registry_dir()
status_files = sorted(
(Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))),
(Path(p)
for p in glob.glob(str(base / "unity-mcp-status-*.json"))),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
@ -120,17 +121,20 @@ class PortDiscovery:
if first_seen_port is None:
first_seen_port = unity_port
if PortDiscovery._try_probe_unity_mcp(unity_port):
logger.info(f"Using Unity port from {path.name}: {unity_port}")
logger.info(
f"Using Unity port from {path.name}: {unity_port}")
return unity_port
except Exception as e:
logger.warning(f"Could not read port registry {path}: {e}")
if first_seen_port is not None:
logger.info(f"No responsive port found; using first seen value {first_seen_port}")
logger.info(
f"No responsive port found; using first seen value {first_seen_port}")
return first_seen_port
# Fallback to default port
logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}")
logger.info(
f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}")
return PortDiscovery.DEFAULT_PORT
@staticmethod
@ -151,5 +155,6 @@ class PortDiscovery:
with open(path, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Could not read port configuration {path}: {e}")
logger.warning(
f"Could not read port configuration {path}: {e}")
return None

View File

@ -1,10 +1,10 @@
[project]
name = "MCPForUnityServer"
version = "3.4.0"
version = "4.1.0"
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"]
dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.15.0"]
[build-system]
requires = ["setuptools>=64.0.0", "wheel"]

View File

@ -4,5 +4,6 @@ Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
All functions are no-ops to prevent accidental external writes.
"""
def flip_reload_sentinel(*args, **kwargs) -> str:
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"

View File

@ -1,10 +1,9 @@
from mcp.server.fastmcp import FastMCP, Context, Image
from mcp.server.fastmcp import FastMCP
import logging
from logging.handlers import RotatingFileHandler
import os
from dataclasses import dataclass
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any, List
from typing import AsyncIterator, Dict, Any
from config import config
from tools import register_all_tools
from unity_connection import get_unity_connection, UnityConnection
@ -150,8 +149,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
# Initialize MCP server
mcp = FastMCP(
"mcp-for-unity-server",
description="Unity Editor integration via Model Context Protocol",
name="mcp-for-unity-server",
lifespan=server_lifespan
)

View File

@ -1 +1 @@
3.4.0
4.1.0

View File

@ -1,29 +1,28 @@
"""
Privacy-focused, anonymous telemetry system for Unity MCP
Inspired by Onyx's telemetry implementation with Unity-specific adaptations
"""
import uuid
import threading
"""
Fire-and-forget telemetry sender with a single background worker.
- No context/thread-local propagation to avoid re-entrancy into tool resolution.
- Small network timeouts to prevent stalls.
"""
import json
import time
import os
import sys
import platform
import logging
from enum import Enum
from urllib.parse import urlparse
from dataclasses import dataclass, asdict
from typing import Optional, Dict, Any, List
from pathlib import Path
import importlib
import queue
import contextlib
from dataclasses import dataclass
from enum import Enum
import importlib
import json
import logging
import os
from pathlib import Path
import platform
import queue
import sys
import threading
import time
from typing import Optional, Dict, Any
from urllib.parse import urlparse
import uuid
try:
import httpx
@ -34,6 +33,7 @@ except ImportError:
logger = logging.getLogger("unity-mcp-telemetry")
class RecordType(str, Enum):
"""Types of telemetry records we collect"""
VERSION = "version"
@ -45,6 +45,7 @@ class RecordType(str, Enum):
UNITY_CONNECTION = "unity_connection"
CLIENT_CONNECTION = "client_connection"
class MilestoneType(str, Enum):
"""Major user journey milestones"""
FIRST_STARTUP = "first_startup"
@ -55,6 +56,7 @@ class MilestoneType(str, Enum):
DAILY_ACTIVE_USER = "daily_active_user"
WEEKLY_ACTIVE_USER = "weekly_active_user"
@dataclass
class TelemetryRecord:
"""Structure for telemetry data"""
@ -65,8 +67,10 @@ class TelemetryRecord:
data: Dict[str, Any]
milestone: Optional[MilestoneType] = None
class TelemetryConfig:
"""Telemetry configuration"""
def __init__(self):
# Prefer config file, then allow env overrides
server_config = None
@ -85,12 +89,14 @@ class TelemetryConfig:
continue
# Determine enabled flag: config -> env DISABLE_* opt-out
cfg_enabled = True if server_config is None else bool(getattr(server_config, "telemetry_enabled", True))
cfg_enabled = True if server_config is None else bool(
getattr(server_config, "telemetry_enabled", True))
self.enabled = cfg_enabled and not self._is_disabled()
# Telemetry endpoint (Cloud Run default; override via env)
cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None)
default_ep = cfg_default or "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events"
cfg_default = None if server_config is None else getattr(
server_config, "telemetry_endpoint", None)
default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
self.default_endpoint = default_ep
self.endpoint = self._validated_endpoint(
os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep),
@ -113,7 +119,8 @@ class TelemetryConfig:
# Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
try:
self.timeout = float(os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
self.timeout = float(os.environ.get(
"UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
except Exception:
self.timeout = 1.5
try:
@ -140,12 +147,14 @@ class TelemetryConfig:
def _get_data_directory(self) -> Path:
"""Get directory for storing telemetry data"""
if os.name == 'nt': # Windows
base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming'))
base_dir = Path(os.environ.get(
'APPDATA', Path.home() / 'AppData' / 'Roaming'))
elif os.name == 'posix': # macOS/Linux
if 'darwin' in os.uname().sysname.lower(): # macOS
base_dir = Path.home() / 'Library' / 'Application Support'
else: # Linux
base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share'))
base_dir = Path(os.environ.get('XDG_DATA_HOME',
Path.home() / '.local' / 'share'))
else:
base_dir = Path.home() / '.unity-mcp'
@ -167,7 +176,8 @@ class TelemetryConfig:
# Reject localhost/loopback endpoints in production to avoid accidental local overrides
host = parsed.hostname or ""
if host in ("localhost", "127.0.0.1", "::1"):
raise ValueError("Localhost endpoints are not allowed for telemetry")
raise ValueError(
"Localhost endpoints are not allowed for telemetry")
return candidate
except Exception as e:
logger.debug(
@ -176,6 +186,7 @@ class TelemetryConfig:
)
return fallback
class TelemetryCollector:
"""Main telemetry collection class"""
@ -188,7 +199,8 @@ class TelemetryCollector:
self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
# Load persistent data before starting worker so first events have UUID
self._load_persistent_data()
self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True)
self._worker: threading.Thread = threading.Thread(
target=self._worker_loop, daemon=True)
self._worker.start()
def _load_persistent_data(self):
@ -196,15 +208,18 @@ class TelemetryCollector:
# Load customer UUID
try:
if self.config.uuid_file.exists():
self._customer_uuid = self.config.uuid_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4())
self._customer_uuid = self.config.uuid_file.read_text(
encoding="utf-8").strip() or str(uuid.uuid4())
else:
self._customer_uuid = str(uuid.uuid4())
try:
self.config.uuid_file.write_text(self._customer_uuid, encoding="utf-8")
self.config.uuid_file.write_text(
self._customer_uuid, encoding="utf-8")
if os.name == "posix":
os.chmod(self.config.uuid_file, 0o600)
except OSError as e:
logger.debug(f"Failed to persist customer UUID: {e}", exc_info=True)
logger.debug(
f"Failed to persist customer UUID: {e}", exc_info=True)
except OSError as e:
logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
self._customer_uuid = str(uuid.uuid4())
@ -212,7 +227,8 @@ class TelemetryCollector:
# Load milestones (failure here must not affect UUID)
try:
if self.config.milestones_file.exists():
content = self.config.milestones_file.read_text(encoding="utf-8")
content = self.config.milestones_file.read_text(
encoding="utf-8")
self._milestones = json.loads(content) or {}
if not isinstance(self._milestones, dict):
self._milestones = {}
@ -276,7 +292,8 @@ class TelemetryCollector:
try:
self._queue.put_nowait(record)
except queue.Full:
logger.debug("Telemetry queue full; dropping %s", record.record_type)
logger.debug("Telemetry queue full; dropping %s",
record.record_type)
def _worker_loop(self):
"""Background worker that serializes telemetry sends."""
@ -323,17 +340,20 @@ class TelemetryCollector:
if httpx:
with httpx.Client(timeout=self.config.timeout) as client:
# Re-validate endpoint at send time to handle dynamic changes
endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint)
endpoint = self.config._validated_endpoint(
self.config.endpoint, self.config.default_endpoint)
response = client.post(endpoint, json=payload)
if 200 <= response.status_code < 300:
logger.debug(f"Telemetry sent: {record.record_type}")
else:
logger.warning(f"Telemetry failed: HTTP {response.status_code}")
logger.warning(
f"Telemetry failed: HTTP {response.status_code}")
else:
import urllib.request
import urllib.error
data_bytes = json.dumps(payload).encode("utf-8")
endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint)
endpoint = self.config._validated_endpoint(
self.config.endpoint, self.config.default_endpoint)
req = urllib.request.Request(
endpoint,
data=data_bytes,
@ -343,9 +363,11 @@ class TelemetryCollector:
try:
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
if 200 <= resp.getcode() < 300:
logger.debug(f"Telemetry sent (urllib): {record.record_type}")
logger.debug(
f"Telemetry sent (urllib): {record.record_type}")
else:
logger.warning(f"Telemetry failed (urllib): HTTP {resp.getcode()}")
logger.warning(
f"Telemetry failed (urllib): HTTP {resp.getcode()}")
except urllib.error.URLError as ue:
logger.warning(f"Telemetry send failed (urllib): {ue}")
@ -357,6 +379,7 @@ class TelemetryCollector:
# Global telemetry instance
_telemetry_collector: Optional[TelemetryCollector] = None
def get_telemetry() -> TelemetryCollector:
"""Get the global telemetry collector instance"""
global _telemetry_collector
@ -364,16 +387,19 @@ def get_telemetry() -> TelemetryCollector:
_telemetry_collector = TelemetryCollector()
return _telemetry_collector
def record_telemetry(record_type: RecordType,
data: Dict[str, Any],
milestone: Optional[MilestoneType] = None):
"""Convenience function to record telemetry"""
get_telemetry().record(record_type, data, milestone)
def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
"""Convenience function to record a milestone"""
return get_telemetry().record_milestone(milestone, data)
def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None):
"""Record tool usage telemetry
@ -402,6 +428,7 @@ def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error:
record_telemetry(RecordType.TOOL_EXECUTION, data)
def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None):
"""Record latency telemetry"""
data = {
@ -414,6 +441,7 @@ def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[s
record_telemetry(RecordType.LATENCY, data)
def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None):
"""Record failure telemetry"""
data = {
@ -426,6 +454,7 @@ def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]
record_telemetry(RecordType.FAILURE, data)
def is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled"""
return get_telemetry().config.enabled

View File

@ -3,15 +3,17 @@ Telemetry decorator for Unity MCP tools
"""
import functools
import time
import inspect
import logging
import time
from typing import Callable, Any
from telemetry import record_tool_usage, record_milestone, MilestoneType
_log = logging.getLogger("unity-mcp-telemetry")
_decorator_log_count = 0
def telemetry_tool(tool_name: str):
"""Decorator to add telemetry tracking to MCP tools"""
def decorator(func: Callable) -> Callable:
@ -41,7 +43,8 @@ def telemetry_tool(tool_name: str):
if tool_name == "manage_script" and action_val == "create":
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
elif tool_name.startswith("manage_scene"):
record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(
MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
except Exception:
_log.debug("milestone emit failed", exc_info=True)
@ -52,7 +55,8 @@ def telemetry_tool(tool_name: str):
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action)
record_tool_usage(tool_name, success,
duration_ms, error, sub_action=sub_action)
except Exception:
_log.debug("record_tool_usage failed", exc_info=True)
@ -82,7 +86,8 @@ def telemetry_tool(tool_name: str):
if tool_name == "manage_script" and action_val == "create":
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
elif tool_name.startswith("manage_scene"):
record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(
MilestoneType.FIRST_SCENE_MODIFICATION)
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
except Exception:
_log.debug("milestone emit failed", exc_info=True)
@ -93,7 +98,8 @@ def telemetry_tool(tool_name: str):
finally:
duration_ms = (time.time() - start_time) * 1000
try:
record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action)
record_tool_usage(tool_name, success,
duration_ms, error, sub_action=sub_action)
except Exception:
_log.debug("record_tool_usage failed", exc_info=True)

View File

@ -5,13 +5,13 @@ Run this to verify telemetry is working correctly
"""
import os
import time
import sys
from pathlib import Path
import sys
# Add src to Python path for imports
sys.path.insert(0, str(Path(__file__).parent))
def test_telemetry_basic():
"""Test basic telemetry functionality"""
# Avoid stdout noise in tests
@ -60,6 +60,7 @@ def test_telemetry_basic():
return True
def test_telemetry_disabled():
"""Test telemetry with disabled state"""
# Silent for tests
@ -88,6 +89,7 @@ def test_telemetry_disabled():
pass
return False
def test_data_storage():
"""Test data storage functionality"""
# Silent for tests
@ -98,7 +100,8 @@ def test_data_storage():
collector = get_telemetry()
data_dir = collector.config.data_dir
_ = (data_dir, collector.config.uuid_file, collector.config.milestones_file)
_ = (data_dir, collector.config.uuid_file,
collector.config.milestones_file)
# Check if files exist
if collector.config.uuid_file.exists():
@ -117,6 +120,7 @@ def test_data_storage():
# Silent failure path for tests
return False
def main():
"""Run all telemetry tests"""
# Silent runner for CI
@ -151,6 +155,7 @@ def main():
pass
return False
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View File

@ -1,10 +1,14 @@
import logging
from mcp.server.fastmcp import FastMCP
from .manage_script_edits import register_manage_script_edits_tools
from .manage_script import register_manage_script_tools
from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_tools
from .manage_prefabs import register_manage_prefabs_tools
from .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools
from .manage_menu_item import register_manage_menu_item_tools
@ -12,7 +16,8 @@ from .resource_tools import register_resource_tools
logger = logging.getLogger("mcp-for-unity-server")
def register_all_tools(mcp):
def register_all_tools(mcp: FastMCP):
"""Register all refactored tools with the MCP server."""
# Prefer the surgical edits tool so LLMs discover it first
logger.info("Registering MCP for Unity Server refactored tools...")
@ -22,6 +27,7 @@ def register_all_tools(mcp):
register_manage_editor_tools(mcp)
register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp)
register_manage_prefabs_tools(mcp)
register_manage_shader_tools(mcp)
register_read_console_tools(mcp)
register_manage_menu_item_tools(mcp)

View File

@ -1,54 +1,41 @@
"""
Defines the manage_asset tool for interacting with Unity assets.
"""
import asyncio # Added: Import asyncio for running sync code in async
from typing import Dict, Any
from mcp.server.fastmcp import FastMCP, Context
# from ..unity_connection import get_unity_connection # Original line that caused error
from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper
from config import config
import time
import asyncio
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import async_send_command_with_retry
from telemetry_decorator import telemetry_tool
def register_manage_asset_tools(mcp: FastMCP):
"""Registers the manage_asset tool with the MCP server."""
@mcp.tool()
@mcp.tool(name="manage_asset", description="Performs asset operations (import, create, modify, delete, etc.) in Unity.")
@telemetry_tool("manage_asset")
async def manage_asset(
ctx: Any,
action: str,
path: str,
asset_type: str = None,
properties: Dict[str, Any] = None,
destination: str = None,
generate_preview: bool = False,
search_pattern: str = None,
filter_type: str = None,
filter_date_after: str = None,
page_size: Any = None,
page_number: Any = None
) -> Dict[str, Any]:
"""Performs asset operations (import, create, modify, delete, etc.) in Unity.
Args:
ctx: The MCP context.
action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components').
path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope.
asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'.
properties: Dictionary of properties for 'create'/'modify'.
example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}.
example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}.
example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}.
destination: Target path for 'duplicate'/'move'.
search_pattern: Search pattern (e.g., '*.prefab').
filter_*: Filters for search (type, date).
page_*: Pagination for search.
Returns:
A dictionary with operation results ('success', 'data', 'error').
"""
ctx: Context,
action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
asset_type: Annotated[str,
"Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
properties: Annotated[dict[str, Any],
"Dictionary of properties for 'create'/'modify'."] | None = None,
destination: Annotated[str,
"Target path for 'duplicate'/'move'."] | None = None,
generate_preview: Annotated[bool,
"Generate a preview/thumbnail for the asset when supported."] = False,
search_pattern: Annotated[str,
"Search pattern (e.g., '*.prefab')."] | None = None,
filter_type: Annotated[str, "Filter type for search"] | None = None,
filter_date_after: Annotated[str,
"Date after which to filter"] | None = None,
page_size: Annotated[int, "Page size for pagination"] | None = None,
page_number: Annotated[int, "Page number for pagination"] | None = None
) -> dict[str, Any]:
ctx.info(f"Processing manage_asset: {action}")
# Ensure properties is a dict if None
if properties is None:
properties = {}
@ -92,8 +79,6 @@ def register_manage_asset_tools(mcp: FastMCP):
# Get the current asyncio event loop
loop = asyncio.get_running_loop()
# Get the Unity connection instance
connection = get_unity_connection()
# Use centralized async retry helper to avoid blocking the event loop
result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)

View File

@ -1,37 +1,31 @@
from mcp.server.fastmcp import FastMCP, Context
import time
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from telemetry import is_telemetry_enabled, record_tool_usage
from unity_connection import send_command_with_retry
def register_manage_editor_tools(mcp: FastMCP):
"""Register all editor management tools with the MCP server."""
@mcp.tool(description=(
"Controls and queries the Unity editor's state and settings.\n\n"
"Args:\n"
"- ctx: Context object (required)\n"
"- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n"
"- wait_for_completion: Optional. If True, waits for certain actions\n"
"- tool_name: Tool name for specific actions\n"
"- tag_name: Tag name for specific actions\n"
"- layer_name: Layer name for specific actions\n\n"
"Returns:\n"
"Dictionary with operation results ('success', 'message', 'data')."
))
@mcp.tool(name="manage_editor", description="Controls and queries the Unity editor's state and settings")
@telemetry_tool("manage_editor")
def manage_editor(
ctx: Context,
action: str,
wait_for_completion: bool = None,
# --- Parameters for specific actions ---
tool_name: str = None,
tag_name: str = None,
layer_name: str = None,
) -> Dict[str, Any]:
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows",
"get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."],
wait_for_completion: Annotated[bool,
"Optional. If True, waits for certain actions"] | None = None,
tool_name: Annotated[str,
"Tool name when setting active tool"] | None = None,
tag_name: Annotated[str,
"Tag name when adding and removing tags"] | None = None,
layer_name: Annotated[str,
"Layer name when adding and removing layers"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_editor: {action}")
try:
# Diagnostics: quick telemetry checks
if action == "telemetry_status":

View File

@ -1,87 +1,74 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_gameobject_tools(mcp: FastMCP):
"""Register all GameObject management tools with the MCP server."""
@mcp.tool()
@mcp.tool(name="manage_gameobject", description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties.")
@telemetry_tool("manage_gameobject")
def manage_gameobject(
ctx: Any,
action: str,
target: str = None, # GameObject identifier by name or path
search_method: str = None,
# --- Combined Parameters for Create/Modify ---
name: str = None, # Used for both 'create' (new object name) and 'modify' (rename)
tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag)
parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent)
position: List[float] = None,
rotation: List[float] = None,
scale: List[float] = None,
components_to_add: List[str] = None, # List of component names to add
primitive_type: str = None,
save_as_prefab: bool = False,
prefab_path: str = None,
prefab_folder: str = "Assets/Prefabs",
ctx: Context,
action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."],
target: Annotated[str,
"GameObject identifier by name or path for modify/delete/component actions"] | None = None,
search_method: Annotated[str,
"How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] | None = None,
name: Annotated[str,
"GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] | None = None,
tag: Annotated[str,
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
parent: Annotated[str,
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
position: Annotated[list[float],
"Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None,
rotation: Annotated[list[float],
"Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None,
scale: Annotated[list[float],
"Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None,
components_to_add: Annotated[list[str],
"List of component names to add"] | None = None,
primitive_type: Annotated[str,
"Primitive type for 'create' action"] | None = None,
save_as_prefab: Annotated[bool,
"If True, saves the created GameObject as a prefab"] | None = None,
prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
prefab_folder: Annotated[str,
"Folder for prefab creation"] | None = None,
# --- Parameters for 'modify' ---
set_active: bool = None,
layer: str = None, # Layer name
components_to_remove: List[str] = None,
component_properties: Dict[str, Dict[str, Any]] = None,
# --- Parameters for 'find' ---
search_term: str = None,
find_all: bool = False,
search_in_children: bool = False,
search_inactive: bool = False,
# -- Component Management Arguments --
component_name: str = None,
includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields
) -> Dict[str, Any]:
"""Manages GameObjects: create, modify, delete, find, and component operations.
Args:
action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components').
target: GameObject identifier (name or path string) for modify/delete/component actions.
search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups.
name: GameObject name - used for both 'create' (initial name) and 'modify' (rename).
tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag).
parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent).
layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer).
component_properties: Dict mapping Component names to their properties to set.
Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}},
To set references:
- Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}}
- Use a dict for scene objects/components, e.g.:
{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject)
{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component)
set_active: Annotated[bool,
"If True, sets the GameObject active"] | None = None,
layer: Annotated[str, "Layer name"] | None = None,
components_to_remove: Annotated[list[str],
"List of component names to remove"] | None = None,
component_properties: Annotated[dict[str, dict[str, Any]],
"""Dictionary of component names to their properties to set. For example:
`{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject
`{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component
Example set nested property:
- Access shared material: {"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}
components_to_add: List of component names to add.
Action-specific arguments (e.g., position, rotation, scale for create/modify;
component_name for component actions;
search_term, find_all for 'find').
includeNonPublicSerialized: If True, includes private fields marked [SerializeField] in component data.
Action-specific details:
- For 'get_components':
Required: target, search_method
Optional: includeNonPublicSerialized (defaults to True)
Returns all components on the target GameObject with their serialized data.
The search_method parameter determines how to find the target ('by_name', 'by_id', 'by_path').
Returns:
Dictionary with operation results ('success', 'message', 'data').
For 'get_components', the 'data' field contains a dictionary of component names and their serialized properties.
"""
- Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None,
# --- Parameters for 'find' ---
search_term: Annotated[str,
"Search term for 'find' action"] | None = None,
find_all: Annotated[bool,
"If True, finds all GameObjects matching the search term"] | None = None,
search_in_children: Annotated[bool,
"If True, searches in children of the GameObject"] | None = None,
search_inactive: Annotated[bool,
"If True, searches inactive GameObjects"] | None = None,
# -- Component Management Arguments --
component_name: Annotated[str,
"Component name for 'add_component' and 'remove_component' actions"] | None = None,
# Controls whether serialization of private [SerializeField] fields is included
includeNonPublicSerialized: Annotated[bool,
"Controls whether serialization of private [SerializeField] fields is included"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_gameobject: {action}")
try:
# --- Early check for attempting to modify a prefab asset ---
# ----------------------------------------------------------
# Prepare parameters, removing None values
params = {
"action": action,
@ -112,7 +99,8 @@ def register_manage_gameobject_tools(mcp: FastMCP):
params = {k: v for k, v in params.items() if v is not None}
# --- Handle Prefab Path Logic ---
if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params
# Check if 'saveAsPrefab' is explicitly True in params
if action == "create" and params.get("saveAsPrefab"):
if "prefabPath" not in params:
if "name" not in params or not params["name"]:
return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."}

View File

@ -7,36 +7,25 @@ from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import get_unity_connection, async_send_command_with_retry
from unity_connection import async_send_command_with_retry
def register_manage_menu_item_tools(mcp: FastMCP):
"""Registers the manage_menu_item tool with the MCP server."""
@mcp.tool()
@mcp.tool(name="manage_menu_item", description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.")
@telemetry_tool("manage_menu_item")
async def manage_menu_item(
ctx: Context,
action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"],
menu_path: Annotated[str | None,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None,
search: Annotated[str | None,
"Optional filter string for 'list' (e.g., 'Save')"] = None,
refresh: Annotated[bool | None,
"Optional flag to force refresh of the menu cache when listing"] = None,
action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."],
menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
search: Annotated[str,
"Optional filter string for 'list' (e.g., 'Save')"] | None = None,
refresh: Annotated[bool,
"Optional flag to force refresh of the menu cache when listing"] | None = None,
) -> dict[str, Any]:
"""Manage Unity menu items (execute/list/exists).
Args:
ctx: The MCP context.
action: One of 'execute', 'list', 'exists'.
menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project").
search: Optional filter string for 'list'.
refresh: Optional flag to force refresh of the menu cache when listing.
Returns:
A dictionary with operation results ('success', 'data', 'error').
"""
ctx.info(f"Processing manage_menu_item: {action}")
# Prepare parameters for the C# handler
params_dict: dict[str, Any] = {
"action": action,
@ -49,8 +38,6 @@ def register_manage_menu_item_tools(mcp: FastMCP):
# Get the current asyncio event loop
loop = asyncio.get_running_loop()
# Touch the connection to ensure availability (mirrors other tools' pattern)
_ = get_unity_connection()
# Use centralized async retry helper
result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop)

View File

@ -0,0 +1,61 @@
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_prefabs_tools(mcp: FastMCP) -> None:
"""Register prefab management tools with the MCP server."""
@mcp.tool(name="manage_prefabs", description="Bridge for prefab management commands (stage control and creation).")
@telemetry_tool("manage_prefabs")
def manage_prefabs(
ctx: Context,
action: Annotated[Literal[
"open_stage",
"close_stage",
"save_open_stage",
"create_from_gameobject",
], "Manage prefabs (stage control and creation)."],
prefab_path: Annotated[str,
"Prefab asset path relative to Assets e.g. Assets/Prefabs/favorite.prefab"] | None = None,
mode: Annotated[str,
"Optional prefab stage mode (only 'InIsolation' is currently supported)"] | None = None,
save_before_close: Annotated[bool,
"When true, `close_stage` will save the prefab before exiting the stage."] | None = None,
target: Annotated[str,
"Scene GameObject name required for create_from_gameobject"] | None = None,
allow_overwrite: Annotated[bool,
"Allow replacing an existing prefab at the same path"] | None = None,
search_inactive: Annotated[bool,
"Include inactive objects when resolving the target name"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_prefabs: {action}")
try:
params: dict[str, Any] = {"action": action}
if prefab_path:
params["prefabPath"] = prefab_path
if mode:
params["mode"] = mode
if save_before_close is not None:
params["saveBeforeClose"] = bool(save_before_close)
if target:
params["target"] = target
if allow_overwrite is not None:
params["allowOverwrite"] = bool(allow_overwrite)
if search_inactive is not None:
params["searchInactive"] = bool(search_inactive)
response = send_command_with_retry("manage_prefabs", params)
if isinstance(response, dict) and response.get("success"):
return {
"success": True,
"message": response.get("message", "Prefab operation successful."),
"data": response.get("data"),
}
return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as exc:
return {"success": False, "message": f"Python error managing prefabs: {exc}"}

View File

@ -1,35 +1,27 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
from typing import Annotated, Literal, Any
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_scene_tools(mcp: FastMCP):
"""Register all scene management tools with the MCP server."""
@mcp.tool()
@mcp.tool(name="manage_scene", description="Manage Unity scenes")
@telemetry_tool("manage_scene")
def manage_scene(
ctx: Context,
action: str,
name: str = "",
path: str = "",
build_index: Any = None,
) -> Dict[str, Any]:
"""Manages Unity scenes (load, save, create, get hierarchy, etc.).
Args:
action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy').
name: Scene name (no extension) for create/load/save.
path: Asset path for scene operations (default: "Assets/").
build_index: Build index for load/build settings actions.
# Add other action-specific args as needed (e.g., for hierarchy depth)
Returns:
Dictionary with results ('success', 'message', 'data').
"""
action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
name: Annotated[str,
"Scene name. Not required get_active/get_build_settings"] | None = None,
path: Annotated[str,
"Asset path for scene operations (default: 'Assets/')"] | None = None,
build_index: Annotated[int,
"Build index for load/build settings actions"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_scene: {action}")
try:
# Coerce numeric inputs defensively
def _coerce_int(value, default=None):

View File

@ -1,21 +1,24 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List
from unity_connection import send_command_with_retry
import base64
import os
from typing import Annotated, Any, Literal
from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import send_command_with_retry
try:
from telemetry_decorator import telemetry_tool
from telemetry import record_milestone, MilestoneType
HAS_TELEMETRY = True
except ImportError:
HAS_TELEMETRY = False
def telemetry_tool(tool_name: str):
def decorator(func):
return func
return decorator
def register_manage_script_tools(mcp: FastMCP):
"""Register all script management tools with the MCP server."""
@ -56,7 +59,8 @@ def register_manage_script_tools(mcp: FastMCP):
# If an 'Assets' segment exists, compute path relative to it (case-insensitive)
parts = [p for p in norm.split("/") if p not in ("", ".")]
idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None)
idx = next((i for i, seg in enumerate(parts)
if seg.lower() == "assets"), None)
assets_rel = "/".join(parts[idx:]) if idx is not None else None
effective_path = assets_rel if assets_rel else norm
@ -69,51 +73,47 @@ def register_manage_script_tools(mcp: FastMCP):
directory = os.path.dirname(effective_path)
return name, directory
@mcp.tool(description=(
"Apply small text edits to a C# script identified by URI.\n\n"
"⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n"
"Common mistakes:\n"
"- Assuming what's on a line without checking\n"
"- Using wrong line numbers (they're 1-indexed)\n"
"- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n"
"RECOMMENDED WORKFLOW:\n"
"1) First call resources/read with start_line/line_count to verify exact content\n"
"2) Count columns carefully (or use find_in_file to locate patterns)\n"
"3) Apply your edit with precise coordinates\n"
"4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n"
"Args:\n"
"- uri: unity://path/Assets/... or file://... or Assets/...\n"
"- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n"
"- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n"
"Notes:\n"
"- Path must resolve under Assets/\n"
"- For method/class operations, use script_apply_edits (safer, structured edits)\n"
"- For pattern-based replacements, consider anchor operations in script_apply_edits\n"
@mcp.tool(name="apply_text_edits", description=(
"""Apply small text edits to a C# script identified by URI.
IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!
RECOMMENDED WORKFLOW:
1. First call resources/read with start_line/line_count to verify exact content
2. Count columns carefully (or use find_in_file to locate patterns)
3. Apply your edit with precise coordinates
4. Consider script_apply_edits with anchors for safer pattern-based replacements
Notes:
- For method/class operations, use script_apply_edits (safer, structured edits)
- For pattern-based replacements, consider anchor operations in script_apply_edits
- Lines, columns are 1-indexed
- Tabs count as 1 column"""
))
@telemetry_tool("apply_text_edits")
def apply_text_edits(
ctx: Context,
uri: str,
edits: List[Dict[str, Any]],
precondition_sha256: str | None = None,
strict: bool | None = None,
options: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""Apply small text edits to a C# script identified by URI."""
uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"],
precondition_sha256: Annotated[str,
"Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None,
strict: Annotated[bool,
"Optional strict flag, used to enforce strict mode"] | None = None,
options: Annotated[dict[str, Any],
"Optional options, used to pass additional options to the script editor"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing apply_text_edits: {uri}")
name, directory = _split_uri(uri)
# Normalize common aliases/misuses for resilience:
# - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
# - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text}
# If normalization is required, read current contents to map indices -> 1-based line/col.
def _needs_normalization(arr: List[Dict[str, Any]]) -> bool:
def _needs_normalization(arr: list[dict[str, Any]]) -> bool:
for e in arr or []:
if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e):
return True
return False
normalized_edits: List[Dict[str, Any]] = []
warnings: List[str] = []
normalized_edits: list[dict[str, Any]] = []
warnings: list[str] = []
if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed
read_resp = send_command_with_retry("manage_script", {
@ -127,7 +127,8 @@ def register_manage_script_tools(mcp: FastMCP):
contents = data.get("contents")
if not contents and data.get("contentsEncoded"):
try:
contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace")
contents = base64.b64decode(data.get("encodedContents", "").encode(
"utf-8")).decode("utf-8", "replace")
except Exception:
contents = contents or ""
@ -167,7 +168,8 @@ def register_manage_script_tools(mcp: FastMCP):
e2[k] = 1
except Exception:
pass
warnings.append("zero_based_explicit_fields_normalized")
warnings.append(
"zero_based_explicit_fields_normalized")
normalized_edits.append(e2)
continue
@ -212,7 +214,8 @@ def register_manage_script_tools(mcp: FastMCP):
normalized_edits = []
for e in edits or []:
e2 = dict(e)
has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol"))
has_all = all(k in e2 for k in (
"startLine", "startCol", "endLine", "endCol"))
if has_all:
zero_based = False
for k in ("startLine", "startCol", "endLine", "endCol"):
@ -231,14 +234,17 @@ def register_manage_script_tools(mcp: FastMCP):
except Exception:
pass
if "zero_based_explicit_fields_normalized" not in warnings:
warnings.append("zero_based_explicit_fields_normalized")
warnings.append(
"zero_based_explicit_fields_normalized")
normalized_edits.append(e2)
# Preflight: detect overlapping ranges among normalized line/col spans
def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]:
def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]:
return (
int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)),
int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)),
int(e.get("startLine", 1)) if key_start else int(
e.get("endLine", 1)),
int(e.get("startCol", 1)) if key_start else int(
e.get("endCol", 1)),
)
def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
@ -276,7 +282,7 @@ def register_manage_script_tools(mcp: FastMCP):
# preserves existing call-count expectations in clients/tests.
# Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance
opts: Dict[str, Any] = dict(options or {})
opts: dict[str, Any] = dict(options or {})
try:
if len(normalized_edits) > 1 and "applyMode" not in opts:
opts["applyMode"] = "atomic"
@ -320,10 +326,16 @@ def register_manage_script_tools(mcp: FastMCP):
if resp.get("success") and (options or {}).get("force_sentinel_reload"):
# Optional: flip sentinel via menu if explicitly requested
try:
import threading, time, json, glob, os
import threading
import time
import json
import glob
import os
def _latest_status() -> dict | None:
try:
files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
files = sorted(glob.glob(os.path.expanduser(
"~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True)
if not files:
return None
with open(files[0], "r") as f:
@ -352,24 +364,21 @@ def register_manage_script_tools(mcp: FastMCP):
return resp
return {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Create a new C# script at the given project path.\n\n"
"Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n"
"Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n"
))
@mcp.tool(name="create_script", description=("Create a new C# script at the given project path."))
@telemetry_tool("create_script")
def create_script(
ctx: Context,
path: str,
contents: str = "",
script_type: str | None = None,
namespace: str | None = None,
) -> Dict[str, Any]:
"""Create a new C# script at the given path."""
path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing create_script: {path}")
name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path)
# Local validation to avoid round-trips on obviously bad input
norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/")
norm_path = os.path.normpath(
(path or "").replace("\\", "/")).replace("\\", "/")
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."}
if ".." in norm_path.split("/") or norm_path.startswith("/"):
@ -378,7 +387,7 @@ def register_manage_script_tools(mcp: FastMCP):
return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
if not norm_path.lower().endswith(".cs"):
return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
params: Dict[str, Any] = {
params: dict[str, Any] = {
"action": "create",
"name": name,
"path": directory,
@ -386,20 +395,21 @@ def register_manage_script_tools(mcp: FastMCP):
"scriptType": script_type,
}
if contents:
params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8")
params["encodedContents"] = base64.b64encode(
contents.encode("utf-8")).decode("utf-8")
params["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Delete a C# script by URI or Assets-relative path.\n\n"
"Args: uri (unity://path/... or file://... or Assets/...).\n"
"Rules: Target must resolve under Assets/.\n"
))
@mcp.tool(name="delete_script", description=("Delete a C# script by URI or Assets-relative path."))
@telemetry_tool("delete_script")
def delete_script(ctx: Context, uri: str) -> Dict[str, Any]:
def delete_script(
ctx: Context,
uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
) -> dict[str, Any]:
"""Delete a C# script by URI."""
ctx.info(f"Processing delete_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
@ -407,18 +417,17 @@ def register_manage_script_tools(mcp: FastMCP):
resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Validate a C# script and return diagnostics.\n\n"
"Args: uri, level=('basic'|'standard'), include_diagnostics (bool, optional).\n"
"- basic: quick syntax checks.\n"
"- standard: deeper checks (performance hints, common pitfalls).\n"
"- include_diagnostics: when true, returns full diagnostics and summary; default returns counts only.\n"
))
@mcp.tool(name="validate_script", description=("Validate a C# script and return diagnostics."))
@telemetry_tool("validate_script")
def validate_script(
ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False
) -> Dict[str, Any]:
"""Validate a C# script and return diagnostics."""
ctx: Context,
uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
level: Annotated[Literal['basic', 'standard'],
"Validation level"] = "basic",
include_diagnostics: Annotated[bool,
"Include full diagnostics and summary"] = False
) -> dict[str, Any]:
ctx.info(f"Processing validate_script: {uri}")
name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
@ -433,103 +442,30 @@ def register_manage_script_tools(mcp: FastMCP):
resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning")
errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal"))
warnings = sum(1 for d in diags if str(
d.get("severity", "")).lower() == "warning")
errors = sum(1 for d in diags if str(
d.get("severity", "")).lower() in ("error", "fatal"))
if include_diagnostics:
return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
return {"success": True, "data": {"warnings": warnings, "errors": errors}}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=(
"Compatibility router for legacy script operations.\n\n"
"Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n"
"Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n"
"Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n"
))
@mcp.tool(name="manage_script", description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits."))
@telemetry_tool("manage_script")
def manage_script(
ctx: Context,
action: str,
name: str,
path: str,
contents: str = "",
script_type: str | None = None,
namespace: str | None = None,
) -> Dict[str, Any]:
"""Compatibility router for legacy script operations.
IMPORTANT:
- Direct file reads should use resources/read.
- Edits should use apply_text_edits.
Args:
action: Operation ('create', 'read', 'delete').
name: Script name (no .cs extension).
path: Asset path (default: "Assets/").
contents: C# code for 'create'/'update'.
script_type: Type hint (e.g., 'MonoBehaviour').
namespace: Script namespace.
Returns:
Dictionary with results ('success', 'message', 'data').
"""
action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."],
name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
contents: Annotated[str, "Contents of the script to create",
"C# code for 'create'/'update'"] | None = None,
script_type: Annotated[str, "Script type (e.g., 'C#')",
"Type hint (e.g., 'MonoBehaviour')"] | None = None,
namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_script: {action}")
try:
# Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace)
if action == 'update':
try:
# 1) Read current contents to compute end range and precondition
read_resp = send_command_with_retry("manage_script", {
"action": "read",
"name": name,
"path": path,
})
if not (isinstance(read_resp, dict) and read_resp.get("success")):
return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."}
data = read_resp.get("data", {})
current = data.get("contents")
if not current and data.get("contentsEncoded"):
current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace")
if current is None:
return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."}
# 2) Compute whole-file range (1-based, end exclusive) and SHA
import hashlib as _hashlib
old_lines = current.splitlines(keepends=True)
end_line = len(old_lines) + 1
sha = _hashlib.sha256(current.encode("utf-8")).hexdigest()
# 3) Apply single whole-file text edit with provided 'contents'
edits = [{
"startLine": 1,
"startCol": 1,
"endLine": end_line,
"endCol": 1,
"newText": contents or "",
}]
route_params = {
"action": "apply_text_edits",
"name": name,
"path": path,
"edits": edits,
"precondition_sha256": sha,
"options": {"refresh": "debounced", "validate": "standard"},
}
# Preflight size vs. default cap (256 KiB) to avoid opaque server errors
try:
import json as _json
payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8"))
if payload_bytes > 256 * 1024:
return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."}
except Exception:
pass
routed = send_command_with_retry("manage_script", route_params)
if isinstance(routed, dict):
routed.setdefault("message", "Routed legacy update to apply_text_edits")
return routed
return {"success": False, "message": str(routed)}
except Exception as e:
return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"}
# Prepare parameters for Unity
params = {
"action": action,
@ -542,7 +478,8 @@ def register_manage_script_tools(mcp: FastMCP):
# Base64 encode the contents if they exist to avoid JSON escaping issues
if contents:
if action == 'create':
params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8')
params["encodedContents"] = base64.b64encode(
contents.encode('utf-8')).decode('utf-8')
params["contentsEncoded"] = True
else:
params["contents"] = contents
@ -554,7 +491,8 @@ def register_manage_script_tools(mcp: FastMCP):
if isinstance(response, dict):
if response.get("success"):
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
decoded_contents = base64.b64decode(
response["data"]["encodedContents"]).decode('utf-8')
response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"]
@ -574,12 +512,17 @@ def register_manage_script_tools(mcp: FastMCP):
"message": f"Python error managing script: {str(e)}",
}
@mcp.tool(description=(
"Get manage_script capabilities (supported ops, limits, and guards).\n\n"
"Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n"
@mcp.tool(name="manage_script_capabilities", description=(
"""Get manage_script capabilities (supported ops, limits, and guards).
Returns:
- ops: list of supported structured ops
- text_ops: list of supported text ops
- max_edit_payload_bytes: server edit payload cap
- guards: header/using guard enabled flag"""
))
@telemetry_tool("manage_script_capabilities")
def manage_script_capabilities(ctx: Context) -> Dict[str, Any]:
def manage_script_capabilities(ctx: Context) -> dict[str, Any]:
ctx.info("Processing manage_script_capabilities")
try:
# Keep in sync with server/Editor ManageScript implementation
ops = [
@ -601,21 +544,21 @@ def register_manage_script_tools(mcp: FastMCP):
except Exception as e:
return {"success": False, "error": f"capabilities error: {e}"}
@mcp.tool(description=(
"Get SHA256 and basic metadata for a Unity C# script without returning file contents.\n\n"
"Args: uri (unity://path/Assets/... or file://... or Assets/...).\n"
"Returns: {sha256, lengthBytes}."
))
@mcp.tool(name="get_sha", description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
@telemetry_tool("get_sha")
def get_sha(ctx: Context, uri: str) -> Dict[str, Any]:
"""Return SHA256 and basic metadata for a script."""
def get_sha(
ctx: Context,
uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."]
) -> dict[str, Any]:
ctx.info(f"Processing get_sha: {uri}")
try:
name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {})
minimal = {"sha256": data.get("sha256"), "lengthBytes": data.get("lengthBytes")}
minimal = {"sha256": data.get(
"sha256"), "lengthBytes": data.get("lengthBytes")}
return {"success": True, "data": minimal}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
except Exception as e:

View File

@ -1,14 +1,15 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any, List, Tuple, Optional
import base64
import hashlib
import re
import os
from unity_connection import send_command_with_retry
from typing import Annotated, Any
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str:
def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
text = original_text
for edit in edits or []:
op = (
@ -29,7 +30,8 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
if op == "prepend":
prepend_text = edit.get("text", "")
text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text
text = (prepend_text if prepend_text.endswith(
"\n") else prepend_text + "\n") + text
elif op == "append":
append_text = edit.get("text", "")
if not text.endswith("\n"):
@ -41,10 +43,12 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
anchor = edit.get("anchor", "")
position = (edit.get("position") or "before").lower()
insert_text = edit.get("text", "")
flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0)
flags = re.MULTILINE | (
re.IGNORECASE if edit.get("ignore_case") else 0)
# Find the best match using improved heuristics
match = _find_best_anchor_match(anchor, text, flags, bool(edit.get("prefer_last", True)))
match = _find_best_anchor_match(
anchor, text, flags, bool(edit.get("prefer_last", True)))
if not match:
if edit.get("allow_noop", True):
continue
@ -62,6 +66,7 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
if (start_line < 1 or end_line < start_line or end_line > max_line
or start_col < 1 or end_col < 1):
raise RuntimeError("replace_range out of bounds")
def index_of(line: int, col: int) -> int:
if line <= len(lines):
return sum(len(l) for l in lines[: line - 1]) + (col - 1)
@ -81,7 +86,8 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
text = re.sub(pattern, repl_py, text, count=count, flags=flags)
else:
allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
raise RuntimeError(
f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
return text
@ -105,7 +111,6 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo
Returns:
Match object of the best match, or None if no match found
"""
import re
# Find all matches
matches = list(re.finditer(pattern, text, flags))
@ -117,7 +122,8 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo
return matches[0]
# For patterns that look like they're trying to match closing braces at end of lines
is_closing_brace_pattern = '}' in pattern and ('$' in pattern or pattern.endswith(r'\s*'))
is_closing_brace_pattern = '}' in pattern and (
'$' in pattern or pattern.endswith(r'\s*'))
if is_closing_brace_pattern and prefer_last:
# Use heuristics to find the best closing brace match
@ -166,11 +172,13 @@ def _find_best_closing_brace_match(matches, text: str):
indentation = len(line_content) - len(line_content.lstrip())
# Prefer lower indentation (class braces are typically less indented than method braces)
score += max(0, 20 - indentation) # Max 20 points for indentation=0
# Max 20 points for indentation=0
score += max(0, 20 - indentation)
# Prefer matches closer to end of file (class closing braces are typically at the end)
distance_from_end = len(lines) - line_num
score += max(0, 10 - distance_from_end) # More points for being closer to end
# More points for being closer to end
score += max(0, 10 - distance_from_end)
# Look at surrounding context to avoid method braces
context_start = max(0, line_num - 3)
@ -209,8 +217,7 @@ def _extract_code_after(keyword: str, request: str) -> str:
# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]:
def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
"""Best-effort normalization of script "name" and "path".
Accepts any of:
@ -258,7 +265,8 @@ def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]:
parts = candidate.split("/")
file_name = parts[-1]
dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name
base = file_name[:-
3] if file_name.lower().endswith(".cs") else file_name
return base, dir_path
# Fall back: remove extension from name if present and return given path
@ -266,7 +274,7 @@ def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]:
return base_name, (p or "Assets")
def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any:
def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any:
if not isinstance(resp, dict):
return resp
data = resp.setdefault("data", {})
@ -276,10 +284,11 @@ def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing:
return resp
def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None,
normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {"success": False, "code": code, "message": message}
data: Dict[str, Any] = {}
def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None,
normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
payload: dict[str, Any] = {"success": False,
"code": code, "message": message}
data: dict[str, Any] = {}
if expected:
data["expected"] = expected
if rewrite:
@ -298,72 +307,73 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew
def register_manage_script_edits_tools(mcp: FastMCP):
@mcp.tool(description=(
"Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n"
"Best practices:\n"
"- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n"
"- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n"
"- Avoid whole-file regex deletes; validators will guard unbalanced braces\n"
"- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n"
"- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n"
"Canonical fields (use these exact keys):\n"
"- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n"
"- className: string (defaults to 'name' if omitted on method/class ops)\n"
"- methodName: string (required for replace_method, delete_method)\n"
"- replacement: string (required for replace_method, insert_method)\n"
"- position: start | end | after | before (insert_method only)\n"
"- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n"
"- anchor: regex string (for anchor_* ops)\n"
"- text: string (for anchor_insert/anchor_replace)\n\n"
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n"
"Examples:\n"
"1) Replace a method:\n"
"{\n"
" \"name\": \"SmartReach\",\n"
" \"path\": \"Assets/Scripts/Interaction\",\n"
" \"edits\": [\n"
" {\n"
" \"op\": \"replace_method\",\n"
" \"className\": \"SmartReach\",\n"
" \"methodName\": \"HasTarget\",\n"
" \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n"
" }\n"
" ],\n"
" \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n"
"}\n\n"
"2) Insert a method after another:\n"
"{\n"
" \"name\": \"SmartReach\",\n"
" \"path\": \"Assets/Scripts/Interaction\",\n"
" \"edits\": [\n"
" {\n"
" \"op\": \"insert_method\",\n"
" \"className\": \"SmartReach\",\n"
" \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n"
" \"position\": \"after\",\n"
" \"afterMethodName\": \"GetCurrentTarget\"\n"
" }\n"
" ]\n"
"}\n\n"
"Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n"
@mcp.tool(name="script_apply_edits", description=(
"""Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
Best practices:
- Prefer anchor_* ops for pattern-based insert/replace near stable markers
- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
- Avoid whole-file regex deletes; validators will guard unbalanced braces
- For tail insertions, prefer anchor/regex_replace on final brace (class closing)
- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
Canonical fields (use these exact keys):
- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
- className: string (defaults to 'name' if omitted on method/class ops)
- methodName: string (required for replace_method, delete_method)
- replacement: string (required for replace_method, insert_method)
- position: start | end | after | before (insert_method only)
- afterMethodName / beforeMethodName: string (required when position='after'/'before')
- anchor: regex string (for anchor_* ops)
- text: string (for anchor_insert/anchor_replace)
Examples:
1) Replace a method:
{
"name": "SmartReach",
"path": "Assets/Scripts/Interaction",
"edits": [
{
"op": "replace_method",
"className": "SmartReach",
"methodName": "HasTarget",
"replacement": "public bool HasTarget(){ return currentTarget!=null; }"
}
],
"options": {"validate": "standard", "refresh": "immediate"}
}
"2) Insert a method after another:
{
"name": "SmartReach",
"path": "Assets/Scripts/Interaction",
"edits": [
{
"op": "insert_method",
"className": "SmartReach",
"replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
"position": "after",
"afterMethodName": "GetCurrentTarget"
}
],
}
]"""
))
@telemetry_tool("script_apply_edits")
def script_apply_edits(
ctx: Context,
name: str,
path: str,
edits: List[Dict[str, Any]],
options: Optional[Dict[str, Any]] = None,
script_type: str = "MonoBehaviour",
namespace: str = "",
) -> Dict[str, Any]:
name: Annotated[str, "Name of the script to edit"],
path: Annotated[str, "Path to the script to edit under Assets/ directory"],
edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"],
options: Annotated[dict[str, Any],
"Options for the script edit"] | None = None,
script_type: Annotated[str,
"Type of the script to edit"] = "MonoBehaviour",
namespace: Annotated[str,
"Namespace of the script to edit"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing script_apply_edits: {name}")
# Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path)
# No NL path: clients must provide structured edits in 'edits'.
# Normalize unsupported or aliased ops to known structured/text paths
def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]:
def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]:
# Unwrap single-key wrappers like {"replace_method": {...}}
for wrapper_key in (
"replace_method", "insert_method", "delete_method",
@ -377,7 +387,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
break
e = dict(edit)
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
op = (e.get("op") or e.get("operation") or e.get(
"type") or e.get("mode") or "").strip().lower()
if op:
e["op"] = op
@ -452,10 +463,11 @@ def register_manage_script_edits_tools(mcp: FastMCP):
e["text"] = edit.get("newText", "")
return e
normalized_edits: List[Dict[str, Any]] = []
normalized_edits: list[dict[str, Any]] = []
for raw in edits or []:
e = _unwrap_and_alias(raw)
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
op = (e.get("op") or e.get("operation") or e.get(
"type") or e.get("mode") or "").strip().lower()
# Default className to script name if missing on structured method/class ops
if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
@ -475,7 +487,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if "text" in e:
e["replacement"] = e.get("text", "")
elif "insert" in e or "content" in e:
e["replacement"] = e.get("insert") or e.get("content") or ""
e["replacement"] = e.get(
"insert") or e.get("content") or ""
if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
e["op"] = "anchor_delete"
normalized_edits.append(e)
@ -486,7 +499,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
normalized_for_echo = edits
# Validate required fields and produce machine-parsable hints
def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]:
def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]:
return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
for e in edits or []:
@ -495,40 +508,46 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not e.get("methodName"):
return error_with_hint(
"replace_method requires 'methodName'.",
{"op": "replace_method", "required": ["className", "methodName", "replacement"]},
{"op": "replace_method", "required": [
"className", "methodName", "replacement"]},
{"edits[0].methodName": "HasTarget"}
)
if not (e.get("replacement") or e.get("text")):
return error_with_hint(
"replace_method requires 'replacement' (inline or base64).",
{"op": "replace_method", "required": ["className", "methodName", "replacement"]},
{"op": "replace_method", "required": [
"className", "methodName", "replacement"]},
{"edits[0].replacement": "public bool X(){ return true; }"}
)
elif op == "insert_method":
if not (e.get("replacement") or e.get("text")):
return error_with_hint(
"insert_method requires a non-empty 'replacement'.",
{"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
{"op": "insert_method", "required": ["className", "replacement"], "position": {
"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
{"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
)
pos = (e.get("position") or "").lower()
if pos == "after" and not e.get("afterMethodName"):
return error_with_hint(
"insert_method with position='after' requires 'afterMethodName'.",
{"op": "insert_method", "position": {"after_requires": "afterMethodName"}},
{"op": "insert_method", "position": {
"after_requires": "afterMethodName"}},
{"edits[0].afterMethodName": "GetCurrentTarget"}
)
if pos == "before" and not e.get("beforeMethodName"):
return error_with_hint(
"insert_method with position='before' requires 'beforeMethodName'.",
{"op": "insert_method", "position": {"before_requires": "beforeMethodName"}},
{"op": "insert_method", "position": {
"before_requires": "beforeMethodName"}},
{"edits[0].beforeMethodName": "GetCurrentTarget"}
)
elif op == "delete_method":
if not e.get("methodName"):
return error_with_hint(
"delete_method requires 'methodName'.",
{"op": "delete_method", "required": ["className", "methodName"]},
{"op": "delete_method", "required": [
"className", "methodName"]},
{"edits[0].methodName": "PrintSeries"}
)
elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
@ -546,7 +565,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
)
# Decide routing: structured vs text vs mixed
STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"}
STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method",
"insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
TEXT = {"prepend", "append", "replace_range", "regex_replace"}
ops_set = {(e.get("op") or "").lower() for e in edits or []}
all_struct = ops_set.issubset(STRUCT)
@ -558,7 +578,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
opts2 = dict(options or {})
# For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
opts2.setdefault("refresh", "immediate")
params_struct: Dict[str, Any] = {
params_struct: dict[str, Any] = {
"action": "edit",
"name": name,
"path": path,
@ -567,7 +587,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": edits,
"options": opts2,
}
resp_struct = send_command_with_retry("manage_script", params_struct)
resp_struct = send_command_with_retry(
"manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
@ -583,10 +604,12 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not isinstance(read_resp, dict) or not read_resp.get("success"):
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {}
data = read_resp.get("data") or read_resp.get(
"result", {}).get("data") or {}
contents = data.get("contents")
if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
contents = base64.b64decode(data["encodedContents"]).decode("utf-8")
contents = base64.b64decode(
data["encodedContents"]).decode("utf-8")
if contents is None:
return {"success": False, "message": "No contents returned from Unity read."}
@ -595,28 +618,35 @@ def register_manage_script_edits_tools(mcp: FastMCP):
# If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
if mixed:
text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT]
struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT]
text_edits = [e for e in edits or [] if (
e.get("op") or "").lower() in TEXT]
struct_edits = [e for e in edits or [] if (
e.get("op") or "").lower() in STRUCT]
try:
base_text = contents
def line_col_from_index(idx: int) -> Tuple[int, int]:
def line_col_from_index(idx: int) -> tuple[int, int]:
line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx)
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
col = (idx - (last_nl + 1)) + \
1 if last_nl >= 0 else idx + 1
return line, col
at_edits: List[Dict[str, Any]] = []
import re as _re
at_edits: list[dict[str, Any]] = []
for e in text_edits:
opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or ""
opx = (e.get("op") or e.get("operation") or e.get(
"type") or e.get("mode") or "").strip().lower()
text_field = e.get("text") or e.get("insert") or e.get(
"content") or e.get("replacement") or ""
if opx == "anchor_insert":
anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower()
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
flags = re.MULTILINE | (
re.IGNORECASE if e.get("ignore_case") else 0)
try:
# Use improved anchor matching logic
m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True)
m = _find_best_anchor_match(
anchor, base_text, flags, prefer_last=True)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
if not m:
@ -629,7 +659,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not text_field_norm.endswith("\n"):
text_field_norm = text_field_norm + "\n"
sl, sc = line_col_from_index(idx)
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
at_edits.append(
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
# do not mutate base_text when building atomic spans
elif opx == "replace_range":
if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
@ -645,39 +676,44 @@ def register_manage_script_edits_tools(mcp: FastMCP):
elif opx == "regex_replace":
pattern = e.get("pattern") or ""
try:
regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0))
regex_obj = re.compile(pattern, re.MULTILINE | (
re.IGNORECASE if e.get("ignore_case") else 0))
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
m = regex_obj.search(base_text)
if not m:
continue
# Expand $1, $2... in replacement using this match
def _expand_dollars(rep: str, _m=m) -> str:
return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
repl = _expand_dollars(text_field)
sl, sc = line_col_from_index(m.start())
el, ec = line_col_from_index(m.end())
at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
at_edits.append(
{"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
# do not mutate base_text when building atomic spans
elif opx in ("prepend", "append"):
if opx == "prepend":
sl, sc = 1, 1
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
at_edits.append(
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
# prepend can be applied atomically without local mutation
else:
# Insert at true EOF position (handles both \n and \r\n correctly)
eof_idx = len(base_text)
sl, sc = line_col_from_index(eof_idx)
new_text = ("\n" if not base_text.endswith("\n") else "") + text_field
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
new_text = ("\n" if not base_text.endswith(
"\n") else "") + text_field
at_edits.append(
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
# do not mutate base_text when building atomic spans
else:
return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
import hashlib
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
if at_edits:
params_text: Dict[str, Any] = {
params_text: dict[str, Any] = {
"action": "apply_text_edits",
"name": name,
"path": path,
@ -687,7 +723,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"precondition_sha256": sha,
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
}
resp_text = send_command_with_retry("manage_script", params_text)
resp_text = send_command_with_retry(
"manage_script", params_text)
if not (isinstance(resp_text, dict) and resp_text.get("success")):
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
# Optional sentinel reload removed (deprecated)
@ -698,7 +735,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
opts2 = dict(options or {})
# Prefer debounced background refresh unless explicitly overridden
opts2.setdefault("refresh", "debounced")
params_struct: Dict[str, Any] = {
params_struct: dict[str, Any] = {
"action": "edit",
"name": name,
"path": path,
@ -707,7 +744,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": struct_edits,
"options": opts2
}
resp_struct = send_command_with_retry("manage_script", params_struct)
resp_struct = send_command_with_retry(
"manage_script", params_struct)
if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
@ -717,32 +755,40 @@ def register_manage_script_edits_tools(mcp: FastMCP):
# If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
# so header guards and validation run on the C# side.
# Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) }
structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"}
text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
"mode") or "").strip().lower() for e in (edits or [])}
structured_kinds = {"replace_class", "delete_class",
"replace_method", "delete_method", "insert_method", "anchor_insert"}
if not text_ops.issubset(structured_kinds):
# Convert to apply_text_edits payload
try:
base_text = contents
def line_col_from_index(idx: int) -> Tuple[int, int]:
def line_col_from_index(idx: int) -> tuple[int, int]:
# 1-based line/col against base buffer
line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx)
col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1
col = (idx - (last_nl + 1)) + \
1 if last_nl >= 0 else idx + 1
return line, col
at_edits: List[Dict[str, Any]] = []
at_edits: list[dict[str, Any]] = []
import re as _re
for e in edits or []:
op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower()
op = (e.get("op") or e.get("operation") or e.get(
"type") or e.get("mode") or "").strip().lower()
# aliasing for text field
text_field = e.get("text") or e.get("insert") or e.get("content") or ""
text_field = e.get("text") or e.get(
"insert") or e.get("content") or ""
if op == "anchor_insert":
anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower()
# Use improved anchor matching logic with helpful errors, honoring ignore_case
try:
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True)
flags = re.MULTILINE | (
re.IGNORECASE if e.get("ignore_case") else 0)
m = _find_best_anchor_match(
anchor, base_text, flags, prefer_last=True)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
if not m:
@ -778,19 +824,22 @@ def register_manage_script_edits_tools(mcp: FastMCP):
elif op == "regex_replace":
pattern = e.get("pattern") or ""
repl = text_field
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)
flags = re.MULTILINE | (
re.IGNORECASE if e.get("ignore_case") else 0)
# Early compile for clearer error messages
try:
regex_obj = _re.compile(pattern, flags)
regex_obj = re.compile(pattern, flags)
except Exception as ex:
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
# Use smart anchor matching for consistent behavior with anchor_insert
m = _find_best_anchor_match(pattern, base_text, flags, prefer_last=True)
m = _find_best_anchor_match(
pattern, base_text, flags, prefer_last=True)
if not m:
continue
# Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
def _expand_dollars(rep: str, _m=m) -> str:
return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
repl_expanded = _expand_dollars(repl)
# Let C# side handle validation using Unity's built-in compiler services
sl, sc = line_col_from_index(m.start())
@ -809,10 +858,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not at_edits:
return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
# Send to Unity with precondition SHA to enforce guards and immediate refresh
import hashlib
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
params: Dict[str, Any] = {
params: dict[str, Any] = {
"action": "apply_text_edits",
"name": name,
"path": path,
@ -830,7 +877,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated)
return _with_norm(
resp if isinstance(resp, dict) else {"success": False, "message": str(resp)},
resp if isinstance(resp, dict) else {
"success": False, "message": str(resp)},
normalized_for_echo,
routing="text"
)
@ -843,7 +891,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
try:
preview_text = _apply_edits_locally(contents, edits)
import difflib
diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
diff = list(difflib.unified_diff(contents.splitlines(
), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
if len(diff) > 800:
diff = diff[:800] + ["... (diff truncated) ..."]
if preview:
@ -870,7 +919,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
import difflib
a = contents.splitlines()
b = new_contents.splitlines()
diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3))
diff = list(difflib.unified_diff(
a, b, fromfile="before", tofile="after", n=3))
# Limit diff size to keep responses small
if len(diff) > 2000:
diff = diff[:2000] + ["... (diff truncated) ..."]
@ -882,7 +932,6 @@ def register_manage_script_edits_tools(mcp: FastMCP):
options.setdefault("validate", "standard")
options.setdefault("refresh", "debounced")
import hashlib
# Compute the SHA of the current file contents for the precondition
old_lines = contents.splitlines(keepends=True)
end_line = len(old_lines) + 1 # 1-based exclusive end
@ -917,8 +966,3 @@ def register_manage_script_edits_tools(mcp: FastMCP):
normalized_for_echo,
routing="text",
)
# safe_script_edit removed to simplify API; clients should call script_apply_edits directly

View File

@ -1,36 +1,26 @@
from mcp.server.fastmcp import FastMCP, Context
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
import os
import base64
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_shader_tools(mcp: FastMCP):
"""Register all shader script management tools with the MCP server."""
@mcp.tool()
@mcp.tool(name="manage_shader", description="Manages shader scripts in Unity (create, read, update, delete).")
@telemetry_tool("manage_shader")
def manage_shader(
ctx: Any,
action: str,
name: str,
path: str,
contents: str,
) -> Dict[str, Any]:
"""Manages shader scripts in Unity (create, read, update, delete).
Args:
action: Operation ('create', 'read', 'update', 'delete').
name: Shader name (no .cs extension).
path: Asset path (default: "Assets/").
contents: Shader code for 'create'/'update'.
Returns:
Dictionary with results ('success', 'message', 'data').
"""
ctx: Context,
action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."],
name: Annotated[str, "Shader name (no .cs extension)"],
path: Annotated[str, "Asset path (default: \"Assets/\")"],
contents: Annotated[str,
"Shader code for 'create'/'update'"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing manage_shader: {action}")
try:
# Prepare parameters for Unity
params = {
@ -43,7 +33,8 @@ def register_manage_shader_tools(mcp: FastMCP):
if contents is not None:
if action in ['create', 'update']:
# Encode content for safer transmission
params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8')
params["encodedContents"] = base64.b64encode(
contents.encode('utf-8')).decode('utf-8')
params["contentsEncoded"] = True
else:
params["contents"] = contents
@ -58,7 +49,8 @@ def register_manage_shader_tools(mcp: FastMCP):
if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"):
decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8')
decoded_contents = base64.b64decode(
response["data"]["encodedContents"]).decode('utf-8')
response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"]

View File

@ -1,47 +1,34 @@
"""
Defines the read_console tool for accessing Unity Editor console messages.
"""
from typing import List, Dict, Any
import time
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_read_console_tools(mcp: FastMCP):
"""Registers the read_console tool with the MCP server."""
@mcp.tool()
@mcp.tool(name="read_console", description="Gets messages from or clears the Unity Editor console.")
@telemetry_tool("read_console")
def read_console(
ctx: Context,
action: str = None,
types: List[str] = None,
count: Any = None,
filter_text: str = None,
since_timestamp: str = None,
format: str = None,
include_stacktrace: bool = None
) -> Dict[str, Any]:
"""Gets messages from or clears the Unity Editor console.
Args:
ctx: The MCP context.
action: Operation ('get' or 'clear').
types: Message types to get ('error', 'warning', 'log', 'all').
count: Max messages to return.
filter_text: Text filter for messages.
since_timestamp: Get messages after this timestamp (ISO 8601).
format: Output format ('plain', 'detailed', 'json').
include_stacktrace: Include stack traces in output.
Returns:
Dictionary with results. For 'get', includes 'data' (messages).
"""
# Get the connection instance
bridge = get_unity_connection()
action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."],
types: Annotated[list[Literal['error', 'warning',
'log', 'all']], "Message types to get"] | None = None,
count: Annotated[int, "Max messages to return"] | None = None,
filter_text: Annotated[str, "Text filter for messages"] | None = None,
since_timestamp: Annotated[str,
"Get messages after this timestamp (ISO 8601)"] | None = None,
format: Annotated[Literal['plain', 'detailed',
'json'], "Output format"] | None = None,
include_stacktrace: Annotated[bool,
"Include stack traces in output"] | None = None
) -> dict[str, Any]:
ctx.info(f"Processing read_console: {action}")
# Set defaults if values are None
action = action if action is not None else 'get'
types = types if types is not None else ['error', 'warning', 'log']
@ -82,7 +69,8 @@ def register_read_console_tools(mcp: FastMCP):
}
# Remove None values unless it's 'count' (as None might mean 'all')
params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'}
params_dict = {k: v for k, v in params_dict.items()
if v is not None or k == 'count'}
# Add count back if it was None, explicitly sending null might be important for C# logic
if 'count' not in params_dict:

View File

@ -3,21 +3,21 @@ Resource wrapper tools so clients that do not expose MCP resources primitives
can still list and read files via normal tools. These call into the same
safe path logic (re-implemented here to avoid importing server.py).
"""
from typing import Dict, Any, List, Optional
import re
from pathlib import Path
from urllib.parse import urlparse, unquote
import fnmatch
import hashlib
import os
from pathlib import Path
import re
from typing import Annotated, Any
from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]:
def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None:
"""Safely coerce various inputs (str/float/etc.) to an int.
Returns default on failure; clamps to minimum when provided.
"""
@ -41,6 +41,7 @@ def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int
except Exception:
return default
def _resolve_project_root(override: str | None) -> Path:
# 1) Explicit override
if override:
@ -52,14 +53,17 @@ def _resolve_project_root(override: str | None) -> Path:
if env:
env_path = Path(env).expanduser()
# If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir
pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve()
pr = (Path.cwd(
) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve()
if (pr / "Assets").exists():
return pr
# 3) Ask Unity via manage_editor.get_project_root
try:
resp = send_command_with_retry("manage_editor", {"action": "get_project_root"})
resp = send_command_with_retry(
"manage_editor", {"action": "get_project_root"})
if isinstance(resp, dict) and resp.get("success"):
pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve()
pr = Path(resp.get("data", {}).get(
"projectRoot", "")).expanduser().resolve()
if pr and (pr / "Assets").exists():
return pr
except Exception:
@ -132,26 +136,17 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None:
def register_resource_tools(mcp: FastMCP) -> None:
"""Registers list_resources and read_resource wrapper tools."""
@mcp.tool(description=(
"List project URIs (unity://path/...) under a folder (default: Assets).\n\n"
"Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n"
"Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n"
"Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n"
))
@mcp.tool(name="list_resources", description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n"))
@telemetry_tool("list_resources")
async def list_resources(
ctx: Optional[Context] = None,
pattern: Optional[str] = "*.cs",
under: str = "Assets",
limit: Any = 200,
project_root: Optional[str] = None,
) -> Dict[str, Any]:
"""
Lists project URIs (unity://path/...) under a folder (default: Assets).
- pattern: glob like *.cs or *.shader (None to list all files)
- under: relative folder under project root
- limit: max results
"""
ctx: Context,
pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs",
under: Annotated[str,
"Folder under project root, default is Assets"] = "Assets",
limit: Annotated[int, "Page limit"] = 200,
project_root: Annotated[str, "Project path"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing list_resources: {pattern}")
try:
project = _resolve_project_root(project_root)
base = (project / under).resolve()
@ -165,7 +160,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
except ValueError:
return {"success": False, "error": "Listing is restricted to Assets/"}
matches: List[str] = []
matches: list[str] = []
limit_int = _coerce_int(limit, default=200, minimum=1)
for p in base.rglob("*"):
if not p.is_file():
@ -194,33 +189,30 @@ def register_resource_tools(mcp: FastMCP) -> None:
except Exception as e:
return {"success": False, "error": str(e)}
@mcp.tool(description=(
"Read a resource by unity://path/... URI with optional slicing.\n\n"
"Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n"
"Security: uri must resolve under Assets/.\n"
"Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n"
))
@mcp.tool(name="read_resource", description=("Reads a resource by unity://path/... URI with optional slicing."))
@telemetry_tool("read_resource")
async def read_resource(
uri: str,
ctx: Optional[Context] = None,
start_line: Any = None,
line_count: Any = None,
head_bytes: Any = None,
tail_lines: Any = None,
project_root: Optional[str] = None,
request: Optional[str] = None,
) -> Dict[str, Any]:
"""
Reads a resource by unity://path/... URI with optional slicing.
One of line window (start_line/line_count) or head_bytes can be used to limit size.
"""
ctx: Context,
uri: Annotated[str, "The resource URI to read under Assets/"],
start_line: Annotated[int,
"The starting line number (0-based)"] | None = None,
line_count: Annotated[int,
"The number of lines to read"] | None = None,
head_bytes: Annotated[int,
"The number of bytes to read from the start of the file"] | None = None,
tail_lines: Annotated[int,
"The number of lines to read from the end of the file"] | None = None,
project_root: Annotated[str,
"The project root directory"] | None = None,
request: Annotated[str, "The request ID"] | None = None,
) -> dict[str, Any]:
ctx.info(f"Processing read_resource: {uri}")
try:
# Serve the canonical spec directly when requested (allow bare or with scheme)
if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"):
spec_json = (
'{\n'
' "name": "Unity MCP Script Edits v1",\n'
' "name": "Unity MCP - Script Edits v1",\n'
' "target_tool": "script_apply_edits",\n'
' "canonical_rules": {\n'
' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n'
@ -300,14 +292,16 @@ def register_resource_tools(mcp: FastMCP) -> None:
m = re.search(r"first\s+(\d+)\s*bytes", req)
if m:
head_bytes = int(m.group(1))
m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req)
m = re.search(
r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req)
if m:
window = int(m.group(1))
method = m.group(2)
# naive search for method header to get a line number
text_all = p.read_text(encoding="utf-8")
lines_all = text_all.splitlines()
pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE)
pat = re.compile(
rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE)
hit_line = None
for i, line in enumerate(lines_all, start=1):
if pat.search(line):
@ -329,7 +323,8 @@ def register_resource_tools(mcp: FastMCP) -> None:
full_sha = hashlib.sha256(full_bytes).hexdigest()
# Selection only when explicitly requested via windowing args or request text hints
selection_requested = bool(head_bytes or tail_lines or (start_line is not None and line_count is not None) or request)
selection_requested = bool(head_bytes or tail_lines or (
start_line is not None and line_count is not None) or request)
if selection_requested:
# Mutually exclusive windowing options precedence:
# 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text
@ -354,24 +349,19 @@ def register_resource_tools(mcp: FastMCP) -> None:
except Exception as e:
return {"success": False, "error": str(e)}
@mcp.tool()
@mcp.tool(name="find_in_file", description="Searches a file with a regex pattern and returns line numbers and excerpts.")
@telemetry_tool("find_in_file")
async def find_in_file(
uri: str,
pattern: str,
ctx: Optional[Context] = None,
ignore_case: Optional[bool] = True,
project_root: Optional[str] = None,
max_results: Any = 200,
) -> Dict[str, Any]:
"""
Searches a file with a regex pattern and returns line numbers and excerpts.
- uri: unity://path/Assets/... or file path form supported by read_resource
- pattern: regular expression (Python re)
- ignore_case: case-insensitive by default
- max_results: cap results to avoid huge payloads
"""
# re is already imported at module level
ctx: Context,
uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
pattern: Annotated[str, "The regex pattern to search for"],
ignore_case: Annotated[bool, "Case-insensitive search"] | None = True,
project_root: Annotated[str,
"The project root directory"] | None = None,
max_results: Annotated[int,
"Cap results to avoid huge payloads"] = 200,
) -> dict[str, Any]:
ctx.info(f"Processing find_in_file: {uri}")
try:
project = _resolve_project_root(project_root)
p = _resolve_safe_path_from_uri(uri, project)
@ -404,5 +394,3 @@ def register_resource_tools(mcp: FastMCP) -> None:
return {"success": True, "data": {"matches": results, "count": len(results)}}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@ -1,17 +1,18 @@
from config import config
import contextlib
from dataclasses import dataclass
import errno
import json
import logging
from pathlib import Path
from port_discovery import PortDiscovery
import random
import socket
import struct
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config
logging.basicConfig(
@ -26,6 +27,7 @@ _connection_lock = threading.Lock()
# Maximum allowed framed payload size (64 MiB)
FRAMED_MAX = 64 * 1024 * 1024
@dataclass
class UnityConnection:
"""Manages the socket connection to the Unity Editor."""
@ -50,11 +52,14 @@ class UnityConnection:
return True
try:
# Bounded connect to avoid indefinite blocking
connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0)))
self.sock = socket.create_connection((self.host, self.port), connect_timeout)
connect_timeout = float(
getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0)))
self.sock = socket.create_connection(
(self.host, self.port), connect_timeout)
# Disable Nagle's algorithm to reduce small RPC latency
with contextlib.suppress(Exception):
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.sock.setsockopt(
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
logger.debug(f"Connected to Unity at {self.host}:{self.port}")
# Strict handshake: require FRAMING=1
@ -78,16 +83,20 @@ class UnityConnection:
if 'FRAMING=1' in text:
self.use_framing = True
logger.debug('Unity MCP handshake received: FRAMING=1 (strict)')
logger.debug(
'Unity MCP handshake received: FRAMING=1 (strict)')
else:
if require_framing:
# Best-effort plain-text advisory for legacy peers
with contextlib.suppress(Exception):
self.sock.sendall(b'Unity MCP requires FRAMING=1\n')
raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}')
self.sock.sendall(
b'Unity MCP requires FRAMING=1\n')
raise ConnectionError(
f'Unity MCP requires FRAMING=1, got: {text!r}')
else:
self.use_framing = False
logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration')
logger.warning(
'Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration')
finally:
self.sock.settimeout(config.connection_timeout)
return True
@ -116,7 +125,8 @@ class UnityConnection:
while len(data) < count:
chunk = sock.recv(count - len(data))
if not chunk:
raise ConnectionError("Connection closed before reading expected bytes")
raise ConnectionError(
"Connection closed before reading expected bytes")
data.extend(chunk)
return bytes(data)
@ -136,13 +146,16 @@ class UnityConnection:
heartbeat_count += 1
if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline:
# Treat as empty successful response to match C# server behavior
logger.debug("Heartbeat threshold reached; returning empty response")
logger.debug(
"Heartbeat threshold reached; returning empty response")
return b""
continue
if payload_len > FRAMED_MAX:
raise ValueError(f"Invalid framed length: {payload_len}")
raise ValueError(
f"Invalid framed length: {payload_len}")
payload = self._read_exact(sock, payload_len)
logger.debug(f"Received framed response ({len(payload)} bytes)")
logger.debug(
f"Received framed response ({len(payload)} bytes)")
return payload
except socket.timeout as e:
logger.warning("Socket timeout during framed receive")
@ -158,7 +171,8 @@ class UnityConnection:
chunk = sock.recv(buffer_size)
if not chunk:
if not chunks:
raise Exception("Connection closed before receiving data")
raise Exception(
"Connection closed before receiving data")
break
chunks.append(chunk)
@ -182,19 +196,22 @@ class UnityConnection:
# Replace escaped quotes in content with regular quotes
content = decoded_data[content_start:content_end]
content = content.replace('\\"', '"')
decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:]
decoded_data = decoded_data[:content_start] + \
content + decoded_data[content_end:]
# Validate JSON format
json.loads(decoded_data)
# If we get here, we have valid JSON
logger.info(f"Received complete response ({len(data)} bytes)")
logger.info(
f"Received complete response ({len(data)} bytes)")
return data
except json.JSONDecodeError:
# We haven't received a complete valid JSON response yet
continue
except Exception as e:
logger.warning(f"Error processing response chunk: {str(e)}")
logger.warning(
f"Error processing response chunk: {str(e)}")
# Continue reading more chunks as this might not be the complete response
continue
except socket.timeout:
@ -217,7 +234,8 @@ class UnityConnection:
def read_status_file() -> dict | None:
try:
status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
status_files = sorted(Path.home().joinpath(
'.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True)
if not status_files:
return None
latest = status_files[0]
@ -253,7 +271,8 @@ class UnityConnection:
payload = b'ping'
else:
command = {"type": command_type, "params": params or {}}
payload = json.dumps(command, ensure_ascii=False).encode('utf-8')
payload = json.dumps(
command, ensure_ascii=False).encode('utf-8')
# Send/receive are serialized to protect the shared socket
with self._io_lock:
@ -280,7 +299,8 @@ class UnityConnection:
try:
response_data = self.receive_full_response(self.sock)
with contextlib.suppress(Exception):
logger.debug("recv %d bytes; mode=%s", len(response_data), mode)
logger.debug("recv %d bytes; mode=%s",
len(response_data), mode)
finally:
if restore_timeout is not None:
self.sock.settimeout(restore_timeout)
@ -295,11 +315,13 @@ class UnityConnection:
resp = json.loads(response_data.decode('utf-8'))
if resp.get('status') == 'error':
err = resp.get('error') or resp.get('message', 'Unknown Unity error')
err = resp.get('error') or resp.get(
'message', 'Unknown Unity error')
raise Exception(err)
return resp.get('result', {})
except Exception as e:
logger.warning(f"Unity communication attempt {attempt+1} failed: {e}")
logger.warning(
f"Unity communication attempt {attempt+1} failed: {e}")
try:
if self.sock:
self.sock.close()
@ -310,7 +332,8 @@ class UnityConnection:
try:
new_port = PortDiscovery.discover_unity_port()
if new_port != self.port:
logger.info(f"Unity port changed {self.port} -> {new_port}")
logger.info(
f"Unity port changed {self.port} -> {new_port}")
self.port = new_port
except Exception as de:
logger.debug(f"Port discovery failed: {de}")
@ -324,11 +347,13 @@ class UnityConnection:
jitter = random.uniform(0.1, 0.3)
# Fastretry for transient socket failures
fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
fast_error = isinstance(
e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
if not fast_error:
try:
err_no = getattr(e, 'errno', None)
fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT)
fast_error = err_no in (
errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT)
except Exception:
pass
@ -345,9 +370,11 @@ class UnityConnection:
continue
raise
# Global Unity connection
_unity_connection = None
def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection.
@ -366,7 +393,8 @@ def get_unity_connection() -> UnityConnection:
_unity_connection = UnityConnection()
if not _unity_connection.connect():
_unity_connection = None
raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
raise ConnectionError(
"Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.")
logger.info("Connected to Unity on startup")
return _unity_connection
@ -400,7 +428,8 @@ def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_re
response = conn.send_command(command_type, params)
retries = 0
while _is_reloading_response(response) and retries < max_retries:
delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms
delay_ms = int(response.get("retry_after_ms", retry_ms)
) if isinstance(response, dict) else retry_ms
time.sleep(max(0.0, delay_ms / 1000.0))
retries += 1
response = conn.send_command(command_type, params)
@ -415,7 +444,8 @@ async def async_send_command_with_retry(command_type: str, params: Dict[str, Any
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms),
lambda: send_command_with_retry(
command_type, params, max_retries=max_retries, retry_ms=retry_ms),
)
except Exception as e:
# Return a structured error dict for consistency with other responses

View File

@ -1,6 +1,6 @@
{
"name": "com.coplaydev.unity-mcp",
"version": "3.4.0",
"version": "4.1.0",
"displayName": "MCP for Unity",
"description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
"unity": "2021.3",