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" echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION"
sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" 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 - name: Commit and push changes
env: env:
NEW_VERSION: ${{ steps.compute.outputs.new_version }} NEW_VERSION: ${{ steps.compute.outputs.new_version }}
@ -78,7 +81,7 @@ jobs:
set -euo pipefail set -euo pipefail
git config user.name "GitHub Actions" git config user.name "GitHub Actions"
git config user.email "actions@github.com" 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 if git diff --cached --quiet; then
echo "No version changes to commit." echo "No version changes to commit."
else 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 # 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. Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development.
## 🚀 Available Development Features ## 🚀 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" /> <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) #### 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) [![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 curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell) # Windows (PowerShell)
winget install Astral.Sh.Uv winget install --id=astral-sh.uv -e
# Docs: https://docs.astral.sh/uv/getting-started/installation/ # 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)** **Method 1: NuGet for Unity (Recommended)**
1. Install [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity) 1. Install [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)
2. Go to `Window > NuGet Package Manager` 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` 5. Go to `Player Settings > Scripting Define Symbols`
6. Add `USE_ROSLYN` 6. Add `USE_ROSLYN`
7. Restart Unity 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 public class CommandRegistryTests
{ {
[Test] [Test]
public void GetHandler_ReturnsNull_ForUnknownCommand() public void GetHandler_ThrowException_ForUnknownCommand()
{ {
var unknown = "HandleDoesNotExist"; var unknown = "HandleDoesNotExist";
var handler = CommandRegistry.GetHandler(unknown); try
Assert.IsNull(handler, "Expected null handler for unknown command name."); {
var handler = CommandRegistry.GetHandler(unknown);
Assert.Fail("Should throw InvalidOperation for unknown handler.");
}
catch (InvalidOperationException)
{
}
catch
{
Assert.Fail("Should throw InvalidOperation for unknown handler.");
}
} }
[Test] [Test]
public void GetHandler_ReturnsManageGameObjectHandler() public void GetHandler_ReturnsManageGameObjectHandler()
{ {
var handler = CommandRegistry.GetHandler("HandleManageGameObject"); var handler = CommandRegistry.GetHandler("manage_gameobject");
Assert.IsNotNull(handler, "Expected a handler for HandleManageGameObject."); Assert.IsNotNull(handler, "Expected a handler for manage_gameobject.");
var methodInfo = handler.Method; var methodInfo = handler.Method;
Assert.AreEqual("HandleCommand", methodInfo.Name, "Handler method name should be HandleCommand."); 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); EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir);
// Ensure no lock is enabled // Ensure no lock is enabled
EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false);
} }
[TearDown] [TearDown]
@ -54,6 +56,7 @@ namespace MCPForUnityTests.Editor.Windows
// Clean up editor preferences set during SetUp // Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); EditorPrefs.DeleteKey("MCPForUnity.ServerSrc");
EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig");
EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled");
// Remove temp files // Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }

View File

@ -159,6 +159,28 @@ namespace MCPForUnity.Editor.Data
mcpType = McpTypes.Kiro, mcpType = McpTypes.Kiro,
configStatus = "Not Configured", 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 // 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.Models;
using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools;
using MCPForUnity.Editor.Tools.MenuItems; using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor namespace MCPForUnity.Editor
{ {
@ -63,7 +64,7 @@ namespace MCPForUnity.Editor
{ {
try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; }
} }
private static void LogBreadcrumb(string stage) private static void LogBreadcrumb(string stage)
{ {
if (IsDebugEnabled()) if (IsDebugEnabled())
@ -82,7 +83,7 @@ namespace MCPForUnity.Editor
public static void StartAutoConnect() public static void StartAutoConnect()
{ {
Stop(); // Stop current connection Stop(); // Stop current connection
try try
{ {
// Prefer stored project port and start using the robust Start() path (with retries/options) // Prefer stored project port and start using the robust Start() path (with retries/options)
@ -314,7 +315,7 @@ namespace MCPForUnity.Editor
const int maxImmediateRetries = 3; const int maxImmediateRetries = 3;
const int retrySleepMs = 75; const int retrySleepMs = 75;
int attempt = 0; int attempt = 0;
for (;;) for (; ; )
{ {
try try
{ {
@ -755,7 +756,7 @@ namespace MCPForUnity.Editor
{ {
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
ulong payloadLen = ReadUInt64BigEndian(header); ulong payloadLen = ReadUInt64BigEndian(header);
if (payloadLen > MaxFrameBytes) if (payloadLen > MaxFrameBytes)
{ {
throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); throw new System.IO.IOException($"Invalid framed length: {payloadLen}");
} }
@ -1039,26 +1040,7 @@ namespace MCPForUnity.Editor
// Use JObject for parameters as the new handlers likely expect this // Use JObject for parameters as the new handlers likely expect this
JObject paramsObject = command.@params ?? new JObject(); JObject paramsObject = command.@params ?? new JObject();
object result = CommandRegistry.GetHandler(command.type)(paramsObject);
// 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}"
),
};
// Standard success response format // Standard success response format
var response = new { status = "success", result }; var response = new { status = "success", result };

View File

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

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Tools.MenuItems; using MCPForUnity.Editor.Tools.MenuItems;
using MCPForUnity.Editor.Tools.Prefabs;
namespace MCPForUnity.Editor.Tools namespace MCPForUnity.Editor.Tools
{ {
@ -14,14 +15,15 @@ namespace MCPForUnity.Editor.Tools
// to the corresponding static HandleCommand method in the appropriate tool class. // to the corresponding static HandleCommand method in the appropriate tool class.
private static readonly Dictionary<string, Func<JObject, object>> _handlers = new() private static readonly Dictionary<string, Func<JObject, object>> _handlers = new()
{ {
{ "HandleManageScript", ManageScript.HandleCommand }, { "manage_script", ManageScript.HandleCommand },
{ "HandleManageScene", ManageScene.HandleCommand }, { "manage_scene", ManageScene.HandleCommand },
{ "HandleManageEditor", ManageEditor.HandleCommand }, { "manage_editor", ManageEditor.HandleCommand },
{ "HandleManageGameObject", ManageGameObject.HandleCommand }, { "manage_gameobject", ManageGameObject.HandleCommand },
{ "HandleManageAsset", ManageAsset.HandleCommand }, { "manage_asset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand }, { "read_console", ReadConsole.HandleCommand },
{ "HandleManageMenuItem", ManageMenuItem.HandleCommand }, { "manage_menu_item", ManageMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand}, { "manage_shader", ManageShader.HandleCommand},
{ "manage_prefabs", ManagePrefabs.HandleCommand},
}; };
/// <summary> /// <summary>
@ -31,17 +33,18 @@ namespace MCPForUnity.Editor.Tools
/// <returns>The command handler function if found, null otherwise.</returns> /// <returns>The command handler function if found, null otherwise.</returns>
public static Func<JObject, object> GetHandler(string commandName) public static Func<JObject, object> GetHandler(string commandName)
{ {
// Use case-insensitive comparison for flexibility, although Python side should be consistent if (!_handlers.TryGetValue(commandName, out var handler))
return _handlers.TryGetValue(commandName, out var handler) ? handler : null; {
// Consider adding logging here if a handler is not found throw new InvalidOperationException(
/* $"Unknown or unsupported command type: {commandName}");
if (_handlers.TryGetValue(commandName, out var handler)) {
return handler;
} else {
UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\");
return null;
} }
*/
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)) if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for reimport."); return Response.Error("'path' is required for reimport.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath)) if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}"); return Response.Error($"Asset not found at path: {fullPath}");
@ -154,7 +154,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(assetType)) if (string.IsNullOrEmpty(assetType))
return Response.Error("'assetType' is required for create."); return Response.Error("'assetType' is required for create.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string directory = Path.GetDirectoryName(fullPath); string directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists // Ensure directory exists
@ -280,7 +280,7 @@ namespace MCPForUnity.Editor.Tools
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for create_folder."); return Response.Error("'path' is required for create_folder.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string parentDir = Path.GetDirectoryName(fullPath); string parentDir = Path.GetDirectoryName(fullPath);
string folderName = Path.GetFileName(fullPath); string folderName = Path.GetFileName(fullPath);
@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools
if (properties == null || !properties.HasValues) if (properties == null || !properties.HasValues)
return Response.Error("'properties' are required for modify."); return Response.Error("'properties' are required for modify.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath)) if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}"); return Response.Error($"Asset not found at path: {fullPath}");
@ -372,7 +372,7 @@ namespace MCPForUnity.Editor.Tools
{ {
targetComponent = gameObject.GetComponent(compType); targetComponent = gameObject.GetComponent(compType);
} }
// Only warn about resolution failure if component also not found // Only warn about resolution failure if component also not found
if (targetComponent == null && !resolved) if (targetComponent == null && !resolved)
{ {
@ -495,7 +495,7 @@ namespace MCPForUnity.Editor.Tools
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for delete."); return Response.Error("'path' is required for delete.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath)) if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}"); return Response.Error($"Asset not found at path: {fullPath}");
@ -526,7 +526,7 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for duplicate."); return Response.Error("'path' is required for duplicate.");
string sourcePath = SanitizeAssetPath(path); string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(sourcePath)) if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}"); return Response.Error($"Source asset not found at path: {sourcePath}");
@ -538,7 +538,7 @@ namespace MCPForUnity.Editor.Tools
} }
else else
{ {
destPath = SanitizeAssetPath(destinationPath); destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (AssetExists(destPath)) if (AssetExists(destPath))
return Response.Error($"Asset already exists at destination path: {destPath}"); return Response.Error($"Asset already exists at destination path: {destPath}");
// Ensure destination directory exists // Ensure destination directory exists
@ -576,8 +576,8 @@ namespace MCPForUnity.Editor.Tools
if (string.IsNullOrEmpty(destinationPath)) if (string.IsNullOrEmpty(destinationPath))
return Response.Error("'destination' path is required for move/rename."); return Response.Error("'destination' path is required for move/rename.");
string sourcePath = SanitizeAssetPath(path); string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
string destPath = SanitizeAssetPath(destinationPath); string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (!AssetExists(sourcePath)) if (!AssetExists(sourcePath))
return Response.Error($"Source asset not found at path: {sourcePath}"); return Response.Error($"Source asset not found at path: {sourcePath}");
@ -642,7 +642,7 @@ namespace MCPForUnity.Editor.Tools
string[] folderScope = null; string[] folderScope = null;
if (!string.IsNullOrEmpty(pathScope)) if (!string.IsNullOrEmpty(pathScope))
{ {
folderScope = new string[] { SanitizeAssetPath(pathScope) }; folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) };
if (!AssetDatabase.IsValidFolder(folderScope[0])) if (!AssetDatabase.IsValidFolder(folderScope[0]))
{ {
// Maybe the user provided a file path instead of a folder? // Maybe the user provided a file path instead of a folder?
@ -732,7 +732,7 @@ namespace MCPForUnity.Editor.Tools
{ {
if (string.IsNullOrEmpty(path)) if (string.IsNullOrEmpty(path))
return Response.Error("'path' is required for get_info."); return Response.Error("'path' is required for get_info.");
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath)) if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {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."); return Response.Error("'path' is required for get_components.");
// 2. Sanitize and check existence // 2. Sanitize and check existence
string fullPath = SanitizeAssetPath(path); string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath)) if (!AssetExists(fullPath))
return Response.Error($"Asset not found at path: {fullPath}"); return Response.Error($"Asset not found at path: {fullPath}");
@ -829,18 +829,6 @@ namespace MCPForUnity.Editor.Tools
/// <summary> /// <summary>
/// Ensures the asset path starts with "Assets/". /// Ensures the asset path starts with "Assets/".
/// </summary> /// </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> /// <summary>
/// Checks if an asset exists at the given path (file or folder). /// Checks if an asset exists at the given path (file or folder).
/// </summary> /// </summary>
@ -930,16 +918,18 @@ 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"; string propName = "_Color";
try { try
{
if (colorArr.Count >= 3) if (colorArr.Count >= 3)
{ {
Color newColor = new Color( Color newColor = new Color(
colorArr[0].ToObject<float>(), colorArr[0].ToObject<float>(),
colorArr[1].ToObject<float>(), colorArr[1].ToObject<float>(),
colorArr[2].ToObject<float>(), colorArr[2].ToObject<float>(),
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f
); );
if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor)
@ -948,8 +938,9 @@ namespace MCPForUnity.Editor.Tools
modified = true; modified = true;
} }
} }
} }
catch (Exception ex) { catch (Exception ex)
{
Debug.LogWarning( Debug.LogWarning(
$"Error parsing color property '{propName}': {ex.Message}" $"Error parsing color property '{propName}': {ex.Message}"
); );
@ -989,7 +980,7 @@ namespace MCPForUnity.Editor.Tools
if (!string.IsNullOrEmpty(texPath)) if (!string.IsNullOrEmpty(texPath))
{ {
Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>( Texture newTex = AssetDatabase.LoadAssetAtPath<Texture>(
SanitizeAssetPath(texPath) AssetPathUtility.SanitizeAssetPath(texPath)
); );
if ( if (
newTex != null newTex != null
@ -1217,7 +1208,7 @@ namespace MCPForUnity.Editor.Tools
&& token.Type == JTokenType.String && token.Type == JTokenType.String
) )
{ {
string assetPath = SanitizeAssetPath(token.ToString()); string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(
assetPath, assetPath,
targetType targetType
@ -1337,4 +1328,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -5,8 +5,9 @@ using System.IO;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEditorInternal; // Required for tag management using UnityEditorInternal; // Required for tag management
using UnityEditor.SceneManagement;
using UnityEngine; using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools namespace MCPForUnity.Editor.Tools
{ {
@ -98,6 +99,8 @@ namespace MCPForUnity.Editor.Tools
return GetActiveTool(); return GetActiveTool();
case "get_selection": case "get_selection":
return GetSelection(); return GetSelection();
case "get_prefab_stage":
return GetPrefabStageInfo();
case "set_active_tool": case "set_active_tool":
string toolName = @params["toolName"]?.ToString(); string toolName = @params["toolName"]?.ToString();
if (string.IsNullOrEmpty(toolName)) if (string.IsNullOrEmpty(toolName))
@ -140,7 +143,7 @@ namespace MCPForUnity.Editor.Tools
default: default:
return Response.Error( 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() private static object GetActiveTool()
{ {
try try
@ -610,4 +642,3 @@ namespace MCPForUnity.Editor.Tools
} }
} }
} }

View File

@ -90,7 +90,7 @@ namespace MCPForUnity.Editor.Tools
return false; return false;
} }
var atAssets = string.Equals( var atAssets = string.Equals(
di.FullName.Replace('\\','/'), di.FullName.Replace('\\', '/'),
assets, assets,
StringComparison.OrdinalIgnoreCase StringComparison.OrdinalIgnoreCase
); );
@ -115,7 +115,7 @@ namespace MCPForUnity.Editor.Tools
{ {
return Response.Error("invalid_params", "Parameters cannot be null."); return Response.Error("invalid_params", "Parameters cannot be null.");
} }
// Extract parameters // Extract parameters
string action = @params["action"]?.ToString()?.ToLower(); string action = @params["action"]?.ToString()?.ToLower();
string name = @params["name"]?.ToString(); string name = @params["name"]?.ToString();
@ -207,81 +207,81 @@ namespace MCPForUnity.Editor.Tools
case "delete": case "delete":
return DeleteScript(fullPath, relativePath); return DeleteScript(fullPath, relativePath);
case "apply_text_edits": case "apply_text_edits":
{ {
var textEdits = @params["edits"] as JArray; var textEdits = @params["edits"] as JArray;
string precondition = @params["precondition_sha256"]?.ToString(); string precondition = @params["precondition_sha256"]?.ToString();
// Respect optional options // Respect optional options
string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant();
string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant();
return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt);
} }
case "validate": case "validate":
{
string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard";
var chosen = level switch
{ {
"basic" => ValidationLevel.Basic, string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard";
"standard" => ValidationLevel.Standard, var chosen = level switch
"strict" => ValidationLevel.Strict, {
"comprehensive" => ValidationLevel.Comprehensive, "basic" => ValidationLevel.Basic,
_ => ValidationLevel.Standard "standard" => ValidationLevel.Standard,
}; "strict" => ValidationLevel.Strict,
string fileText; "comprehensive" => ValidationLevel.Comprehensive,
try { fileText = File.ReadAllText(fullPath); } _ => ValidationLevel.Standard
catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } };
string fileText;
try { fileText = File.ReadAllText(fullPath); }
catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); }
bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw);
var diags = (diagsRaw ?? Array.Empty<string>()).Select(s => var diags = (diagsRaw ?? Array.Empty<string>()).Select(s =>
{ {
var m = Regex.Match( var m = Regex.Match(
s, s,
@"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$",
RegexOptions.CultureInvariant | RegexOptions.Multiline, RegexOptions.CultureInvariant | RegexOptions.Multiline,
TimeSpan.FromMilliseconds(250) TimeSpan.FromMilliseconds(250)
); );
string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info";
string message = m.Success ? m.Groups[2].Value : s; string message = m.Success ? m.Groups[2].Value : s;
int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0;
return new { line = lineNum, col = 0, severity, message }; return new { line = lineNum, col = 0, severity, message };
}).ToArray(); }).ToArray();
var result = new { diagnostics = diags }; var result = new { diagnostics = diags };
return ok ? Response.Success("Validation completed.", result) return ok ? Response.Success("Validation completed.", result)
: Response.Error("Validation failed.", result); : Response.Error("Validation failed.", result);
} }
case "edit": case "edit":
Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility.");
var structEdits = @params["edits"] as JArray; var structEdits = @params["edits"] as JArray;
var options = @params["options"] as JObject; var options = @params["options"] as JObject;
return EditScript(fullPath, relativePath, name, structEdits, options); return EditScript(fullPath, relativePath, name, structEdits, options);
case "get_sha": case "get_sha":
{
try
{ {
if (!File.Exists(fullPath)) try
return Response.Error($"Script not found at '{relativePath}'.");
string text = File.ReadAllText(fullPath);
string sha = ComputeSha256(text);
var fi = new FileInfo(fullPath);
long lengthBytes;
try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); }
catch { lengthBytes = fi.Exists ? fi.Length : 0; }
var data = new
{ {
uri = $"unity://path/{relativePath}", if (!File.Exists(fullPath))
path = relativePath, return Response.Error($"Script not found at '{relativePath}'.");
sha256 = sha,
lengthBytes, string text = File.ReadAllText(fullPath);
lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty string sha = ComputeSha256(text);
}; var fi = new FileInfo(fullPath);
return Response.Success($"SHA computed for '{relativePath}'.", data); long lengthBytes;
try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); }
catch { lengthBytes = fi.Exists ? fi.Length : 0; }
var data = new
{
uri = $"unity://path/{relativePath}",
path = relativePath,
sha256 = sha,
lengthBytes,
lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty
};
return Response.Success($"SHA computed for '{relativePath}'.", data);
}
catch (Exception ex)
{
return Response.Error($"Failed to compute SHA: {ex.Message}");
}
} }
catch (Exception ex)
{
return Response.Error($"Failed to compute SHA: {ex.Message}");
}
}
default: default:
return Response.Error( return Response.Error(
$"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)."
@ -505,7 +505,7 @@ namespace MCPForUnity.Editor.Tools
try try
{ {
var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? "");
while (di != null && !string.Equals(di.FullName.Replace('\\','/'), Application.dataPath.Replace('\\','/'), StringComparison.OrdinalIgnoreCase)) while (di != null && !string.Equals(di.FullName.Replace('\\', '/'), Application.dataPath.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase))
{ {
if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0)
return Response.Error("Refusing to edit a symlinked script path."); return Response.Error("Refusing to edit a symlinked script path.");
@ -640,7 +640,7 @@ namespace MCPForUnity.Editor.Tools
}; };
structEdits.Add(op); structEdits.Add(op);
// Reuse structured path // Reuse structured path
return EditScript(fullPath, relativePath, name, structEdits, new JObject{ ["refresh"] = "immediate", ["validate"] = "standard" }); return EditScript(fullPath, relativePath, name, structEdits, new JObject { ["refresh"] = "immediate", ["validate"] = "standard" });
} }
} }
} }
@ -656,7 +656,7 @@ namespace MCPForUnity.Editor.Tools
spans = spans.OrderByDescending(t => t.start).ToList(); spans = spans.OrderByDescending(t => t.start).ToList();
for (int i = 1; i < spans.Count; i++) for (int i = 1; i < spans.Count; i++)
{ {
if (spans[i].end > spans[i - 1].start) if (spans[i].end > spans[i - 1].start)
{ {
var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } };
return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." });
@ -942,8 +942,10 @@ namespace MCPForUnity.Editor.Tools
if (c == '\'') { inChr = true; esc = false; continue; } if (c == '\'') { inChr = true; esc = false; continue; }
if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; 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 == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; }
if (c == '{') brace++; else if (c == '}') brace--; if (c == '{') brace++;
else if (c == '(') paren++; else if (c == ')') paren--; else if (c == '}') brace--;
else if (c == '(') paren++;
else if (c == ')') paren--;
else if (c == '[') bracket++; else if (c == ']') bracket--; else if (c == '[') bracket++; else if (c == ']') bracket--;
// Allow temporary negative balance - will check tolerance at end // Allow temporary negative balance - will check tolerance at end
} }
@ -1035,291 +1037,291 @@ namespace MCPForUnity.Editor.Tools
switch (mode) switch (mode)
{ {
case "replace_class": case "replace_class":
{
string className = op.Value<string>("className");
string ns = op.Value<string>("namespace");
string replacement = ExtractReplacement(op);
if (string.IsNullOrWhiteSpace(className))
return Response.Error("replace_class requires 'className'.");
if (replacement == null)
return Response.Error("replace_class requires 'replacement' (inline or base64).");
if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why))
return Response.Error($"replace_class failed: {why}");
if (!ValidateClassSnippet(replacement, className, out var vErr))
return Response.Error($"Replacement snippet invalid: {vErr}");
if (applySequentially)
{ {
working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); string className = op.Value<string>("className");
appliedCount++; string ns = op.Value<string>("namespace");
string replacement = ExtractReplacement(op);
if (string.IsNullOrWhiteSpace(className))
return Response.Error("replace_class requires 'className'.");
if (replacement == null)
return Response.Error("replace_class requires 'replacement' (inline or base64).");
if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why))
return Response.Error($"replace_class failed: {why}");
if (!ValidateClassSnippet(replacement, className, out var vErr))
return Response.Error($"Replacement snippet invalid: {vErr}");
if (applySequentially)
{
working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement));
appliedCount++;
}
else
{
replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement)));
}
break;
} }
else
{
replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement)));
}
break;
}
case "delete_class": case "delete_class":
{
string className = op.Value<string>("className");
string ns = op.Value<string>("namespace");
if (string.IsNullOrWhiteSpace(className))
return Response.Error("delete_class requires 'className'.");
if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why))
return Response.Error($"delete_class failed: {why}");
if (applySequentially)
{ {
working = working.Remove(s, l); string className = op.Value<string>("className");
appliedCount++; string ns = op.Value<string>("namespace");
if (string.IsNullOrWhiteSpace(className))
return Response.Error("delete_class requires 'className'.");
if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why))
return Response.Error($"delete_class failed: {why}");
if (applySequentially)
{
working = working.Remove(s, l);
appliedCount++;
}
else
{
replacements.Add((s, l, string.Empty));
}
break;
} }
else
{
replacements.Add((s, l, string.Empty));
}
break;
}
case "replace_method": case "replace_method":
{
string className = op.Value<string>("className");
string ns = op.Value<string>("namespace");
string methodName = op.Value<string>("methodName");
string replacement = ExtractReplacement(op);
string returnType = op.Value<string>("returnType");
string parametersSignature = op.Value<string>("parametersSignature");
string attributesContains = op.Value<string>("attributesContains");
if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'.");
if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'.");
if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64).");
if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
return Response.Error($"replace_method failed to locate class: {whyClass}");
if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod))
{ {
bool hasDependentInsert = edits.Any(j => j is JObject jo && string className = op.Value<string>("className");
string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && string ns = op.Value<string>("namespace");
string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && string methodName = op.Value<string>("methodName");
((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string replacement = ExtractReplacement(op);
string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; string returnType = op.Value<string>("returnType");
return Response.Error($"replace_method failed: {whyMethod}.{hint}"); string parametersSignature = op.Value<string>("parametersSignature");
} string attributesContains = op.Value<string>("attributesContains");
if (applySequentially) if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'.");
{ if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'.");
working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64).");
appliedCount++;
if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
return Response.Error($"replace_method failed to locate class: {whyClass}");
if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod))
{
bool hasDependentInsert = edits.Any(j => j is JObject jo &&
string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) &&
string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) &&
((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method"));
string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty;
return Response.Error($"replace_method failed: {whyMethod}.{hint}");
}
if (applySequentially)
{
working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement));
appliedCount++;
}
else
{
replacements.Add((mStart, mLen, NormalizeNewlines(replacement)));
}
break;
} }
else
{
replacements.Add((mStart, mLen, NormalizeNewlines(replacement)));
}
break;
}
case "delete_method": case "delete_method":
{
string className = op.Value<string>("className");
string ns = op.Value<string>("namespace");
string methodName = op.Value<string>("methodName");
string returnType = op.Value<string>("returnType");
string parametersSignature = op.Value<string>("parametersSignature");
string attributesContains = op.Value<string>("attributesContains");
if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'.");
if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'.");
if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
return Response.Error($"delete_method failed to locate class: {whyClass}");
if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod))
{ {
bool hasDependentInsert = edits.Any(j => j is JObject jo && string className = op.Value<string>("className");
string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) && string ns = op.Value<string>("namespace");
string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) && string methodName = op.Value<string>("methodName");
((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); string returnType = op.Value<string>("returnType");
string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; string parametersSignature = op.Value<string>("parametersSignature");
return Response.Error($"delete_method failed: {whyMethod}.{hint}"); string attributesContains = op.Value<string>("attributesContains");
}
if (applySequentially) if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'.");
{ if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'.");
working = working.Remove(mStart, mLen);
appliedCount++; if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
return Response.Error($"delete_method failed to locate class: {whyClass}");
if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod))
{
bool hasDependentInsert = edits.Any(j => j is JObject jo &&
string.Equals(jo.Value<string>("className"), className, StringComparison.Ordinal) &&
string.Equals(jo.Value<string>("methodName"), methodName, StringComparison.Ordinal) &&
((jo.Value<string>("mode") ?? jo.Value<string>("op") ?? string.Empty).ToLowerInvariant() == "insert_method"));
string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty;
return Response.Error($"delete_method failed: {whyMethod}.{hint}");
}
if (applySequentially)
{
working = working.Remove(mStart, mLen);
appliedCount++;
}
else
{
replacements.Add((mStart, mLen, string.Empty));
}
break;
} }
else
{
replacements.Add((mStart, mLen, string.Empty));
}
break;
}
case "insert_method": case "insert_method":
{
string className = op.Value<string>("className");
string ns = op.Value<string>("namespace");
string position = (op.Value<string>("position") ?? "end").ToLowerInvariant();
string afterMethodName = op.Value<string>("afterMethodName");
string afterReturnType = op.Value<string>("afterReturnType");
string afterParameters = op.Value<string>("afterParametersSignature");
string afterAttributesContains = op.Value<string>("afterAttributesContains");
string snippet = ExtractReplacement(op);
// Harden: refuse empty replacement for inserts
if (snippet == null || snippet.Trim().Length == 0)
return Response.Error("insert_method requires a non-empty 'replacement' text.");
if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'.");
if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration.");
if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
return Response.Error($"insert_method failed to locate class: {whyClass}");
if (position == "after")
{ {
if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); string className = op.Value<string>("className");
if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) string ns = op.Value<string>("namespace");
return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); string position = (op.Value<string>("position") ?? "end").ToLowerInvariant();
int insAt = aStart + aLen; string afterMethodName = op.Value<string>("afterMethodName");
string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); string afterReturnType = op.Value<string>("afterReturnType");
if (applySequentially) string afterParameters = op.Value<string>("afterParametersSignature");
{ string afterAttributesContains = op.Value<string>("afterAttributesContains");
working = working.Insert(insAt, text); string snippet = ExtractReplacement(op);
appliedCount++; // Harden: refuse empty replacement for inserts
} if (snippet == null || snippet.Trim().Length == 0)
else return Response.Error("insert_method requires a non-empty 'replacement' text.");
{
replacements.Add((insAt, 0, text));
}
}
else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns))
return Response.Error($"insert_method failed: {whyIns}");
else
{
string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n");
if (applySequentially)
{
working = working.Insert(insAt, text);
appliedCount++;
}
else
{
replacements.Add((insAt, 0, text));
}
}
break;
}
case "anchor_insert": if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'.");
{ if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration.");
string anchor = op.Value<string>("anchor");
string position = (op.Value<string>("position") ?? "before").ToLowerInvariant();
string text = op.Value<string>("text") ?? ExtractReplacement(op);
if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex).");
if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'.");
try if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass))
{ return Response.Error($"insert_method failed to locate class: {whyClass}");
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
var m = rx.Match(working);
if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}");
int insAt = position == "after" ? m.Index + m.Length : m.Index;
string norm = NormalizeNewlines(text);
if (!norm.EndsWith("\n"))
{
norm += "\n";
}
// Duplicate guard: if identical snippet already exists within this class, skip insert if (position == "after")
if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _))
{ {
string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'.");
if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter))
return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}");
int insAt = aStart + aLen;
string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n");
if (applySequentially)
{ {
// Do not insert duplicate; treat as no-op working = working.Insert(insAt, text);
break; appliedCount++;
}
else
{
replacements.Add((insAt, 0, text));
} }
} }
if (applySequentially) else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns))
{ return Response.Error($"insert_method failed: {whyIns}");
working = working.Insert(insAt, norm);
appliedCount++;
}
else else
{ {
replacements.Add((insAt, 0, norm)); string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n");
if (applySequentially)
{
working = working.Insert(insAt, text);
appliedCount++;
}
else
{
replacements.Add((insAt, 0, text));
}
} }
break;
} }
catch (Exception ex)
case "anchor_insert":
{ {
return Response.Error($"anchor_insert failed: {ex.Message}"); string anchor = op.Value<string>("anchor");
string position = (op.Value<string>("position") ?? "before").ToLowerInvariant();
string text = op.Value<string>("text") ?? ExtractReplacement(op);
if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex).");
if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'.");
try
{
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
var m = rx.Match(working);
if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}");
int insAt = position == "after" ? m.Index + m.Length : m.Index;
string norm = NormalizeNewlines(text);
if (!norm.EndsWith("\n"))
{
norm += "\n";
}
// Duplicate guard: if identical snippet already exists within this class, skip insert
if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _))
{
string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG));
if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0)
{
// Do not insert duplicate; treat as no-op
break;
}
}
if (applySequentially)
{
working = working.Insert(insAt, norm);
appliedCount++;
}
else
{
replacements.Add((insAt, 0, norm));
}
}
catch (Exception ex)
{
return Response.Error($"anchor_insert failed: {ex.Message}");
}
break;
} }
break;
}
case "anchor_delete": case "anchor_delete":
{
string anchor = op.Value<string>("anchor");
if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex).");
try
{ {
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); string anchor = op.Value<string>("anchor");
var m = rx.Match(working); if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex).");
if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); try
int delAt = m.Index;
int delLen = m.Length;
if (applySequentially)
{ {
working = working.Remove(delAt, delLen); var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
appliedCount++; var m = rx.Match(working);
if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}");
int delAt = m.Index;
int delLen = m.Length;
if (applySequentially)
{
working = working.Remove(delAt, delLen);
appliedCount++;
}
else
{
replacements.Add((delAt, delLen, string.Empty));
}
} }
else catch (Exception ex)
{ {
replacements.Add((delAt, delLen, string.Empty)); return Response.Error($"anchor_delete failed: {ex.Message}");
} }
break;
} }
catch (Exception ex)
{
return Response.Error($"anchor_delete failed: {ex.Message}");
}
break;
}
case "anchor_replace": case "anchor_replace":
{
string anchor = op.Value<string>("anchor");
string replacement = op.Value<string>("text") ?? op.Value<string>("replacement") ?? ExtractReplacement(op) ?? string.Empty;
if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex).");
try
{ {
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); string anchor = op.Value<string>("anchor");
var m = rx.Match(working); string replacement = op.Value<string>("text") ?? op.Value<string>("replacement") ?? ExtractReplacement(op) ?? string.Empty;
if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex).");
int at = m.Index; try
int len = m.Length;
string norm = NormalizeNewlines(replacement);
if (applySequentially)
{ {
working = working.Remove(at, len).Insert(at, norm); var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
appliedCount++; var m = rx.Match(working);
if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}");
int at = m.Index;
int len = m.Length;
string norm = NormalizeNewlines(replacement);
if (applySequentially)
{
working = working.Remove(at, len).Insert(at, norm);
appliedCount++;
}
else
{
replacements.Add((at, len, norm));
}
} }
else catch (Exception ex)
{ {
replacements.Add((at, len, norm)); return Response.Error($"anchor_replace failed: {ex.Message}");
} }
break;
} }
catch (Exception ex)
{
return Response.Error($"anchor_replace failed: {ex.Message}");
}
break;
}
default: default:
return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace.");
@ -1703,7 +1705,7 @@ namespace MCPForUnity.Editor.Tools
} }
// Tolerate generic constraints between params and body: multiple 'where T : ...' // Tolerate generic constraints between params and body: multiple 'where T : ...'
for (;;) for (; ; )
{ {
// Skip whitespace/comments before checking for 'where' // Skip whitespace/comments before checking for 'where'
for (; i < searchEnd; i++) for (; i < searchEnd; i++)

View File

@ -1,15 +1,9 @@
using System; using System;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems 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 public static class ManageMenuItem
{ {
/// <summary> /// <summary>

View File

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems namespace MCPForUnity.Editor.Tools.MenuItems
@ -37,23 +36,12 @@ namespace MCPForUnity.Editor.Tools.MenuItems
try try
{ {
// Execute on main thread using delayCall bool executed = EditorApplication.ExecuteMenuItem(menuPath);
EditorApplication.delayCall += () => if (!executed)
{ {
try 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.");
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.");
}
}
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."); return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors.");
} }
catch (Exception e) catch (Exception e)

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using UnityEditor; using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Helpers;
namespace MCPForUnity.Editor.Tools.MenuItems 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 else
{ {
// For Cursor/others, skip if already configured CheckMcpConfiguration(client);
if (!IsCursorConfigured(pythonDir)) bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{ {
ConfigureMcpClient(client); ConfigureMcpClient(client);
anyRegistered = true; anyRegistered = true;
@ -581,7 +582,10 @@ namespace MCPForUnity.Editor.Windows
MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); 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 // Ensure the bridge is listening and has a fresh saved port
@ -658,7 +662,9 @@ namespace MCPForUnity.Editor.Windows
} }
else else
{ {
if (!IsCursorConfigured(pythonDir)) CheckMcpConfiguration(client);
bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{ {
ConfigureMcpClient(client); ConfigureMcpClient(client);
anyRegistered = true; anyRegistered = true;
@ -670,7 +676,10 @@ namespace MCPForUnity.Editor.Windows
UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); 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 // Restart/ensure bridge
MCPForUnityBridge.StartAutoConnect(); MCPForUnityBridge.StartAutoConnect();
@ -686,11 +695,11 @@ namespace MCPForUnity.Editor.Windows
} }
} }
private static bool IsCursorConfigured(string pythonDir) private static bool IsCursorConfigured(string pythonDir)
{ {
try try
{ {
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor", "mcp.json") ".cursor", "mcp.json")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
@ -708,24 +717,9 @@ namespace MCPForUnity.Editor.Windows
string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args) string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args)
.Select(x => x?.ToString() ?? string.Empty) .Select(x => x?.ToString() ?? string.Empty)
.ToArray(); .ToArray();
string dir = ExtractDirectoryArg(strArgs); string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs);
if (string.IsNullOrEmpty(dir)) return false; if (string.IsNullOrEmpty(dir)) return false;
return PathsEqual(dir, pythonDir); return McpConfigFileHelper.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);
} }
catch { return false; } catch { return false; }
} }
@ -1136,19 +1130,6 @@ namespace MCPForUnity.Editor.Windows
catch { return false; } 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) private static bool ArgsEqual(string[] a, string[] b)
{ {
if (a == null || b == null) return a == b; if (a == null || b == null) return a == b;
@ -1236,48 +1217,7 @@ namespace MCPForUnity.Editor.Windows
} }
catch { } catch { }
if (uvPath == null) return "UV package manager not found. Please install UV first."; if (uvPath == null) return "UV package manager not found. Please install UV first.";
string serverSrc = ExtractDirectoryArg(existingArgs); string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, 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();
}
// 2) Canonical args order // 2) Canonical args order
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
@ -1301,60 +1241,7 @@ namespace MCPForUnity.Editor.Windows
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
// Robust atomic write without redundant backup or race on existence McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson);
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 { }
}
try try
{ {
@ -1377,54 +1264,27 @@ namespace MCPForUnity.Editor.Windows
} }
// New method to show manual instructions without changing status // New method to show manual instructions without changing status
private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient)
{
// Get the Python directory path using Package Manager API
string pythonDir = FindPackagePythonDirectory();
// Build manual JSON centrally using the shared builder
string uvPathForManual = FindUvPath();
if (uvPathForManual == null)
{
UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
return;
}
string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient);
}
private static string ResolveServerSrc()
{ {
try // Get the Python directory path using Package Manager API
string pythonDir = FindPackagePythonDirectory();
// Build manual JSON centrally using the shared builder
string uvPathForManual = FindUvPath();
if (uvPathForManual == null)
{ {
string remembered = UnityEditor.EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) return;
{
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() private string FindPackagePythonDirectory()
{ {
string pythonDir = ResolveServerSrc(); string pythonDir = McpConfigFileHelper.ResolveServerSource();
try try
{ {
@ -1508,12 +1368,12 @@ namespace MCPForUnity.Editor.Windows
} }
} }
private string ConfigureMcpClient(McpClient mcpClient) private string ConfigureMcpClient(McpClient mcpClient)
{ {
try try
{ {
// Determine the config file path based on OS // Determine the config file path based on OS
string configPath; string configPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
@ -1541,21 +1401,23 @@ namespace MCPForUnity.Editor.Windows
// Create directory if it doesn't exist // Create directory if it doesn't exist
Directory.CreateDirectory(Path.GetDirectoryName(configPath)); Directory.CreateDirectory(Path.GetDirectoryName(configPath));
// Find the server.py file location using the same logic as FindPackagePythonDirectory // Find the server.py file location using the same logic as FindPackagePythonDirectory
string pythonDir = FindPackagePythonDirectory(); string pythonDir = FindPackagePythonDirectory();
if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
{ {
ShowManualInstructionsWindow(configPath, mcpClient); ShowManualInstructionsWindow(configPath, mcpClient);
return "Manual Configuration Required"; 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 // Update the client status after successful configuration
if (result == "Configured successfully") if (result == "Configured successfully")
{ {
mcpClient.SetStatus(McpStatus.Configured); mcpClient.SetStatus(McpStatus.Configured);
} }
return result; return result;
@ -1588,8 +1450,82 @@ namespace MCPForUnity.Editor.Windows
$"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}"
); );
return $"Failed to configure {mcpClient.name}"; return $"Failed to configure {mcpClient.name}";
} }
} }
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( private void ShowCursorManualConfigurationInstructions(
string configPath, string configPath,
@ -1721,28 +1657,36 @@ namespace MCPForUnity.Editor.Windows
string[] args = null; string[] args = null;
bool configExists = false; bool configExists = false;
switch (mcpClient.mcpType) switch (mcpClient.mcpType)
{ {
case McpTypes.VSCode: case McpTypes.VSCode:
dynamic config = JsonConvert.DeserializeObject(configJson); dynamic config = JsonConvert.DeserializeObject(configJson);
// New schema: top-level servers // New schema: top-level servers
if (config?.servers?.unityMCP != null) if (config?.servers?.unityMCP != null)
{ {
args = config.servers.unityMCP.args.ToObject<string[]>(); args = config.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
} }
// Back-compat: legacy mcp.servers // Back-compat: legacy mcp.servers
else if (config?.mcp?.servers?.unityMCP != null) else if (config?.mcp?.servers?.unityMCP != null)
{ {
args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); args = config.mcp.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
} }
break; break;
default: case McpTypes.Codex:
// Standard MCP configuration check for Claude Desktop, Cursor, etc. if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); {
args = codexArgs;
configExists = true;
}
break;
default:
// Standard MCP configuration check for Claude Desktop, Cursor, etc.
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
if (standardConfig?.mcpServers?.unityMCP != null) if (standardConfig?.mcpServers?.unityMCP != null)
{ {
@ -1755,8 +1699,8 @@ namespace MCPForUnity.Editor.Windows
// Common logic for checking configuration status // Common logic for checking configuration status
if (configExists) if (configExists)
{ {
string configuredDir = ExtractDirectoryArg(args); string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args);
bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir);
if (matches) if (matches)
{ {
mcpClient.SetStatus(McpStatus.Configured); mcpClient.SetStatus(McpStatus.Configured);
@ -1766,7 +1710,9 @@ namespace MCPForUnity.Editor.Windows
// Attempt auto-rewrite once if the package path changed // Attempt auto-rewrite once if the package path changed
try 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 (rewriteResult == "Configured successfully")
{ {
if (debugLogsEnabled) if (debugLogsEnabled)

View File

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

View File

@ -24,4 +24,4 @@ RUN uv pip install --system -e .
# Command to run the server # Command to run the server
CMD ["uv", "run", "server.py"] CMD ["uv", "run", "server.py"]

View File

@ -1,3 +1,3 @@
""" """
MCP for Unity Server package. MCP for Unity Server package.
""" """

View File

@ -5,26 +5,30 @@ This file contains all configurable parameters for the server.
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class ServerConfig: class ServerConfig:
"""Main configuration class for the MCP server.""" """Main configuration class for the MCP server."""
# Network settings # Network settings
unity_host: str = "localhost" unity_host: str = "localhost"
unity_port: int = 6400 unity_port: int = 6400
mcp_port: int = 6500 mcp_port: int = 6500
# Connection settings # 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 buffer_size: int = 16 * 1024 * 1024 # 16MB buffer
# Framed receive behavior # Framed receive behavior
framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only # max seconds to wait while consuming heartbeats only
max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up framed_receive_timeout: float = 2.0
# cap heartbeat frames consumed before giving up
max_heartbeat_frames: int = 16
# Logging settings # Logging settings
log_level: str = "INFO" log_level: str = "INFO"
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Server settings # Server settings
max_retries: int = 10 max_retries: int = 10
retry_delay: float = 0.25 retry_delay: float = 0.25
@ -33,11 +37,12 @@ class ServerConfig:
# Number of polite retries when Unity reports reloading # Number of polite retries when Unity reports reloading
# 40 × 250ms ≈ 10s default window # 40 × 250ms ≈ 10s default window
reload_max_retries: int = 40 reload_max_retries: int = 40
# Telemetry settings # Telemetry settings
telemetry_enabled: bool = True telemetry_enabled: bool = True
# Align with telemetry.py default Cloud Run endpoint # 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 # Create a global config instance
config = ServerConfig() config = ServerConfig()

View File

@ -11,31 +11,31 @@ What changed and why:
(quick socket connect + ping) before choosing it. (quick socket connect + ping) before choosing it.
""" """
import glob
import json import json
import os
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional, List
import glob
import socket import socket
from typing import Optional, List
logger = logging.getLogger("mcp-for-unity-server") logger = logging.getLogger("mcp-for-unity-server")
class PortDiscovery: class PortDiscovery:
"""Handles port discovery from Unity Bridge registry""" """Handles port discovery from Unity Bridge registry"""
REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file
DEFAULT_PORT = 6400 DEFAULT_PORT = 6400
CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery
@staticmethod @staticmethod
def get_registry_path() -> Path: def get_registry_path() -> Path:
"""Get the path to the port registry file""" """Get the path to the port registry file"""
return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE
@staticmethod @staticmethod
def get_registry_dir() -> Path: def get_registry_dir() -> Path:
return Path.home() / ".unity-mcp" return Path.home() / ".unity-mcp"
@staticmethod @staticmethod
def list_candidate_files() -> List[Path]: def list_candidate_files() -> List[Path]:
"""Return candidate registry files, newest first. """Return candidate registry files, newest first.
@ -52,7 +52,7 @@ class PortDiscovery:
# Put legacy at the end so hashed, per-project files win # Put legacy at the end so hashed, per-project files win
hashed.append(legacy) hashed.append(legacy)
return hashed return hashed
@staticmethod @staticmethod
def _try_probe_unity_mcp(port: int) -> bool: def _try_probe_unity_mcp(port: int) -> bool:
"""Quickly check if a MCP for Unity listener is on this port. """Quickly check if a MCP for Unity listener is on this port.
@ -78,7 +78,8 @@ class PortDiscovery:
try: try:
base = PortDiscovery.get_registry_dir() base = PortDiscovery.get_registry_dir()
status_files = sorted( 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, key=lambda p: p.stat().st_mtime,
reverse=True, reverse=True,
) )
@ -88,14 +89,14 @@ class PortDiscovery:
return json.load(f) return json.load(f)
except Exception: except Exception:
return None return None
@staticmethod @staticmethod
def discover_unity_port() -> int: def discover_unity_port() -> int:
""" """
Discover Unity port by scanning per-project and legacy registry files. Discover Unity port by scanning per-project and legacy registry files.
Prefer the newest file whose port responds; fall back to first parsed Prefer the newest file whose port responds; fall back to first parsed
value; finally default to 6400. value; finally default to 6400.
Returns: Returns:
Port number to connect to Port number to connect to
""" """
@ -120,26 +121,29 @@ class PortDiscovery:
if first_seen_port is None: if first_seen_port is None:
first_seen_port = unity_port first_seen_port = unity_port
if PortDiscovery._try_probe_unity_mcp(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 return unity_port
except Exception as e: except Exception as e:
logger.warning(f"Could not read port registry {path}: {e}") logger.warning(f"Could not read port registry {path}: {e}")
if first_seen_port is not None: 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 return first_seen_port
# Fallback to default 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 return PortDiscovery.DEFAULT_PORT
@staticmethod @staticmethod
def get_port_config() -> Optional[dict]: def get_port_config() -> Optional[dict]:
""" """
Get the most relevant port configuration from registry. Get the most relevant port configuration from registry.
Returns the most recent hashed file's config if present, Returns the most recent hashed file's config if present,
otherwise the legacy file's config. Returns None if nothing exists. otherwise the legacy file's config. Returns None if nothing exists.
Returns: Returns:
Port configuration dict or None if not found Port configuration dict or None if not found
""" """
@ -151,5 +155,6 @@ class PortDiscovery:
with open(path, 'r') as f: with open(path, 'r') as f:
return json.load(f) return json.load(f)
except Exception as e: except Exception as e:
logger.warning(f"Could not read port configuration {path}: {e}") logger.warning(
return None f"Could not read port configuration {path}: {e}")
return None

View File

@ -1,10 +1,10 @@
[project] [project]
name = "MCPForUnityServer" 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)." description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" 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] [build-system]
requires = ["setuptools>=64.0.0", "wheel"] 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. All functions are no-ops to prevent accidental external writes.
""" """
def flip_reload_sentinel(*args, **kwargs) -> str: def flip_reload_sentinel(*args, **kwargs) -> str:
return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" 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 import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
import os import os
from dataclasses import dataclass
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any, List from typing import AsyncIterator, Dict, Any
from config import config from config import config
from tools import register_all_tools from tools import register_all_tools
from unity_connection import get_unity_connection, UnityConnection 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 # Initialize MCP server
mcp = FastMCP( mcp = FastMCP(
"mcp-for-unity-server", name="mcp-for-unity-server",
description="Unity Editor integration via Model Context Protocol",
lifespan=server_lifespan 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 Privacy-focused, anonymous telemetry system for Unity MCP
Inspired by Onyx's telemetry implementation with Unity-specific adaptations Inspired by Onyx's telemetry implementation with Unity-specific adaptations
"""
import uuid
import threading
"""
Fire-and-forget telemetry sender with a single background worker. Fire-and-forget telemetry sender with a single background worker.
- No context/thread-local propagation to avoid re-entrancy into tool resolution. - No context/thread-local propagation to avoid re-entrancy into tool resolution.
- Small network timeouts to prevent stalls. - 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 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: try:
import httpx import httpx
@ -34,10 +33,11 @@ except ImportError:
logger = logging.getLogger("unity-mcp-telemetry") logger = logging.getLogger("unity-mcp-telemetry")
class RecordType(str, Enum): class RecordType(str, Enum):
"""Types of telemetry records we collect""" """Types of telemetry records we collect"""
VERSION = "version" VERSION = "version"
STARTUP = "startup" STARTUP = "startup"
USAGE = "usage" USAGE = "usage"
LATENCY = "latency" LATENCY = "latency"
FAILURE = "failure" FAILURE = "failure"
@ -45,6 +45,7 @@ class RecordType(str, Enum):
UNITY_CONNECTION = "unity_connection" UNITY_CONNECTION = "unity_connection"
CLIENT_CONNECTION = "client_connection" CLIENT_CONNECTION = "client_connection"
class MilestoneType(str, Enum): class MilestoneType(str, Enum):
"""Major user journey milestones""" """Major user journey milestones"""
FIRST_STARTUP = "first_startup" FIRST_STARTUP = "first_startup"
@ -55,6 +56,7 @@ class MilestoneType(str, Enum):
DAILY_ACTIVE_USER = "daily_active_user" DAILY_ACTIVE_USER = "daily_active_user"
WEEKLY_ACTIVE_USER = "weekly_active_user" WEEKLY_ACTIVE_USER = "weekly_active_user"
@dataclass @dataclass
class TelemetryRecord: class TelemetryRecord:
"""Structure for telemetry data""" """Structure for telemetry data"""
@ -65,8 +67,10 @@ class TelemetryRecord:
data: Dict[str, Any] data: Dict[str, Any]
milestone: Optional[MilestoneType] = None milestone: Optional[MilestoneType] = None
class TelemetryConfig: class TelemetryConfig:
"""Telemetry configuration""" """Telemetry configuration"""
def __init__(self): def __init__(self):
# Prefer config file, then allow env overrides # Prefer config file, then allow env overrides
server_config = None server_config = None
@ -85,12 +89,14 @@ class TelemetryConfig:
continue continue
# Determine enabled flag: config -> env DISABLE_* opt-out # 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() self.enabled = cfg_enabled and not self._is_disabled()
# Telemetry endpoint (Cloud Run default; override via env) # Telemetry endpoint (Cloud Run default; override via env)
cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None) cfg_default = None if server_config is None else getattr(
default_ep = cfg_default or "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events" server_config, "telemetry_endpoint", None)
default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
self.default_endpoint = default_ep self.default_endpoint = default_ep
self.endpoint = self._validated_endpoint( self.endpoint = self._validated_endpoint(
os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep),
@ -105,50 +111,53 @@ class TelemetryConfig:
) )
except Exception: except Exception:
pass pass
# Local storage for UUID and milestones # Local storage for UUID and milestones
self.data_dir = self._get_data_directory() self.data_dir = self._get_data_directory()
self.uuid_file = self.data_dir / "customer_uuid.txt" self.uuid_file = self.data_dir / "customer_uuid.txt"
self.milestones_file = self.data_dir / "milestones.json" self.milestones_file = self.data_dir / "milestones.json"
# Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
try: 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: except Exception:
self.timeout = 1.5 self.timeout = 1.5
try: try:
logger.info("Telemetry timeout=%.2fs", self.timeout) logger.info("Telemetry timeout=%.2fs", self.timeout)
except Exception: except Exception:
pass pass
# Session tracking # Session tracking
self.session_id = str(uuid.uuid4()) self.session_id = str(uuid.uuid4())
def _is_disabled(self) -> bool: def _is_disabled(self) -> bool:
"""Check if telemetry is disabled via environment variables""" """Check if telemetry is disabled via environment variables"""
disable_vars = [ disable_vars = [
"DISABLE_TELEMETRY", "DISABLE_TELEMETRY",
"UNITY_MCP_DISABLE_TELEMETRY", "UNITY_MCP_DISABLE_TELEMETRY",
"MCP_DISABLE_TELEMETRY" "MCP_DISABLE_TELEMETRY"
] ]
for var in disable_vars: for var in disable_vars:
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
return True return True
return False return False
def _get_data_directory(self) -> Path: def _get_data_directory(self) -> Path:
"""Get directory for storing telemetry data""" """Get directory for storing telemetry data"""
if os.name == 'nt': # Windows 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 elif os.name == 'posix': # macOS/Linux
if 'darwin' in os.uname().sysname.lower(): # macOS if 'darwin' in os.uname().sysname.lower(): # macOS
base_dir = Path.home() / 'Library' / 'Application Support' base_dir = Path.home() / 'Library' / 'Application Support'
else: # Linux 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: else:
base_dir = Path.home() / '.unity-mcp' base_dir = Path.home() / '.unity-mcp'
data_dir = base_dir / 'UnityMCP' data_dir = base_dir / 'UnityMCP'
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
return data_dir return data_dir
@ -167,7 +176,8 @@ class TelemetryConfig:
# Reject localhost/loopback endpoints in production to avoid accidental local overrides # Reject localhost/loopback endpoints in production to avoid accidental local overrides
host = parsed.hostname or "" host = parsed.hostname or ""
if host in ("localhost", "127.0.0.1", "::1"): 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 return candidate
except Exception as e: except Exception as e:
logger.debug( logger.debug(
@ -176,9 +186,10 @@ class TelemetryConfig:
) )
return fallback return fallback
class TelemetryCollector: class TelemetryCollector:
"""Main telemetry collection class""" """Main telemetry collection class"""
def __init__(self): def __init__(self):
self.config = TelemetryConfig() self.config = TelemetryConfig()
self._customer_uuid: Optional[str] = None self._customer_uuid: Optional[str] = None
@ -188,23 +199,27 @@ class TelemetryCollector:
self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
# Load persistent data before starting worker so first events have UUID # Load persistent data before starting worker so first events have UUID
self._load_persistent_data() 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() self._worker.start()
def _load_persistent_data(self): def _load_persistent_data(self):
"""Load UUID and milestones from disk""" """Load UUID and milestones from disk"""
# Load customer UUID # Load customer UUID
try: try:
if self.config.uuid_file.exists(): 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: else:
self._customer_uuid = str(uuid.uuid4()) self._customer_uuid = str(uuid.uuid4())
try: 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": if os.name == "posix":
os.chmod(self.config.uuid_file, 0o600) os.chmod(self.config.uuid_file, 0o600)
except OSError as e: 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: except OSError as e:
logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
self._customer_uuid = str(uuid.uuid4()) self._customer_uuid = str(uuid.uuid4())
@ -212,14 +227,15 @@ class TelemetryCollector:
# Load milestones (failure here must not affect UUID) # Load milestones (failure here must not affect UUID)
try: try:
if self.config.milestones_file.exists(): 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 {} self._milestones = json.loads(content) or {}
if not isinstance(self._milestones, dict): if not isinstance(self._milestones, dict):
self._milestones = {} self._milestones = {}
except (OSError, json.JSONDecodeError, ValueError) as e: except (OSError, json.JSONDecodeError, ValueError) as e:
logger.debug(f"Failed to load milestones: {e}", exc_info=True) logger.debug(f"Failed to load milestones: {e}", exc_info=True)
self._milestones = {} self._milestones = {}
def _save_milestones(self): def _save_milestones(self):
"""Save milestones to disk. Caller must hold self._lock.""" """Save milestones to disk. Caller must hold self._lock."""
try: try:
@ -229,7 +245,7 @@ class TelemetryCollector:
) )
except OSError as e: except OSError as e:
logger.warning(f"Failed to save milestones: {e}", exc_info=True) logger.warning(f"Failed to save milestones: {e}", exc_info=True)
def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
"""Record a milestone event, returns True if this is the first occurrence""" """Record a milestone event, returns True if this is the first occurrence"""
if not self.config.enabled: if not self.config.enabled:
@ -244,26 +260,26 @@ class TelemetryCollector:
} }
self._milestones[milestone_key] = milestone_data self._milestones[milestone_key] = milestone_data
self._save_milestones() self._save_milestones()
# Also send as telemetry record # Also send as telemetry record
self.record( self.record(
record_type=RecordType.USAGE, record_type=RecordType.USAGE,
data={"milestone": milestone_key, **(data or {})}, data={"milestone": milestone_key, **(data or {})},
milestone=milestone milestone=milestone
) )
return True return True
def record(self, def record(self,
record_type: RecordType, record_type: RecordType,
data: Dict[str, Any], data: Dict[str, Any],
milestone: Optional[MilestoneType] = None): milestone: Optional[MilestoneType] = None):
"""Record a telemetry event (async, non-blocking)""" """Record a telemetry event (async, non-blocking)"""
if not self.config.enabled: if not self.config.enabled:
return return
# Allow fallback sender when httpx is unavailable (no early return) # Allow fallback sender when httpx is unavailable (no early return)
record = TelemetryRecord( record = TelemetryRecord(
record_type=record_type, record_type=record_type,
timestamp=time.time(), timestamp=time.time(),
@ -276,7 +292,8 @@ class TelemetryCollector:
try: try:
self._queue.put_nowait(record) self._queue.put_nowait(record)
except queue.Full: 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): def _worker_loop(self):
"""Background worker that serializes telemetry sends.""" """Background worker that serializes telemetry sends."""
@ -290,7 +307,7 @@ class TelemetryCollector:
finally: finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self._queue.task_done() self._queue.task_done()
def _send_telemetry(self, record: TelemetryRecord): def _send_telemetry(self, record: TelemetryRecord):
"""Send telemetry data to endpoint""" """Send telemetry data to endpoint"""
try: try:
@ -323,17 +340,20 @@ class TelemetryCollector:
if httpx: if httpx:
with httpx.Client(timeout=self.config.timeout) as client: with httpx.Client(timeout=self.config.timeout) as client:
# Re-validate endpoint at send time to handle dynamic changes # 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) response = client.post(endpoint, json=payload)
if 200 <= response.status_code < 300: if 200 <= response.status_code < 300:
logger.debug(f"Telemetry sent: {record.record_type}") logger.debug(f"Telemetry sent: {record.record_type}")
else: else:
logger.warning(f"Telemetry failed: HTTP {response.status_code}") logger.warning(
f"Telemetry failed: HTTP {response.status_code}")
else: else:
import urllib.request import urllib.request
import urllib.error import urllib.error
data_bytes = json.dumps(payload).encode("utf-8") 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( req = urllib.request.Request(
endpoint, endpoint,
data=data_bytes, data=data_bytes,
@ -343,9 +363,11 @@ class TelemetryCollector:
try: try:
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
if 200 <= resp.getcode() < 300: if 200 <= resp.getcode() < 300:
logger.debug(f"Telemetry sent (urllib): {record.record_type}") logger.debug(
f"Telemetry sent (urllib): {record.record_type}")
else: 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: except urllib.error.URLError as ue:
logger.warning(f"Telemetry send failed (urllib): {ue}") logger.warning(f"Telemetry send failed (urllib): {ue}")
@ -357,6 +379,7 @@ class TelemetryCollector:
# Global telemetry instance # Global telemetry instance
_telemetry_collector: Optional[TelemetryCollector] = None _telemetry_collector: Optional[TelemetryCollector] = None
def get_telemetry() -> TelemetryCollector: def get_telemetry() -> TelemetryCollector:
"""Get the global telemetry collector instance""" """Get the global telemetry collector instance"""
global _telemetry_collector global _telemetry_collector
@ -364,16 +387,19 @@ def get_telemetry() -> TelemetryCollector:
_telemetry_collector = TelemetryCollector() _telemetry_collector = TelemetryCollector()
return _telemetry_collector return _telemetry_collector
def record_telemetry(record_type: RecordType,
data: Dict[str, Any], def record_telemetry(record_type: RecordType,
milestone: Optional[MilestoneType] = None): data: Dict[str, Any],
milestone: Optional[MilestoneType] = None):
"""Convenience function to record telemetry""" """Convenience function to record telemetry"""
get_telemetry().record(record_type, data, milestone) get_telemetry().record(record_type, data, milestone)
def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
"""Convenience function to record a milestone""" """Convenience function to record a milestone"""
return get_telemetry().record_milestone(milestone, data) 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): 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 """Record tool usage telemetry
@ -396,36 +422,39 @@ def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error:
except Exception: except Exception:
# Ensure telemetry is never disruptive # Ensure telemetry is never disruptive
data["sub_action"] = "unknown" data["sub_action"] = "unknown"
if error: if error:
data["error"] = str(error)[:200] # Limit error message length data["error"] = str(error)[:200] # Limit error message length
record_telemetry(RecordType.TOOL_EXECUTION, data) record_telemetry(RecordType.TOOL_EXECUTION, data)
def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None):
"""Record latency telemetry""" """Record latency telemetry"""
data = { data = {
"operation": operation, "operation": operation,
"duration_ms": round(duration_ms, 2) "duration_ms": round(duration_ms, 2)
} }
if metadata: if metadata:
data.update(metadata) data.update(metadata)
record_telemetry(RecordType.LATENCY, data) record_telemetry(RecordType.LATENCY, data)
def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None):
"""Record failure telemetry""" """Record failure telemetry"""
data = { data = {
"component": component, "component": component,
"error": str(error)[:500] # Limit error message length "error": str(error)[:500] # Limit error message length
} }
if metadata: if metadata:
data.update(metadata) data.update(metadata)
record_telemetry(RecordType.FAILURE, data) record_telemetry(RecordType.FAILURE, data)
def is_telemetry_enabled() -> bool: def is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled""" """Check if telemetry is enabled"""
return get_telemetry().config.enabled return get_telemetry().config.enabled

View File

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

View File

@ -5,30 +5,30 @@ Run this to verify telemetry is working correctly
""" """
import os import os
import time
import sys
from pathlib import Path from pathlib import Path
import sys
# Add src to Python path for imports # Add src to Python path for imports
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
def test_telemetry_basic(): def test_telemetry_basic():
"""Test basic telemetry functionality""" """Test basic telemetry functionality"""
# Avoid stdout noise in tests # Avoid stdout noise in tests
try: try:
from telemetry import ( from telemetry import (
get_telemetry, record_telemetry, record_milestone, get_telemetry, record_telemetry, record_milestone,
RecordType, MilestoneType, is_telemetry_enabled RecordType, MilestoneType, is_telemetry_enabled
) )
pass pass
except ImportError as e: except ImportError as e:
# Silent failure path for tests # Silent failure path for tests
return False return False
# Test telemetry enabled status # Test telemetry enabled status
_ = is_telemetry_enabled() _ = is_telemetry_enabled()
# Test basic record # Test basic record
try: try:
record_telemetry(RecordType.VERSION, { record_telemetry(RecordType.VERSION, {
@ -39,7 +39,7 @@ def test_telemetry_basic():
except Exception as e: except Exception as e:
# Silent failure path for tests # Silent failure path for tests
return False return False
# Test milestone recording # Test milestone recording
try: try:
is_first = record_milestone(MilestoneType.FIRST_STARTUP, { is_first = record_milestone(MilestoneType.FIRST_STARTUP, {
@ -49,7 +49,7 @@ def test_telemetry_basic():
except Exception as e: except Exception as e:
# Silent failure path for tests # Silent failure path for tests
return False return False
# Test telemetry collector # Test telemetry collector
try: try:
collector = get_telemetry() collector = get_telemetry()
@ -57,79 +57,83 @@ def test_telemetry_basic():
except Exception as e: except Exception as e:
# Silent failure path for tests # Silent failure path for tests
return False return False
return True return True
def test_telemetry_disabled(): def test_telemetry_disabled():
"""Test telemetry with disabled state""" """Test telemetry with disabled state"""
# Silent for tests # Silent for tests
# Set environment variable to disable telemetry # Set environment variable to disable telemetry
os.environ["DISABLE_TELEMETRY"] = "true" os.environ["DISABLE_TELEMETRY"] = "true"
# Re-import to get fresh config # Re-import to get fresh config
import importlib import importlib
import telemetry import telemetry
importlib.reload(telemetry) importlib.reload(telemetry)
from telemetry import is_telemetry_enabled, record_telemetry, RecordType from telemetry import is_telemetry_enabled, record_telemetry, RecordType
_ = is_telemetry_enabled() _ = is_telemetry_enabled()
if not is_telemetry_enabled(): if not is_telemetry_enabled():
pass pass
# Test that records are ignored when disabled # Test that records are ignored when disabled
record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"})
pass pass
return True return True
else: else:
pass pass
return False return False
def test_data_storage(): def test_data_storage():
"""Test data storage functionality""" """Test data storage functionality"""
# Silent for tests # Silent for tests
try: try:
from telemetry import get_telemetry from telemetry import get_telemetry
collector = get_telemetry() collector = get_telemetry()
data_dir = collector.config.data_dir 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 # Check if files exist
if collector.config.uuid_file.exists(): if collector.config.uuid_file.exists():
pass pass
else: else:
pass pass
if collector.config.milestones_file.exists(): if collector.config.milestones_file.exists():
pass pass
else: else:
pass pass
return True return True
except Exception as e: except Exception as e:
# Silent failure path for tests # Silent failure path for tests
return False return False
def main(): def main():
"""Run all telemetry tests""" """Run all telemetry tests"""
# Silent runner for CI # Silent runner for CI
tests = [ tests = [
test_telemetry_basic, test_telemetry_basic,
test_data_storage, test_data_storage,
test_telemetry_disabled, test_telemetry_disabled,
] ]
passed = 0 passed = 0
failed = 0 failed = 0
for test in tests: for test in tests:
try: try:
if test(): if test():
@ -141,9 +145,9 @@ def main():
except Exception as e: except Exception as e:
failed += 1 failed += 1
pass pass
_ = (passed, failed) _ = (passed, failed)
if failed == 0: if failed == 0:
pass pass
return True return True
@ -151,6 +155,7 @@ def main():
pass pass
return False return False
if __name__ == "__main__": if __name__ == "__main__":
success = main() success = main()
sys.exit(0 if success else 1) sys.exit(0 if success else 1)

View File

@ -1,10 +1,14 @@
import logging import logging
from mcp.server.fastmcp import FastMCP
from .manage_script_edits import register_manage_script_edits_tools from .manage_script_edits import register_manage_script_edits_tools
from .manage_script import register_manage_script_tools from .manage_script import register_manage_script_tools
from .manage_scene import register_manage_scene_tools from .manage_scene import register_manage_scene_tools
from .manage_editor import register_manage_editor_tools from .manage_editor import register_manage_editor_tools
from .manage_gameobject import register_manage_gameobject_tools from .manage_gameobject import register_manage_gameobject_tools
from .manage_asset import register_manage_asset_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 .manage_shader import register_manage_shader_tools
from .read_console import register_read_console_tools from .read_console import register_read_console_tools
from .manage_menu_item import register_manage_menu_item_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") 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.""" """Register all refactored tools with the MCP server."""
# Prefer the surgical edits tool so LLMs discover it first # Prefer the surgical edits tool so LLMs discover it first
logger.info("Registering MCP for Unity Server refactored tools...") 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_editor_tools(mcp)
register_manage_gameobject_tools(mcp) register_manage_gameobject_tools(mcp)
register_manage_asset_tools(mcp) register_manage_asset_tools(mcp)
register_manage_prefabs_tools(mcp)
register_manage_shader_tools(mcp) register_manage_shader_tools(mcp)
register_read_console_tools(mcp) register_read_console_tools(mcp)
register_manage_menu_item_tools(mcp) register_manage_menu_item_tools(mcp)

View File

@ -1,58 +1,45 @@
""" """
Defines the manage_asset tool for interacting with Unity assets. Defines the manage_asset tool for interacting with Unity assets.
""" """
import asyncio # Added: Import asyncio for running sync code in async import asyncio
from typing import Dict, Any from typing import Annotated, Any, Literal
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
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import async_send_command_with_retry
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
def register_manage_asset_tools(mcp: FastMCP): def register_manage_asset_tools(mcp: FastMCP):
"""Registers the manage_asset tool with the MCP server.""" """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") @telemetry_tool("manage_asset")
async def manage_asset( async def manage_asset(
ctx: Any, ctx: Context,
action: str, action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."],
path: str, path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."],
asset_type: str = None, asset_type: Annotated[str,
properties: Dict[str, Any] = None, "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None,
destination: str = None, properties: Annotated[dict[str, Any],
generate_preview: bool = False, "Dictionary of properties for 'create'/'modify'."] | None = None,
search_pattern: str = None, destination: Annotated[str,
filter_type: str = None, "Target path for 'duplicate'/'move'."] | None = None,
filter_date_after: str = None, generate_preview: Annotated[bool,
page_size: Any = None, "Generate a preview/thumbnail for the asset when supported."] = False,
page_number: Any = None search_pattern: Annotated[str,
) -> Dict[str, Any]: "Search pattern (e.g., '*.prefab')."] | None = None,
"""Performs asset operations (import, create, modify, delete, etc.) in Unity. filter_type: Annotated[str, "Filter type for search"] | None = None,
filter_date_after: Annotated[str,
Args: "Date after which to filter"] | None = None,
ctx: The MCP context. page_size: Annotated[int, "Page size for pagination"] | None = None,
action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). page_number: Annotated[int, "Page number for pagination"] | None = None
path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. ) -> dict[str, Any]:
asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. ctx.info(f"Processing manage_asset: {action}")
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').
"""
# Ensure properties is a dict if None # Ensure properties is a dict if None
if properties is None: if properties is None:
properties = {} properties = {}
# Coerce numeric inputs defensively # Coerce numeric inputs defensively
def _coerce_int(value, default=None): def _coerce_int(value, default=None):
if value is None: if value is None:
@ -86,15 +73,13 @@ def register_manage_asset_tools(mcp: FastMCP):
"pageSize": page_size, "pageSize": page_size,
"pageNumber": page_number "pageNumber": page_number
} }
# Remove None values to avoid sending unnecessary nulls # Remove None values to avoid sending unnecessary nulls
params_dict = {k: v for k, v in params_dict.items() if v is not None} params_dict = {k: v for k, v in params_dict.items() if v is not None}
# Get the current asyncio event loop # Get the current asyncio event loop
loop = asyncio.get_running_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 # Use centralized async retry helper to avoid blocking the event loop
result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop)
# Return the result obtained from Unity # Return the result obtained from Unity

View File

@ -1,37 +1,31 @@
from mcp.server.fastmcp import FastMCP, Context from typing import Annotated, Any, Literal
import time
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from telemetry import is_telemetry_enabled, record_tool_usage from telemetry import is_telemetry_enabled, record_tool_usage
from unity_connection import send_command_with_retry
def register_manage_editor_tools(mcp: FastMCP): def register_manage_editor_tools(mcp: FastMCP):
"""Register all editor management tools with the MCP server.""" """Register all editor management tools with the MCP server."""
@mcp.tool(description=( @mcp.tool(name="manage_editor", description="Controls and queries the Unity editor's state and settings")
"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')."
))
@telemetry_tool("manage_editor") @telemetry_tool("manage_editor")
def manage_editor( def manage_editor(
ctx: Context, ctx: Context,
action: str, action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows",
wait_for_completion: bool = None, "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."],
# --- Parameters for specific actions --- wait_for_completion: Annotated[bool,
tool_name: str = None, "Optional. If True, waits for certain actions"] | None = None,
tag_name: str = None, tool_name: Annotated[str,
layer_name: str = None, "Tool name when setting active tool"] | None = None,
) -> Dict[str, Any]: 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: try:
# Diagnostics: quick telemetry checks # Diagnostics: quick telemetry checks
if action == "telemetry_status": if action == "telemetry_status":
@ -44,16 +38,16 @@ def register_manage_editor_tools(mcp: FastMCP):
params = { params = {
"action": action, "action": action,
"waitForCompletion": wait_for_completion, "waitForCompletion": wait_for_completion,
"toolName": tool_name, # Corrected parameter name to match C# "toolName": tool_name, # Corrected parameter name to match C#
"tagName": tag_name, # Pass tag name "tagName": tag_name, # Pass tag name
"layerName": layer_name, # Pass layer name "layerName": layer_name, # Pass layer name
# Add other parameters based on the action being performed # Add other parameters based on the action being performed
# "width": width, # "width": width,
# "height": height, # "height": height,
# etc. # etc.
} }
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command using centralized retry helper # Send command using centralized retry helper
response = send_command_with_retry("manage_editor", params) response = send_command_with_retry("manage_editor", params)
@ -63,4 +57,4 @@ def register_manage_editor_tools(mcp: FastMCP):
return response if isinstance(response, dict) else {"success": False, "message": str(response)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing editor: {str(e)}"} return {"success": False, "message": f"Python error managing editor: {str(e)}"}

View File

@ -1,87 +1,74 @@
from mcp.server.fastmcp import FastMCP, Context from typing import Annotated, Any, Literal
from typing import Dict, Any, List
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_gameobject_tools(mcp: FastMCP): def register_manage_gameobject_tools(mcp: FastMCP):
"""Register all GameObject management tools with the MCP server.""" """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") @telemetry_tool("manage_gameobject")
def manage_gameobject( def manage_gameobject(
ctx: Any, ctx: Context,
action: str, action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."],
target: str = None, # GameObject identifier by name or path target: Annotated[str,
search_method: str = None, "GameObject identifier by name or path for modify/delete/component actions"] | None = None,
# --- Combined Parameters for Create/Modify --- search_method: Annotated[str,
name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) "How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] | None = None,
tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) name: Annotated[str,
parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) "GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] | None = None,
position: List[float] = None, tag: Annotated[str,
rotation: List[float] = None, "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
scale: List[float] = None, parent: Annotated[str,
components_to_add: List[str] = None, # List of component names to add "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
primitive_type: str = None, position: Annotated[list[float],
save_as_prefab: bool = False, "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None,
prefab_path: str = None, rotation: Annotated[list[float],
prefab_folder: str = "Assets/Prefabs", "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' --- # --- Parameters for 'modify' ---
set_active: bool = None, set_active: Annotated[bool,
layer: str = None, # Layer name "If True, sets the GameObject active"] | None = None,
components_to_remove: List[str] = None, layer: Annotated[str, "Layer name"] | None = None,
component_properties: Dict[str, Dict[str, Any]] = 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]}}`"""] | None = None,
# --- Parameters for 'find' --- # --- Parameters for 'find' ---
search_term: str = None, search_term: Annotated[str,
find_all: bool = False, "Search term for 'find' action"] | None = None,
search_in_children: bool = False, find_all: Annotated[bool,
search_inactive: bool = False, "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 Management Arguments --
component_name: str = None, component_name: Annotated[str,
includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields "Component name for 'add_component' and 'remove_component' actions"] | None = None,
) -> Dict[str, Any]: # Controls whether serialization of private [SerializeField] fields is included
"""Manages GameObjects: create, modify, delete, find, and component operations. includeNonPublicSerialized: Annotated[bool,
"Controls whether serialization of private [SerializeField] fields is included"] | None = None,
Args: ) -> dict[str, Any]:
action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). ctx.info(f"Processing manage_gameobject: {action}")
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)
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.
"""
try: try:
# --- Early check for attempting to modify a prefab asset ---
# ----------------------------------------------------------
# Prepare parameters, removing None values # Prepare parameters, removing None values
params = { params = {
"action": action, "action": action,
@ -110,9 +97,10 @@ def register_manage_gameobject_tools(mcp: FastMCP):
"includeNonPublicSerialized": includeNonPublicSerialized "includeNonPublicSerialized": includeNonPublicSerialized
} }
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# --- Handle Prefab Path Logic --- # --- 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 "prefabPath" not in params:
if "name" not in params or not params["name"]: if "name" not in params or not params["name"]:
return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."}
@ -124,9 +112,9 @@ def register_manage_gameobject_tools(mcp: FastMCP):
return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"} return {"success": False, "message": f"Invalid prefab_path: '{params['prefabPath']}' must end with .prefab"}
# Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided
# The C# side only needs the final prefabPath # The C# side only needs the final prefabPath
params.pop("prefabFolder", None) params.pop("prefabFolder", None)
# -------------------------------- # --------------------------------
# Use centralized retry helper # Use centralized retry helper
response = send_command_with_retry("manage_gameobject", params) response = send_command_with_retry("manage_gameobject", params)
@ -137,4 +125,4 @@ def register_manage_gameobject_tools(mcp: FastMCP):
return response if isinstance(response, dict) else {"success": False, "message": str(response)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} return {"success": False, "message": f"Python error managing GameObject: {str(e)}"}

View File

@ -7,36 +7,25 @@ from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool 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): def register_manage_menu_item_tools(mcp: FastMCP):
"""Registers the manage_menu_item tool with the MCP server.""" """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") @telemetry_tool("manage_menu_item")
async def manage_menu_item( async def manage_menu_item(
ctx: Context, ctx: Context,
action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"], action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."],
menu_path: Annotated[str | None, menu_path: Annotated[str,
"Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None,
search: Annotated[str | None, search: Annotated[str,
"Optional filter string for 'list' (e.g., 'Save')"] = None, "Optional filter string for 'list' (e.g., 'Save')"] | None = None,
refresh: Annotated[bool | None, refresh: Annotated[bool,
"Optional flag to force refresh of the menu cache when listing"] = None, "Optional flag to force refresh of the menu cache when listing"] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Manage Unity menu items (execute/list/exists). ctx.info(f"Processing manage_menu_item: {action}")
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').
"""
# Prepare parameters for the C# handler # Prepare parameters for the C# handler
params_dict: dict[str, Any] = { params_dict: dict[str, Any] = {
"action": action, "action": action,
@ -49,8 +38,6 @@ def register_manage_menu_item_tools(mcp: FastMCP):
# Get the current asyncio event loop # Get the current asyncio event loop
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Touch the connection to ensure availability (mirrors other tools' pattern)
_ = get_unity_connection()
# Use centralized async retry helper # Use centralized async retry helper
result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) 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 Annotated, Literal, Any
from typing import Dict, Any
from unity_connection import get_unity_connection, send_command_with_retry
from config import config
import time
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_scene_tools(mcp: FastMCP): def register_manage_scene_tools(mcp: FastMCP):
"""Register all scene management tools with the MCP server.""" """Register all scene management tools with the MCP server."""
@mcp.tool() @mcp.tool(name="manage_scene", description="Manage Unity scenes")
@telemetry_tool("manage_scene") @telemetry_tool("manage_scene")
def manage_scene( def manage_scene(
ctx: Context, ctx: Context,
action: str, action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
name: str = "", name: Annotated[str,
path: str = "", "Scene name. Not required get_active/get_build_settings"] | None = None,
build_index: Any = None, path: Annotated[str,
) -> Dict[str, Any]: "Asset path for scene operations (default: 'Assets/')"] | None = None,
"""Manages Unity scenes (load, save, create, get hierarchy, etc.). build_index: Annotated[int,
"Build index for load/build settings actions"] | None = None,
Args: ) -> dict[str, Any]:
action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). ctx.info(f"Processing manage_scene: {action}")
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').
"""
try: try:
# Coerce numeric inputs defensively # Coerce numeric inputs defensively
def _coerce_int(value, default=None): def _coerce_int(value, default=None):
@ -56,7 +48,7 @@ def register_manage_scene_tools(mcp: FastMCP):
params["path"] = path params["path"] = path
if coerced_build_index is not None: if coerced_build_index is not None:
params["buildIndex"] = coerced_build_index params["buildIndex"] = coerced_build_index
# Use centralized retry helper # Use centralized retry helper
response = send_command_with_retry("manage_scene", params) response = send_command_with_retry("manage_scene", params)
@ -66,4 +58,4 @@ def register_manage_scene_tools(mcp: FastMCP):
return response if isinstance(response, dict) else {"success": False, "message": str(response)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: except Exception as e:
return {"success": False, "message": f"Python error managing scene: {str(e)}"} return {"success": False, "message": f"Python error managing scene: {str(e)}"}

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 base64
import os import os
from typing import Annotated, Any, Literal
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
from mcp.server.fastmcp import FastMCP, Context
from unity_connection import send_command_with_retry
try: try:
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from telemetry import record_milestone, MilestoneType
HAS_TELEMETRY = True HAS_TELEMETRY = True
except ImportError: except ImportError:
HAS_TELEMETRY = False HAS_TELEMETRY = False
def telemetry_tool(tool_name: str): def telemetry_tool(tool_name: str):
def decorator(func): def decorator(func):
return func return func
return decorator return decorator
def register_manage_script_tools(mcp: FastMCP): def register_manage_script_tools(mcp: FastMCP):
"""Register all script management tools with the MCP server.""" """Register all script management tools with the MCP server."""
@ -32,7 +35,7 @@ def register_manage_script_tools(mcp: FastMCP):
""" """
raw_path: str raw_path: str
if uri.startswith("unity://path/"): if uri.startswith("unity://path/"):
raw_path = uri[len("unity://path/") :] raw_path = uri[len("unity://path/"):]
elif uri.startswith("file://"): elif uri.startswith("file://"):
parsed = urlparse(uri) parsed = urlparse(uri)
host = (parsed.netloc or "").strip() host = (parsed.netloc or "").strip()
@ -56,7 +59,8 @@ def register_manage_script_tools(mcp: FastMCP):
# If an 'Assets' segment exists, compute path relative to it (case-insensitive) # If an 'Assets' segment exists, compute path relative to it (case-insensitive)
parts = [p for p in norm.split("/") if p not in ("", ".")] 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 assets_rel = "/".join(parts[idx:]) if idx is not None else None
effective_path = assets_rel if assets_rel else norm 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) directory = os.path.dirname(effective_path)
return name, directory return name, directory
@mcp.tool(description=( @mcp.tool(name="apply_text_edits", description=(
"Apply small text edits to a C# script identified by URI.\n\n" """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!\n" IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!
"Common mistakes:\n" RECOMMENDED WORKFLOW:
"- Assuming what's on a line without checking\n" 1. First call resources/read with start_line/line_count to verify exact content
"- Using wrong line numbers (they're 1-indexed)\n" 2. Count columns carefully (or use find_in_file to locate patterns)
"- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n" 3. Apply your edit with precise coordinates
"RECOMMENDED WORKFLOW:\n" 4. Consider script_apply_edits with anchors for safer pattern-based replacements
"1) First call resources/read with start_line/line_count to verify exact content\n" Notes:
"2) Count columns carefully (or use find_in_file to locate patterns)\n" - For method/class operations, use script_apply_edits (safer, structured edits)
"3) Apply your edit with precise coordinates\n" - For pattern-based replacements, consider anchor operations in script_apply_edits
"4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" - Lines, columns are 1-indexed
"Args:\n" - Tabs count as 1 column"""
"- 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"
)) ))
@telemetry_tool("apply_text_edits") @telemetry_tool("apply_text_edits")
def apply_text_edits( def apply_text_edits(
ctx: Context, ctx: Context,
uri: str, uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
edits: List[Dict[str, Any]], 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: str | None = None, precondition_sha256: Annotated[str,
strict: bool | None = None, "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None,
options: Dict[str, Any] | None = None, strict: Annotated[bool,
) -> Dict[str, Any]: "Optional strict flag, used to enforce strict mode"] | None = None,
"""Apply small text edits to a C# script identified by URI.""" 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) name, directory = _split_uri(uri)
# Normalize common aliases/misuses for resilience: # Normalize common aliases/misuses for resilience:
# - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text}
# - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], 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. # 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 []: 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): 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 True
return False return False
normalized_edits: List[Dict[str, Any]] = [] normalized_edits: list[dict[str, Any]] = []
warnings: List[str] = [] warnings: list[str] = []
if _needs_normalization(edits): if _needs_normalization(edits):
# Read file to support index->line/col conversion when needed # Read file to support index->line/col conversion when needed
read_resp = send_command_with_retry("manage_script", { read_resp = send_command_with_retry("manage_script", {
@ -127,7 +127,8 @@ def register_manage_script_tools(mcp: FastMCP):
contents = data.get("contents") contents = data.get("contents")
if not contents and data.get("contentsEncoded"): if not contents and data.get("contentsEncoded"):
try: 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: except Exception:
contents = contents or "" contents = contents or ""
@ -151,7 +152,7 @@ def register_manage_script_tools(mcp: FastMCP):
if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2: if "startLine" in e2 and "startCol" in e2 and "endLine" in e2 and "endCol" in e2:
# Guard: explicit fields must be 1-based. # Guard: explicit fields must be 1-based.
zero_based = False zero_based = False
for k in ("startLine","startCol","endLine","endCol"): for k in ("startLine", "startCol", "endLine", "endCol"):
try: try:
if int(e2.get(k, 1)) < 1: if int(e2.get(k, 1)) < 1:
zero_based = True zero_based = True
@ -161,13 +162,14 @@ def register_manage_script_tools(mcp: FastMCP):
if strict: if strict:
return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}} return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": normalized_edits}}
# Normalize by clamping to 1 and warn # Normalize by clamping to 1 and warn
for k in ("startLine","startCol","endLine","endCol"): for k in ("startLine", "startCol", "endLine", "endCol"):
try: try:
if int(e2.get(k, 1)) < 1: if int(e2.get(k, 1)) < 1:
e2[k] = 1 e2[k] = 1
except Exception: except Exception:
pass pass
warnings.append("zero_based_explicit_fields_normalized") warnings.append(
"zero_based_explicit_fields_normalized")
normalized_edits.append(e2) normalized_edits.append(e2)
continue continue
@ -205,17 +207,18 @@ def register_manage_script_tools(mcp: FastMCP):
"success": False, "success": False,
"code": "missing_field", "code": "missing_field",
"message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'", "message": "apply_text_edits requires startLine/startCol/endLine/endCol/newText or a normalizable 'range'",
"data": {"expected": ["startLine","startCol","endLine","endCol","newText"], "got": e} "data": {"expected": ["startLine", "startCol", "endLine", "endCol", "newText"], "got": e}
} }
else: else:
# Even when edits appear already in explicit form, validate 1-based coordinates. # Even when edits appear already in explicit form, validate 1-based coordinates.
normalized_edits = [] normalized_edits = []
for e in edits or []: for e in edits or []:
e2 = dict(e) 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: if has_all:
zero_based = False zero_based = False
for k in ("startLine","startCol","endLine","endCol"): for k in ("startLine", "startCol", "endLine", "endCol"):
try: try:
if int(e2.get(k, 1)) < 1: if int(e2.get(k, 1)) < 1:
zero_based = True zero_based = True
@ -224,21 +227,24 @@ def register_manage_script_tools(mcp: FastMCP):
if zero_based: if zero_based:
if strict: if strict:
return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}} return {"success": False, "code": "zero_based_explicit_fields", "message": "Explicit line/col fields are 1-based; received zero-based.", "data": {"normalizedEdits": [e2]}}
for k in ("startLine","startCol","endLine","endCol"): for k in ("startLine", "startCol", "endLine", "endCol"):
try: try:
if int(e2.get(k, 1)) < 1: if int(e2.get(k, 1)) < 1:
e2[k] = 1 e2[k] = 1
except Exception: except Exception:
pass pass
if "zero_based_explicit_fields_normalized" not in warnings: 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) normalized_edits.append(e2)
# Preflight: detect overlapping ranges among normalized line/col spans # 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 ( return (
int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)), int(e.get("startLine", 1)) if key_start else int(
int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)), 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: 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. # preserves existing call-count expectations in clients/tests.
# Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance # 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: try:
if len(normalized_edits) > 1 and "applyMode" not in opts: if len(normalized_edits) > 1 and "applyMode" not in opts:
opts["applyMode"] = "atomic" 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"): if resp.get("success") and (options or {}).get("force_sentinel_reload"):
# Optional: flip sentinel via menu if explicitly requested # Optional: flip sentinel via menu if explicitly requested
try: try:
import threading, time, json, glob, os import threading
import time
import json
import glob
import os
def _latest_status() -> dict | None: def _latest_status() -> dict | None:
try: 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: if not files:
return None return None
with open(files[0], "r") as f: with open(files[0], "r") as f:
@ -352,24 +364,21 @@ def register_manage_script_tools(mcp: FastMCP):
return resp return resp
return {"success": False, "message": str(resp)} return {"success": False, "message": str(resp)}
@mcp.tool(description=( @mcp.tool(name="create_script", description=("Create a new C# script at the given project path."))
"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"
))
@telemetry_tool("create_script") @telemetry_tool("create_script")
def create_script( def create_script(
ctx: Context, ctx: Context,
path: str, path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
contents: str = "", contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."],
script_type: str | None = None, script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None,
namespace: str | None = None, namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> Dict[str, Any]: ) -> dict[str, Any]:
"""Create a new C# script at the given path.""" ctx.info(f"Processing create_script: {path}")
name = os.path.splitext(os.path.basename(path))[0] name = os.path.splitext(os.path.basename(path))[0]
directory = os.path.dirname(path) directory = os.path.dirname(path)
# Local validation to avoid round-trips on obviously bad input # 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": 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}'."} 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("/"): 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."} return {"success": False, "code": "bad_path", "message": "path must include a script file name."}
if not norm_path.lower().endswith(".cs"): if not norm_path.lower().endswith(".cs"):
return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."}
params: Dict[str, Any] = { params: dict[str, Any] = {
"action": "create", "action": "create",
"name": name, "name": name,
"path": directory, "path": directory,
@ -386,20 +395,21 @@ def register_manage_script_tools(mcp: FastMCP):
"scriptType": script_type, "scriptType": script_type,
} }
if contents: 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["contentsEncoded"] = True
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
resp = send_command_with_retry("manage_script", params) resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=( @mcp.tool(name="delete_script", description=("Delete a C# script by URI or Assets-relative path."))
"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"
))
@telemetry_tool("delete_script") @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.""" """Delete a C# script by URI."""
ctx.info(f"Processing delete_script: {uri}")
name, directory = _split_uri(uri) name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets": if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under '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) resp = send_command_with_retry("manage_script", params)
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=( @mcp.tool(name="validate_script", description=("Validate a C# script and return diagnostics."))
"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"
))
@telemetry_tool("validate_script") @telemetry_tool("validate_script")
def validate_script( def validate_script(
ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False ctx: Context,
) -> Dict[str, Any]: uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."],
"""Validate a C# script and return diagnostics.""" 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) name, directory = _split_uri(uri)
if not directory or directory.split("/")[0].lower() != "assets": if not directory or directory.split("/")[0].lower() != "assets":
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under '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) resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
diags = resp.get("data", {}).get("diagnostics", []) or [] diags = resp.get("data", {}).get("diagnostics", []) or []
warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning") warnings = sum(1 for d in diags if str(
errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal")) d.get("severity", "")).lower() == "warning")
errors = sum(1 for d in diags if str(
d.get("severity", "")).lower() in ("error", "fatal"))
if include_diagnostics: if include_diagnostics:
return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}}
return {"success": True, "data": {"warnings": warnings, "errors": errors}} return {"success": True, "data": {"warnings": warnings, "errors": errors}}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
@mcp.tool(description=( @mcp.tool(name="manage_script", description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits."))
"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"
))
@telemetry_tool("manage_script") @telemetry_tool("manage_script")
def manage_script( def manage_script(
ctx: Context, ctx: Context,
action: str, action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."],
name: str, name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"],
path: str, path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"],
contents: str = "", contents: Annotated[str, "Contents of the script to create",
script_type: str | None = None, "C# code for 'create'/'update'"] | None = None,
namespace: str | None = None, script_type: Annotated[str, "Script type (e.g., 'C#')",
) -> Dict[str, Any]: "Type hint (e.g., 'MonoBehaviour')"] | None = None,
"""Compatibility router for legacy script operations. namespace: Annotated[str, "Namespace for the script"] | None = None,
) -> dict[str, Any]:
IMPORTANT: ctx.info(f"Processing manage_script: {action}")
- 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').
"""
try: 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 # Prepare parameters for Unity
params = { params = {
"action": action, "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 # Base64 encode the contents if they exist to avoid JSON escaping issues
if contents: if contents:
if action == 'create': 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 params["contentsEncoded"] = True
else: else:
params["contents"] = contents params["contents"] = contents
@ -554,7 +491,8 @@ def register_manage_script_tools(mcp: FastMCP):
if isinstance(response, dict): if isinstance(response, dict):
if response.get("success"): if response.get("success"):
if response.get("data", {}).get("contentsEncoded"): 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 response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"] del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"] del response["data"]["contentsEncoded"]
@ -574,19 +512,24 @@ def register_manage_script_tools(mcp: FastMCP):
"message": f"Python error managing script: {str(e)}", "message": f"Python error managing script: {str(e)}",
} }
@mcp.tool(description=( @mcp.tool(name="manage_script_capabilities", description=(
"Get manage_script capabilities (supported ops, limits, and guards).\n\n" """Get manage_script capabilities (supported ops, limits, and guards).
"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" 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") @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: try:
# Keep in sync with server/Editor ManageScript implementation # Keep in sync with server/Editor ManageScript implementation
ops = [ ops = [
"replace_class","delete_class","replace_method","delete_method", "replace_class", "delete_class", "replace_method", "delete_method",
"insert_method","anchor_insert","anchor_delete","anchor_replace" "insert_method", "anchor_insert", "anchor_delete", "anchor_replace"
] ]
text_ops = ["replace_range","regex_replace","prepend","append"] text_ops = ["replace_range", "regex_replace", "prepend", "append"]
# Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback # Match ManageScript.MaxEditPayloadBytes if exposed; hardcode a sensible default fallback
max_edit_payload_bytes = 256 * 1024 max_edit_payload_bytes = 256 * 1024
guards = {"using_guard": True} guards = {"using_guard": True}
@ -601,21 +544,21 @@ def register_manage_script_tools(mcp: FastMCP):
except Exception as e: except Exception as e:
return {"success": False, "error": f"capabilities error: {e}"} return {"success": False, "error": f"capabilities error: {e}"}
@mcp.tool(description=( @mcp.tool(name="get_sha", description="Get SHA256 and basic metadata for a Unity C# script without returning file contents")
"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}."
))
@telemetry_tool("get_sha") @telemetry_tool("get_sha")
def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: def get_sha(
"""Return SHA256 and basic metadata for a script.""" 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: try:
name, directory = _split_uri(uri) name, directory = _split_uri(uri)
params = {"action": "get_sha", "name": name, "path": directory} params = {"action": "get_sha", "name": name, "path": directory}
resp = send_command_with_retry("manage_script", params) resp = send_command_with_retry("manage_script", params)
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
data = resp.get("data", {}) 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 {"success": True, "data": minimal}
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
except Exception as e: 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 base64
import hashlib
import re import re
import os from typing import Annotated, Any
from unity_connection import send_command_with_retry
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool 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 text = original_text
for edit in edits or []: for edit in edits or []:
op = ( op = (
@ -29,7 +30,8 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
if op == "prepend": if op == "prepend":
prepend_text = edit.get("text", "") 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": elif op == "append":
append_text = edit.get("text", "") append_text = edit.get("text", "")
if not text.endswith("\n"): 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", "") anchor = edit.get("anchor", "")
position = (edit.get("position") or "before").lower() position = (edit.get("position") or "before").lower()
insert_text = edit.get("text", "") 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 # 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 not match:
if edit.get("allow_noop", True): if edit.get("allow_noop", True):
continue continue
@ -53,15 +57,16 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
text = text[:idx] + insert_text + text[idx:] text = text[:idx] + insert_text + text[idx:]
elif op == "replace_range": elif op == "replace_range":
start_line = int(edit.get("startLine", 1)) start_line = int(edit.get("startLine", 1))
start_col = int(edit.get("startCol", 1)) start_col = int(edit.get("startCol", 1))
end_line = int(edit.get("endLine", start_line)) end_line = int(edit.get("endLine", start_line))
end_col = int(edit.get("endCol", 1)) end_col = int(edit.get("endCol", 1))
replacement = edit.get("text", "") replacement = edit.get("text", "")
lines = text.splitlines(keepends=True) lines = text.splitlines(keepends=True)
max_line = len(lines) + 1 # 1-based, exclusive end max_line = len(lines) + 1 # 1-based, exclusive end
if (start_line < 1 or end_line < start_line or end_line > max_line if (start_line < 1 or end_line < start_line or end_line > max_line
or start_col < 1 or end_col < 1): or start_col < 1 or end_col < 1):
raise RuntimeError("replace_range out of bounds") raise RuntimeError("replace_range out of bounds")
def index_of(line: int, col: int) -> int: def index_of(line: int, col: int) -> int:
if line <= len(lines): if line <= len(lines):
return sum(len(l) for l in lines[: line - 1]) + (col - 1) return sum(len(l) for l in lines[: line - 1]) + (col - 1)
@ -81,48 +86,49 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str
text = re.sub(pattern, repl_py, text, count=count, flags=flags) text = re.sub(pattern, repl_py, text, count=count, flags=flags)
else: else:
allowed = "anchor_insert, prepend, append, replace_range, regex_replace" 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 return text
def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
""" """
Find the best anchor match using improved heuristics. Find the best anchor match using improved heuristics.
For patterns like \\s*}\\s*$ that are meant to find class-ending braces, For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
this function uses heuristics to choose the most semantically appropriate match: this function uses heuristics to choose the most semantically appropriate match:
1. If prefer_last=True, prefer the last match (common for class-end insertions) 1. If prefer_last=True, prefer the last match (common for class-end insertions)
2. Use indentation levels to distinguish class vs method braces 2. Use indentation levels to distinguish class vs method braces
3. Consider context to avoid matches inside strings/comments 3. Consider context to avoid matches inside strings/comments
Args: Args:
pattern: Regex pattern to search for pattern: Regex pattern to search for
text: Text to search in text: Text to search in
flags: Regex flags flags: Regex flags
prefer_last: If True, prefer the last match over the first prefer_last: If True, prefer the last match over the first
Returns: Returns:
Match object of the best match, or None if no match found Match object of the best match, or None if no match found
""" """
import re
# Find all matches # Find all matches
matches = list(re.finditer(pattern, text, flags)) matches = list(re.finditer(pattern, text, flags))
if not matches: if not matches:
return None return None
# If only one match, return it # If only one match, return it
if len(matches) == 1: if len(matches) == 1:
return matches[0] return matches[0]
# For patterns that look like they're trying to match closing braces at end of lines # 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: if is_closing_brace_pattern and prefer_last:
# Use heuristics to find the best closing brace match # Use heuristics to find the best closing brace match
return _find_best_closing_brace_match(matches, text) return _find_best_closing_brace_match(matches, text)
# Default behavior: use last match if prefer_last, otherwise first match # Default behavior: use last match if prefer_last, otherwise first match
return matches[-1] if prefer_last else matches[0] return matches[-1] if prefer_last else matches[0]
@ -130,68 +136,70 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo
def _find_best_closing_brace_match(matches, text: str): def _find_best_closing_brace_match(matches, text: str):
""" """
Find the best closing brace match using C# structure heuristics. Find the best closing brace match using C# structure heuristics.
Enhanced heuristics for scope-aware matching: Enhanced heuristics for scope-aware matching:
1. Prefer matches with lower indentation (likely class-level) 1. Prefer matches with lower indentation (likely class-level)
2. Prefer matches closer to end of file 2. Prefer matches closer to end of file
3. Avoid matches that seem to be inside method bodies 3. Avoid matches that seem to be inside method bodies
4. For #endregion patterns, ensure class-level context 4. For #endregion patterns, ensure class-level context
5. Validate insertion point is at appropriate scope 5. Validate insertion point is at appropriate scope
Args: Args:
matches: List of regex match objects matches: List of regex match objects
text: The full text being searched text: The full text being searched
Returns: Returns:
The best match object The best match object
""" """
if not matches: if not matches:
return None return None
scored_matches = [] scored_matches = []
lines = text.splitlines() lines = text.splitlines()
for match in matches: for match in matches:
score = 0 score = 0
start_pos = match.start() start_pos = match.start()
# Find which line this match is on # Find which line this match is on
lines_before = text[:start_pos].count('\n') lines_before = text[:start_pos].count('\n')
line_num = lines_before line_num = lines_before
if line_num < len(lines): if line_num < len(lines):
line_content = lines[line_num] line_content = lines[line_num]
# Calculate indentation level (lower is better for class braces) # Calculate indentation level (lower is better for class braces)
indentation = len(line_content) - len(line_content.lstrip()) indentation = len(line_content) - len(line_content.lstrip())
# Prefer lower indentation (class braces are typically less indented than method braces) # 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) # Prefer matches closer to end of file (class closing braces are typically at the end)
distance_from_end = len(lines) - line_num 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 # Look at surrounding context to avoid method braces
context_start = max(0, line_num - 3) context_start = max(0, line_num - 3)
context_end = min(len(lines), line_num + 2) context_end = min(len(lines), line_num + 2)
context_lines = lines[context_start:context_end] context_lines = lines[context_start:context_end]
# Penalize if this looks like it's inside a method (has method-like patterns above) # Penalize if this looks like it's inside a method (has method-like patterns above)
for context_line in context_lines: for context_line in context_lines:
if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
score -= 5 # Penalty for being near method signatures score -= 5 # Penalty for being near method signatures
# Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
if indentation <= 4 and distance_from_end <= 3: if indentation <= 4 and distance_from_end <= 3:
score += 15 # Bonus for likely class-ending brace score += 15 # Bonus for likely class-ending brace
scored_matches.append((score, match)) scored_matches.append((score, match))
# Return the match with the highest score # Return the match with the highest score
scored_matches.sort(key=lambda x: x[0], reverse=True) scored_matches.sort(key=lambda x: x[0], reverse=True)
best_match = scored_matches[0][1] best_match = scored_matches[0][1]
return best_match return best_match
@ -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 # 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". """Best-effort normalization of script "name" and "path".
Accepts any of: Accepts any of:
@ -258,7 +265,8 @@ def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]:
parts = candidate.split("/") parts = candidate.split("/")
file_name = parts[-1] file_name = parts[-1]
dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" 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 return base, dir_path
# Fall back: remove extension from name if present and return given 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") 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): if not isinstance(resp, dict):
return resp return resp
data = resp.setdefault("data", {}) data = resp.setdefault("data", {})
@ -276,10 +284,11 @@ def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing:
return resp return resp
def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, 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]: 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} payload: dict[str, Any] = {"success": False,
data: Dict[str, Any] = {} "code": code, "message": message}
data: dict[str, Any] = {}
if expected: if expected:
data["expected"] = expected data["expected"] = expected
if rewrite: if rewrite:
@ -298,77 +307,78 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew
def register_manage_script_edits_tools(mcp: FastMCP): def register_manage_script_edits_tools(mcp: FastMCP):
@mcp.tool(description=( @mcp.tool(name="script_apply_edits", description=(
"Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
"Best practices:\n" Best practices:
"- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" - Prefer anchor_* ops for pattern-based insert/replace near stable markers
"- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n" - Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
"- Avoid whole-file regex deletes; validators will guard unbalanced braces\n" - Avoid whole-file regex deletes; validators will guard unbalanced braces
"- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n" - For tail insertions, prefer anchor/regex_replace on final brace (class closing)
"- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n" - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
"Canonical fields (use these exact keys):\n" Canonical fields (use these exact keys):
"- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
"- className: string (defaults to 'name' if omitted on method/class ops)\n" - className: string (defaults to 'name' if omitted on method/class ops)
"- methodName: string (required for replace_method, delete_method)\n" - methodName: string (required for replace_method, delete_method)
"- replacement: string (required for replace_method, insert_method)\n" - replacement: string (required for replace_method, insert_method)
"- position: start | end | after | before (insert_method only)\n" - position: start | end | after | before (insert_method only)
"- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" - afterMethodName / beforeMethodName: string (required when position='after'/'before')
"- anchor: regex string (for anchor_* ops)\n" - anchor: regex string (for anchor_* ops)
"- text: string (for anchor_insert/anchor_replace)\n\n" - text: string (for anchor_insert/anchor_replace)
"Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" Examples:
"Examples:\n" 1) Replace a method:
"1) Replace a method:\n" {
"{\n" "name": "SmartReach",
" \"name\": \"SmartReach\",\n" "path": "Assets/Scripts/Interaction",
" \"path\": \"Assets/Scripts/Interaction\",\n" "edits": [
" \"edits\": [\n" {
" {\n" "op": "replace_method",
" \"op\": \"replace_method\",\n" "className": "SmartReach",
" \"className\": \"SmartReach\",\n" "methodName": "HasTarget",
" \"methodName\": \"HasTarget\",\n" "replacement": "public bool HasTarget(){ return currentTarget!=null; }"
" \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n" }
" }\n" ],
" ],\n" "options": {"validate": "standard", "refresh": "immediate"}
" \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n" }
"}\n\n" "2) Insert a method after another:
"2) Insert a method after another:\n" {
"{\n" "name": "SmartReach",
" \"name\": \"SmartReach\",\n" "path": "Assets/Scripts/Interaction",
" \"path\": \"Assets/Scripts/Interaction\",\n" "edits": [
" \"edits\": [\n" {
" {\n" "op": "insert_method",
" \"op\": \"insert_method\",\n" "className": "SmartReach",
" \"className\": \"SmartReach\",\n" "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
" \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n" "position": "after",
" \"position\": \"after\",\n" "afterMethodName": "GetCurrentTarget"
" \"afterMethodName\": \"GetCurrentTarget\"\n" }
" }\n" ],
" ]\n" }
"}\n\n" ]"""
"Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n"
)) ))
@telemetry_tool("script_apply_edits") @telemetry_tool("script_apply_edits")
def script_apply_edits( def script_apply_edits(
ctx: Context, ctx: Context,
name: str, name: Annotated[str, "Name of the script to edit"],
path: str, path: Annotated[str, "Path to the script to edit under Assets/ directory"],
edits: List[Dict[str, Any]], edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"],
options: Optional[Dict[str, Any]] = None, options: Annotated[dict[str, Any],
script_type: str = "MonoBehaviour", "Options for the script edit"] | None = None,
namespace: str = "", script_type: Annotated[str,
) -> Dict[str, Any]: "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. # Normalize locator first so downstream calls target the correct script file.
name, path = _normalize_script_locator(name, path) 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 # 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": {...}} # Unwrap single-key wrappers like {"replace_method": {...}}
for wrapper_key in ( for wrapper_key in (
"replace_method","insert_method","delete_method", "replace_method", "insert_method", "delete_method",
"replace_class","delete_class", "replace_class", "delete_class",
"anchor_insert","anchor_replace","anchor_delete", "anchor_insert", "anchor_replace", "anchor_delete",
): ):
if wrapper_key in edit and isinstance(edit[wrapper_key], dict): if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
inner = dict(edit[wrapper_key]) inner = dict(edit[wrapper_key])
@ -377,7 +387,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
break break
e = dict(edit) 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: if op:
e["op"] = op e["op"] = op
@ -452,13 +463,14 @@ def register_manage_script_edits_tools(mcp: FastMCP):
e["text"] = edit.get("newText", "") e["text"] = edit.get("newText", "")
return e return e
normalized_edits: List[Dict[str, Any]] = [] normalized_edits: list[dict[str, Any]] = []
for raw in edits or []: for raw in edits or []:
e = _unwrap_and_alias(raw) 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 # 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"): if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
e["className"] = name e["className"] = name
# Map common aliases for text ops # Map common aliases for text ops
@ -475,7 +487,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if "text" in e: if "text" in e:
e["replacement"] = e.get("text", "") e["replacement"] = e.get("text", "")
elif "insert" in e or "content" in e: 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")): 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" e["op"] = "anchor_delete"
normalized_edits.append(e) normalized_edits.append(e)
@ -486,7 +499,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
normalized_for_echo = edits normalized_for_echo = edits
# Validate required fields and produce machine-parsable hints # 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) return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
for e in edits or []: for e in edits or []:
@ -495,40 +508,46 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not e.get("methodName"): if not e.get("methodName"):
return error_with_hint( return error_with_hint(
"replace_method requires 'methodName'.", "replace_method requires 'methodName'.",
{"op": "replace_method", "required": ["className", "methodName", "replacement"]}, {"op": "replace_method", "required": [
"className", "methodName", "replacement"]},
{"edits[0].methodName": "HasTarget"} {"edits[0].methodName": "HasTarget"}
) )
if not (e.get("replacement") or e.get("text")): if not (e.get("replacement") or e.get("text")):
return error_with_hint( return error_with_hint(
"replace_method requires 'replacement' (inline or base64).", "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; }"} {"edits[0].replacement": "public bool X(){ return true; }"}
) )
elif op == "insert_method": elif op == "insert_method":
if not (e.get("replacement") or e.get("text")): if not (e.get("replacement") or e.get("text")):
return error_with_hint( return error_with_hint(
"insert_method requires a non-empty 'replacement'.", "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\"); }"} {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
) )
pos = (e.get("position") or "").lower() pos = (e.get("position") or "").lower()
if pos == "after" and not e.get("afterMethodName"): if pos == "after" and not e.get("afterMethodName"):
return error_with_hint( return error_with_hint(
"insert_method with position='after' requires 'afterMethodName'.", "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"} {"edits[0].afterMethodName": "GetCurrentTarget"}
) )
if pos == "before" and not e.get("beforeMethodName"): if pos == "before" and not e.get("beforeMethodName"):
return error_with_hint( return error_with_hint(
"insert_method with position='before' requires 'beforeMethodName'.", "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"} {"edits[0].beforeMethodName": "GetCurrentTarget"}
) )
elif op == "delete_method": elif op == "delete_method":
if not e.get("methodName"): if not e.get("methodName"):
return error_with_hint( return error_with_hint(
"delete_method requires 'methodName'.", "delete_method requires 'methodName'.",
{"op": "delete_method", "required": ["className", "methodName"]}, {"op": "delete_method", "required": [
"className", "methodName"]},
{"edits[0].methodName": "PrintSeries"} {"edits[0].methodName": "PrintSeries"}
) )
elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
@ -546,9 +565,10 @@ def register_manage_script_edits_tools(mcp: FastMCP):
) )
# Decide routing: structured vs text vs mixed # 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",
TEXT = {"prepend","append","replace_range","regex_replace"} "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
ops_set = { (e.get("op") or "").lower() for e in edits or [] } 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) all_struct = ops_set.issubset(STRUCT)
all_text = ops_set.issubset(TEXT) all_text = ops_set.issubset(TEXT)
mixed = not (all_struct or all_text) mixed = not (all_struct or all_text)
@ -558,7 +578,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
opts2 = dict(options or {}) opts2 = dict(options or {})
# For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
opts2.setdefault("refresh", "immediate") opts2.setdefault("refresh", "immediate")
params_struct: Dict[str, Any] = { params_struct: dict[str, Any] = {
"action": "edit", "action": "edit",
"name": name, "name": name,
"path": path, "path": path,
@ -567,7 +587,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": edits, "edits": edits,
"options": opts2, "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"): if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated) 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") 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"): 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)} 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") contents = data.get("contents")
if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): 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: if contents is None:
return {"success": False, "message": "No contents returned from Unity read."} 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 we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
if mixed: if mixed:
text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] text_edits = [e for e in edits or [] if (
struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT] e.get("op") or "").lower() in TEXT]
struct_edits = [e for e in edits or [] if (
e.get("op") or "").lower() in STRUCT]
try: try:
base_text = contents 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 line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx) 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 return line, col
at_edits: List[Dict[str, Any]] = [] at_edits: list[dict[str, Any]] = []
import re as _re
for e in text_edits: for e in text_edits:
opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() opx = (e.get("op") or e.get("operation") or e.get(
text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" "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": if opx == "anchor_insert":
anchor = e.get("anchor") or "" anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower() 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: try:
# Use improved anchor matching logic # 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: 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") 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: if not m:
@ -629,10 +659,11 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not text_field_norm.endswith("\n"): if not text_field_norm.endswith("\n"):
text_field_norm = text_field_norm + "\n" text_field_norm = text_field_norm + "\n"
sl, sc = line_col_from_index(idx) 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 # do not mutate base_text when building atomic spans
elif opx == "replace_range": elif opx == "replace_range":
if all(k in e for k in ("startLine","startCol","endLine","endCol")): if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
at_edits.append({ at_edits.append({
"startLine": int(e.get("startLine", 1)), "startLine": int(e.get("startLine", 1)),
"startCol": int(e.get("startCol", 1)), "startCol": int(e.get("startCol", 1)),
@ -645,39 +676,44 @@ def register_manage_script_edits_tools(mcp: FastMCP):
elif opx == "regex_replace": elif opx == "regex_replace":
pattern = e.get("pattern") or "" pattern = e.get("pattern") or ""
try: 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: 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") 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) m = regex_obj.search(base_text)
if not m: if not m:
continue continue
# Expand $1, $2... in replacement using this match # Expand $1, $2... in replacement using this match
def _expand_dollars(rep: str, _m=m) -> str: 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) repl = _expand_dollars(text_field)
sl, sc = line_col_from_index(m.start()) sl, sc = line_col_from_index(m.start())
el, ec = line_col_from_index(m.end()) 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 # do not mutate base_text when building atomic spans
elif opx in ("prepend","append"): elif opx in ("prepend", "append"):
if opx == "prepend": if opx == "prepend":
sl, sc = 1, 1 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 # prepend can be applied atomically without local mutation
else: else:
# Insert at true EOF position (handles both \n and \r\n correctly) # Insert at true EOF position (handles both \n and \r\n correctly)
eof_idx = len(base_text) eof_idx = len(base_text)
sl, sc = line_col_from_index(eof_idx) sl, sc = line_col_from_index(eof_idx)
new_text = ("\n" if not base_text.endswith("\n") else "") + text_field new_text = ("\n" if not base_text.endswith(
at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) "\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 # do not mutate base_text when building atomic spans
else: 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") 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() sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
if at_edits: if at_edits:
params_text: Dict[str, Any] = { params_text: dict[str, Any] = {
"action": "apply_text_edits", "action": "apply_text_edits",
"name": name, "name": name,
"path": path, "path": path,
@ -687,7 +723,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"precondition_sha256": sha, "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"))} "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")): 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") 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) # Optional sentinel reload removed (deprecated)
@ -698,7 +735,7 @@ def register_manage_script_edits_tools(mcp: FastMCP):
opts2 = dict(options or {}) opts2 = dict(options or {})
# Prefer debounced background refresh unless explicitly overridden # Prefer debounced background refresh unless explicitly overridden
opts2.setdefault("refresh", "debounced") opts2.setdefault("refresh", "debounced")
params_struct: Dict[str, Any] = { params_struct: dict[str, Any] = {
"action": "edit", "action": "edit",
"name": name, "name": name,
"path": path, "path": path,
@ -707,7 +744,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
"edits": struct_edits, "edits": struct_edits,
"options": opts2 "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"): if isinstance(resp_struct, dict) and resp_struct.get("success"):
pass # Optional sentinel reload removed (deprecated) 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") 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 # 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. # so header guards and validation run on the C# side.
# Supported conversions: anchor_insert, replace_range, regex_replace (first match only). # 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 []) } text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} "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): if not text_ops.issubset(structured_kinds):
# Convert to apply_text_edits payload # Convert to apply_text_edits payload
try: try:
base_text = contents 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 # 1-based line/col against base buffer
line = base_text.count("\n", 0, idx) + 1 line = base_text.count("\n", 0, idx) + 1
last_nl = base_text.rfind("\n", 0, idx) 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 return line, col
at_edits: List[Dict[str, Any]] = [] at_edits: list[dict[str, Any]] = []
import re as _re import re as _re
for e in edits or []: 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 # 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": if op == "anchor_insert":
anchor = e.get("anchor") or "" anchor = e.get("anchor") or ""
position = (e.get("position") or "after").lower() position = (e.get("position") or "after").lower()
# Use improved anchor matching logic with helpful errors, honoring ignore_case # Use improved anchor matching logic with helpful errors, honoring ignore_case
try: try:
flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) flags = re.MULTILINE | (
m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) 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: 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") 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: if not m:
@ -778,19 +824,22 @@ def register_manage_script_edits_tools(mcp: FastMCP):
elif op == "regex_replace": elif op == "regex_replace":
pattern = e.get("pattern") or "" pattern = e.get("pattern") or ""
repl = text_field 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 # Early compile for clearer error messages
try: try:
regex_obj = _re.compile(pattern, flags) regex_obj = re.compile(pattern, flags)
except Exception as ex: 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") 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 # 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: if not m:
continue continue
# Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
def _expand_dollars(rep: str, _m=m) -> str: 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) repl_expanded = _expand_dollars(repl)
# Let C# side handle validation using Unity's built-in compiler services # Let C# side handle validation using Unity's built-in compiler services
sl, sc = line_col_from_index(m.start()) sl, sc = line_col_from_index(m.start())
@ -809,10 +858,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if not at_edits: 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") 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() sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
params: Dict[str, Any] = { params: dict[str, Any] = {
"action": "apply_text_edits", "action": "apply_text_edits",
"name": name, "name": name,
"path": path, "path": path,
@ -830,7 +877,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if isinstance(resp, dict) and resp.get("success"): if isinstance(resp, dict) and resp.get("success"):
pass # Optional sentinel reload removed (deprecated) pass # Optional sentinel reload removed (deprecated)
return _with_norm( 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, normalized_for_echo,
routing="text" routing="text"
) )
@ -843,7 +891,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
try: try:
preview_text = _apply_edits_locally(contents, edits) preview_text = _apply_edits_locally(contents, edits)
import difflib 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: if len(diff) > 800:
diff = diff[:800] + ["... (diff truncated) ..."] diff = diff[:800] + ["... (diff truncated) ..."]
if preview: if preview:
@ -870,7 +919,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
import difflib import difflib
a = contents.splitlines() a = contents.splitlines()
b = new_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 # Limit diff size to keep responses small
if len(diff) > 2000: if len(diff) > 2000:
diff = diff[:2000] + ["... (diff truncated) ..."] diff = diff[:2000] + ["... (diff truncated) ..."]
@ -882,7 +932,6 @@ def register_manage_script_edits_tools(mcp: FastMCP):
options.setdefault("validate", "standard") options.setdefault("validate", "standard")
options.setdefault("refresh", "debounced") options.setdefault("refresh", "debounced")
import hashlib
# Compute the SHA of the current file contents for the precondition # Compute the SHA of the current file contents for the precondition
old_lines = contents.splitlines(keepends=True) old_lines = contents.splitlines(keepends=True)
end_line = len(old_lines) + 1 # 1-based exclusive end end_line = len(old_lines) + 1 # 1-based exclusive end
@ -912,13 +961,8 @@ def register_manage_script_edits_tools(mcp: FastMCP):
if isinstance(write_resp, dict) and write_resp.get("success"): if isinstance(write_resp, dict) and write_resp.get("success"):
pass # Optional sentinel reload removed (deprecated) pass # Optional sentinel reload removed (deprecated)
return _with_norm( return _with_norm(
write_resp if isinstance(write_resp, dict) write_resp if isinstance(write_resp, dict)
else {"success": False, "message": str(write_resp)}, else {"success": False, "message": str(write_resp)},
normalized_for_echo, normalized_for_echo,
routing="text", 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 import base64
from typing import Annotated, Any, Literal
from mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_manage_shader_tools(mcp: FastMCP): def register_manage_shader_tools(mcp: FastMCP):
"""Register all shader script management tools with the MCP server.""" """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") @telemetry_tool("manage_shader")
def manage_shader( def manage_shader(
ctx: Any, ctx: Context,
action: str, action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."],
name: str, name: Annotated[str, "Shader name (no .cs extension)"],
path: str, path: Annotated[str, "Asset path (default: \"Assets/\")"],
contents: str, contents: Annotated[str,
) -> Dict[str, Any]: "Shader code for 'create'/'update'"] | None = None,
"""Manages shader scripts in Unity (create, read, update, delete). ) -> dict[str, Any]:
ctx.info(f"Processing manage_shader: {action}")
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').
"""
try: try:
# Prepare parameters for Unity # Prepare parameters for Unity
params = { params = {
@ -38,34 +28,36 @@ def register_manage_shader_tools(mcp: FastMCP):
"name": name, "name": name,
"path": path, "path": path,
} }
# Base64 encode the contents if they exist to avoid JSON escaping issues # Base64 encode the contents if they exist to avoid JSON escaping issues
if contents is not None: if contents is not None:
if action in ['create', 'update']: if action in ['create', 'update']:
# Encode content for safer transmission # 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 params["contentsEncoded"] = True
else: else:
params["contents"] = contents params["contents"] = contents
# Remove None values so they don't get sent as null # Remove None values so they don't get sent as null
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
# Send command via centralized retry helper # Send command via centralized retry helper
response = send_command_with_retry("manage_shader", params) response = send_command_with_retry("manage_shader", params)
# Process response from Unity # Process response from Unity
if isinstance(response, dict) and response.get("success"): if isinstance(response, dict) and response.get("success"):
# If the response contains base64 encoded content, decode it # If the response contains base64 encoded content, decode it
if response.get("data", {}).get("contentsEncoded"): 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 response["data"]["contents"] = decoded_contents
del response["data"]["encodedContents"] del response["data"]["encodedContents"]
del response["data"]["contentsEncoded"] del response["data"]["contentsEncoded"]
return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")} return {"success": True, "message": response.get("message", "Operation successful."), "data": response.get("data")}
return response if isinstance(response, dict) else {"success": False, "message": str(response)} return response if isinstance(response, dict) else {"success": False, "message": str(response)}
except Exception as e: except Exception as e:
# Handle Python-side errors (e.g., connection issues) # Handle Python-side errors (e.g., connection issues)
return {"success": False, "message": f"Python error managing shader: {str(e)}"} return {"success": False, "message": f"Python error managing shader: {str(e)}"}

View File

@ -1,47 +1,34 @@
""" """
Defines the read_console tool for accessing Unity Editor console messages. Defines the read_console tool for accessing Unity Editor console messages.
""" """
from typing import List, Dict, Any from typing import Annotated, Any, Literal
import time
from mcp.server.fastmcp import FastMCP, Context 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 telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry
def register_read_console_tools(mcp: FastMCP): def register_read_console_tools(mcp: FastMCP):
"""Registers the read_console tool with the MCP server.""" """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") @telemetry_tool("read_console")
def read_console( def read_console(
ctx: Context, ctx: Context,
action: str = None, action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."],
types: List[str] = None, types: Annotated[list[Literal['error', 'warning',
count: Any = None, 'log', 'all']], "Message types to get"] | None = None,
filter_text: str = None, count: Annotated[int, "Max messages to return"] | None = None,
since_timestamp: str = None, filter_text: Annotated[str, "Text filter for messages"] | None = None,
format: str = None, since_timestamp: Annotated[str,
include_stacktrace: bool = None "Get messages after this timestamp (ISO 8601)"] | None = None,
) -> Dict[str, Any]: format: Annotated[Literal['plain', 'detailed',
"""Gets messages from or clears the Unity Editor console. 'json'], "Output format"] | None = None,
include_stacktrace: Annotated[bool,
Args: "Include stack traces in output"] | None = None
ctx: The MCP context. ) -> dict[str, Any]:
action: Operation ('get' or 'clear'). ctx.info(f"Processing read_console: {action}")
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()
# Set defaults if values are None # Set defaults if values are None
action = action if action is not None else 'get' action = action if action is not None else 'get'
types = types if types is not None else ['error', 'warning', 'log'] types = types if types is not None else ['error', 'warning', 'log']
@ -51,7 +38,7 @@ def register_read_console_tools(mcp: FastMCP):
# Normalize action if it's a string # Normalize action if it's a string
if isinstance(action, str): if isinstance(action, str):
action = action.lower() action = action.lower()
# Coerce count defensively (string/float -> int) # Coerce count defensively (string/float -> int)
def _coerce_int(value, default=None): def _coerce_int(value, default=None):
if value is None: if value is None:
@ -82,11 +69,12 @@ def register_read_console_tools(mcp: FastMCP):
} }
# Remove None values unless it's 'count' (as None might mean 'all') # 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 # Add count back if it was None, explicitly sending null might be important for C# logic
if 'count' not in params_dict: if 'count' not in params_dict:
params_dict['count'] = None params_dict['count'] = None
# Use centralized retry helper # Use centralized retry helper
resp = send_command_with_retry("read_console", params_dict) resp = send_command_with_retry("read_console", params_dict)
@ -99,4 +87,4 @@ def register_read_console_tools(mcp: FastMCP):
line.pop("stacktrace", None) line.pop("stacktrace", None)
except Exception: except Exception:
pass pass
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}

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 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). 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 fnmatch
import hashlib import hashlib
import os 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 mcp.server.fastmcp import FastMCP, Context
from telemetry_decorator import telemetry_tool from telemetry_decorator import telemetry_tool
from unity_connection import send_command_with_retry 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. """Safely coerce various inputs (str/float/etc.) to an int.
Returns default on failure; clamps to minimum when provided. 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: except Exception:
return default return default
def _resolve_project_root(override: str | None) -> Path: def _resolve_project_root(override: str | None) -> Path:
# 1) Explicit override # 1) Explicit override
if override: if override:
@ -52,14 +53,17 @@ def _resolve_project_root(override: str | None) -> Path:
if env: if env:
env_path = Path(env).expanduser() env_path = Path(env).expanduser()
# If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir # 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(): if (pr / "Assets").exists():
return pr return pr
# 3) Ask Unity via manage_editor.get_project_root # 3) Ask Unity via manage_editor.get_project_root
try: 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"): 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(): if pr and (pr / "Assets").exists():
return pr return pr
except Exception: 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: def register_resource_tools(mcp: FastMCP) -> None:
"""Registers list_resources and read_resource wrapper tools.""" """Registers list_resources and read_resource wrapper tools."""
@mcp.tool(description=( @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"))
"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"
))
@telemetry_tool("list_resources") @telemetry_tool("list_resources")
async def list_resources( async def list_resources(
ctx: Optional[Context] = None, ctx: Context,
pattern: Optional[str] = "*.cs", pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs",
under: str = "Assets", under: Annotated[str,
limit: Any = 200, "Folder under project root, default is Assets"] = "Assets",
project_root: Optional[str] = None, limit: Annotated[int, "Page limit"] = 200,
) -> Dict[str, Any]: project_root: Annotated[str, "Project path"] | None = None,
""" ) -> dict[str, Any]:
Lists project URIs (unity://path/...) under a folder (default: Assets). ctx.info(f"Processing list_resources: {pattern}")
- pattern: glob like *.cs or *.shader (None to list all files)
- under: relative folder under project root
- limit: max results
"""
try: try:
project = _resolve_project_root(project_root) project = _resolve_project_root(project_root)
base = (project / under).resolve() base = (project / under).resolve()
@ -165,7 +160,7 @@ def register_resource_tools(mcp: FastMCP) -> None:
except ValueError: except ValueError:
return {"success": False, "error": "Listing is restricted to Assets/"} return {"success": False, "error": "Listing is restricted to Assets/"}
matches: List[str] = [] matches: list[str] = []
limit_int = _coerce_int(limit, default=200, minimum=1) limit_int = _coerce_int(limit, default=200, minimum=1)
for p in base.rglob("*"): for p in base.rglob("*"):
if not p.is_file(): if not p.is_file():
@ -194,33 +189,30 @@ def register_resource_tools(mcp: FastMCP) -> None:
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@mcp.tool(description=( @mcp.tool(name="read_resource", description=("Reads a resource by unity://path/... URI with optional slicing."))
"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"
))
@telemetry_tool("read_resource") @telemetry_tool("read_resource")
async def read_resource( async def read_resource(
uri: str, ctx: Context,
ctx: Optional[Context] = None, uri: Annotated[str, "The resource URI to read under Assets/"],
start_line: Any = None, start_line: Annotated[int,
line_count: Any = None, "The starting line number (0-based)"] | None = None,
head_bytes: Any = None, line_count: Annotated[int,
tail_lines: Any = None, "The number of lines to read"] | None = None,
project_root: Optional[str] = None, head_bytes: Annotated[int,
request: Optional[str] = None, "The number of bytes to read from the start of the file"] | None = None,
) -> Dict[str, Any]: tail_lines: Annotated[int,
""" "The number of lines to read from the end of the file"] | None = None,
Reads a resource by unity://path/... URI with optional slicing. project_root: Annotated[str,
One of line window (start_line/line_count) or head_bytes can be used to limit size. "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: try:
# Serve the canonical spec directly when requested (allow bare or with scheme) # Serve the canonical spec directly when requested (allow bare or with scheme)
if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"):
spec_json = ( spec_json = (
'{\n' '{\n'
' "name": "Unity MCP Script Edits v1",\n' ' "name": "Unity MCP - Script Edits v1",\n'
' "target_tool": "script_apply_edits",\n' ' "target_tool": "script_apply_edits",\n'
' "canonical_rules": {\n' ' "canonical_rules": {\n'
' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\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) m = re.search(r"first\s+(\d+)\s*bytes", req)
if m: if m:
head_bytes = int(m.group(1)) 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: if m:
window = int(m.group(1)) window = int(m.group(1))
method = m.group(2) method = m.group(2)
# naive search for method header to get a line number # naive search for method header to get a line number
text_all = p.read_text(encoding="utf-8") text_all = p.read_text(encoding="utf-8")
lines_all = text_all.splitlines() 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 hit_line = None
for i, line in enumerate(lines_all, start=1): for i, line in enumerate(lines_all, start=1):
if pat.search(line): if pat.search(line):
@ -329,7 +323,8 @@ def register_resource_tools(mcp: FastMCP) -> None:
full_sha = hashlib.sha256(full_bytes).hexdigest() full_sha = hashlib.sha256(full_bytes).hexdigest()
# Selection only when explicitly requested via windowing args or request text hints # 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: if selection_requested:
# Mutually exclusive windowing options precedence: # Mutually exclusive windowing options precedence:
# 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text # 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: except Exception as e:
return {"success": False, "error": str(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") @telemetry_tool("find_in_file")
async def find_in_file( async def find_in_file(
uri: str, ctx: Context,
pattern: str, uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
ctx: Optional[Context] = None, pattern: Annotated[str, "The regex pattern to search for"],
ignore_case: Optional[bool] = True, ignore_case: Annotated[bool, "Case-insensitive search"] | None = True,
project_root: Optional[str] = None, project_root: Annotated[str,
max_results: Any = 200, "The project root directory"] | None = None,
) -> Dict[str, Any]: max_results: Annotated[int,
""" "Cap results to avoid huge payloads"] = 200,
Searches a file with a regex pattern and returns line numbers and excerpts. ) -> dict[str, Any]:
- uri: unity://path/Assets/... or file path form supported by read_resource ctx.info(f"Processing find_in_file: {uri}")
- 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
try: try:
project = _resolve_project_root(project_root) project = _resolve_project_root(project_root)
p = _resolve_safe_path_from_uri(uri, project) 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)}} return {"success": True, "data": {"matches": results, "count": len(results)}}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}

View File

@ -1,17 +1,18 @@
from config import config
import contextlib import contextlib
from dataclasses import dataclass
import errno import errno
import json import json
import logging import logging
from pathlib import Path
from port_discovery import PortDiscovery
import random import random
import socket import socket
import struct import struct
import threading import threading
import time import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from config import config
from port_discovery import PortDiscovery
# Configure logging using settings from config # Configure logging using settings from config
logging.basicConfig( logging.basicConfig(
@ -26,6 +27,7 @@ _connection_lock = threading.Lock()
# Maximum allowed framed payload size (64 MiB) # Maximum allowed framed payload size (64 MiB)
FRAMED_MAX = 64 * 1024 * 1024 FRAMED_MAX = 64 * 1024 * 1024
@dataclass @dataclass
class UnityConnection: class UnityConnection:
"""Manages the socket connection to the Unity Editor.""" """Manages the socket connection to the Unity Editor."""
@ -33,7 +35,7 @@ class UnityConnection:
port: int = None # Will be set dynamically port: int = None # Will be set dynamically
sock: socket.socket = None # Socket for Unity communication sock: socket.socket = None # Socket for Unity communication
use_framing: bool = False # Negotiated per-connection use_framing: bool = False # Negotiated per-connection
def __post_init__(self): def __post_init__(self):
"""Set port from discovery if not explicitly provided""" """Set port from discovery if not explicitly provided"""
if self.port is None: if self.port is None:
@ -50,11 +52,14 @@ class UnityConnection:
return True return True
try: try:
# Bounded connect to avoid indefinite blocking # Bounded connect to avoid indefinite blocking
connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) connect_timeout = float(
self.sock = socket.create_connection((self.host, self.port), connect_timeout) 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 # Disable Nagle's algorithm to reduce small RPC latency
with contextlib.suppress(Exception): 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}") logger.debug(f"Connected to Unity at {self.host}:{self.port}")
# Strict handshake: require FRAMING=1 # Strict handshake: require FRAMING=1
@ -78,16 +83,20 @@ class UnityConnection:
if 'FRAMING=1' in text: if 'FRAMING=1' in text:
self.use_framing = True self.use_framing = True
logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') logger.debug(
'Unity MCP handshake received: FRAMING=1 (strict)')
else: else:
if require_framing: if require_framing:
# Best-effort plain-text advisory for legacy peers # Best-effort plain-text advisory for legacy peers
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.sock.sendall(b'Unity MCP requires FRAMING=1\n') self.sock.sendall(
raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') b'Unity MCP requires FRAMING=1\n')
raise ConnectionError(
f'Unity MCP requires FRAMING=1, got: {text!r}')
else: else:
self.use_framing = False 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: finally:
self.sock.settimeout(config.connection_timeout) self.sock.settimeout(config.connection_timeout)
return True return True
@ -116,7 +125,8 @@ class UnityConnection:
while len(data) < count: while len(data) < count:
chunk = sock.recv(count - len(data)) chunk = sock.recv(count - len(data))
if not chunk: if not chunk:
raise ConnectionError("Connection closed before reading expected bytes") raise ConnectionError(
"Connection closed before reading expected bytes")
data.extend(chunk) data.extend(chunk)
return bytes(data) return bytes(data)
@ -136,13 +146,16 @@ class UnityConnection:
heartbeat_count += 1 heartbeat_count += 1
if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline:
# Treat as empty successful response to match C# server behavior # 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"" return b""
continue continue
if payload_len > FRAMED_MAX: 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) 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 return payload
except socket.timeout as e: except socket.timeout as e:
logger.warning("Socket timeout during framed receive") logger.warning("Socket timeout during framed receive")
@ -158,21 +171,22 @@ class UnityConnection:
chunk = sock.recv(buffer_size) chunk = sock.recv(buffer_size)
if not chunk: if not chunk:
if not chunks: if not chunks:
raise Exception("Connection closed before receiving data") raise Exception(
"Connection closed before receiving data")
break break
chunks.append(chunk) chunks.append(chunk)
# Process the data received so far # Process the data received so far
data = b''.join(chunks) data = b''.join(chunks)
decoded_data = data.decode('utf-8') decoded_data = data.decode('utf-8')
# Check if we've received a complete response # Check if we've received a complete response
try: try:
# Special case for ping-pong # Special case for ping-pong
if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'):
logger.debug("Received ping response") logger.debug("Received ping response")
return data return data
# Handle escaped quotes in the content # Handle escaped quotes in the content
if '"content":' in decoded_data: if '"content":' in decoded_data:
# Find the content field and its value # Find the content field and its value
@ -182,19 +196,22 @@ class UnityConnection:
# Replace escaped quotes in content with regular quotes # Replace escaped quotes in content with regular quotes
content = decoded_data[content_start:content_end] content = decoded_data[content_start:content_end]
content = content.replace('\\"', '"') 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 # Validate JSON format
json.loads(decoded_data) json.loads(decoded_data)
# If we get here, we have valid JSON # 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 return data
except json.JSONDecodeError: except json.JSONDecodeError:
# We haven't received a complete valid JSON response yet # We haven't received a complete valid JSON response yet
continue continue
except Exception as e: 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 reading more chunks as this might not be the complete response
continue continue
except socket.timeout: except socket.timeout:
@ -217,7 +234,8 @@ class UnityConnection:
def read_status_file() -> dict | None: def read_status_file() -> dict | None:
try: 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: if not status_files:
return None return None
latest = status_files[0] latest = status_files[0]
@ -253,7 +271,8 @@ class UnityConnection:
payload = b'ping' payload = b'ping'
else: else:
command = {"type": command_type, "params": params or {}} 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 # Send/receive are serialized to protect the shared socket
with self._io_lock: with self._io_lock:
@ -280,7 +299,8 @@ class UnityConnection:
try: try:
response_data = self.receive_full_response(self.sock) response_data = self.receive_full_response(self.sock)
with contextlib.suppress(Exception): 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: finally:
if restore_timeout is not None: if restore_timeout is not None:
self.sock.settimeout(restore_timeout) self.sock.settimeout(restore_timeout)
@ -295,11 +315,13 @@ class UnityConnection:
resp = json.loads(response_data.decode('utf-8')) resp = json.loads(response_data.decode('utf-8'))
if resp.get('status') == 'error': 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) raise Exception(err)
return resp.get('result', {}) return resp.get('result', {})
except Exception as e: 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: try:
if self.sock: if self.sock:
self.sock.close() self.sock.close()
@ -310,7 +332,8 @@ class UnityConnection:
try: try:
new_port = PortDiscovery.discover_unity_port() new_port = PortDiscovery.discover_unity_port()
if new_port != self.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 self.port = new_port
except Exception as de: except Exception as de:
logger.debug(f"Port discovery failed: {de}") logger.debug(f"Port discovery failed: {de}")
@ -324,11 +347,13 @@ class UnityConnection:
jitter = random.uniform(0.1, 0.3) jitter = random.uniform(0.1, 0.3)
# Fastretry for transient socket failures # Fastretry for transient socket failures
fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) fast_error = isinstance(
e, (ConnectionRefusedError, ConnectionResetError, TimeoutError))
if not fast_error: if not fast_error:
try: try:
err_no = getattr(e, 'errno', None) 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: except Exception:
pass pass
@ -345,9 +370,11 @@ class UnityConnection:
continue continue
raise raise
# Global Unity connection # Global Unity connection
_unity_connection = None _unity_connection = None
def get_unity_connection() -> UnityConnection: def get_unity_connection() -> UnityConnection:
"""Retrieve or establish a persistent Unity connection. """Retrieve or establish a persistent Unity connection.
@ -366,7 +393,8 @@ def get_unity_connection() -> UnityConnection:
_unity_connection = UnityConnection() _unity_connection = UnityConnection()
if not _unity_connection.connect(): if not _unity_connection.connect():
_unity_connection = None _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") logger.info("Connected to Unity on startup")
return _unity_connection 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) response = conn.send_command(command_type, params)
retries = 0 retries = 0
while _is_reloading_response(response) and retries < max_retries: 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)) time.sleep(max(0.0, delay_ms / 1000.0))
retries += 1 retries += 1
response = conn.send_command(command_type, params) 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() loop = asyncio.get_running_loop()
return await loop.run_in_executor( return await loop.run_in_executor(
None, 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: except Exception as e:
# Return a structured error dict for consistency with other responses # Return a structured error dict for consistency with other responses

View File

@ -1,6 +1,6 @@
{ {
"name": "com.coplaydev.unity-mcp", "name": "com.coplaydev.unity-mcp",
"version": "3.4.0", "version": "4.1.0",
"displayName": "MCP for Unity", "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", "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", "unity": "2021.3",