Merge branch 'CoplayDev:main' into main
commit
bce6afaf24
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
||||||
|
|
@ -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。
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
[](https://discord.gg/y4p8KfzrN4)
|
||||||
|
[](https://unity.com/releases/editor/archive)
|
||||||
|
[](https://www.python.org)
|
||||||
|
[](https://modelcontextprotocol.io/introduction)
|
||||||
|

|
||||||
|

|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](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)
|
||||||
|
* **uv(Python 工具链管理器):**
|
||||||
|
```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>
|
||||||
|
|
||||||
|
对于捕获未定义命名空间、类型和方法的**严格**验证级别:
|
||||||
|
|
||||||
|
**方法 1:Unity 的 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历史
|
||||||
|
|
||||||
|
[](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>
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
[](https://discord.gg/y4p8KfzrN4)
|
[](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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d539787bf8f6a426e94bfffb32a36d4f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 013424dea29744a98b3dc01618f4e95e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8e7a7e542325421ba6de4992ddb3f5db
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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 { }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c11944bcfb9ec4576bab52874b7df584
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ea652131dcdaa44ca8cb35cd1191be3f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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("\"", "\\\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b3e68082ffc0b4cd39d3747673a4cc22
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f69ad468942b74c0ea24e3e8e5f21a4b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ namespace MCPForUnity.Editor.Models
|
||||||
{
|
{
|
||||||
ClaudeCode,
|
ClaudeCode,
|
||||||
ClaudeDesktop,
|
ClaudeDesktop,
|
||||||
|
Codex,
|
||||||
Cursor,
|
Cursor,
|
||||||
|
Kiro,
|
||||||
VSCode,
|
VSCode,
|
||||||
Windsurf,
|
Windsurf,
|
||||||
Kiro,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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++)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1bd48a1b7555c46bba168078ce0291cc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c14e76b2aa7bb4570a88903b061e946e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""
|
"""
|
||||||
MCP for Unity Server package.
|
MCP for Unity Server package.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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'"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.4.0
|
4.1.0
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}"}
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
# Fast‑retry for transient socket failures
|
# Fast‑retry 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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue