diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 87b3876..7ce2a99 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -70,6 +70,9 @@ jobs: echo "Updating UnityMcpBridge/UnityMcpServer~/src/pyproject.toml to $NEW_VERSION" sed -i '0,/^version = ".*"/s//version = "'"$NEW_VERSION"'"/' "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" + echo "Updating UnityMcpBridge/UnityMcpServer~/src/server_version.txt to $NEW_VERSION" + echo "$NEW_VERSION" > "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" + - name: Commit and push changes env: NEW_VERSION: ${{ steps.compute.outputs.new_version }} @@ -78,7 +81,7 @@ jobs: set -euo pipefail git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" + git add UnityMcpBridge/package.json "UnityMcpBridge/UnityMcpServer~/src/pyproject.toml" "UnityMcpBridge/UnityMcpServer~/src/server_version.txt" if git diff --cached --quiet; then echo "No version changes to commit." else diff --git a/.github/workflows/github-repo-stats.yml b/.github/workflows/github-repo-stats.yml new file mode 100644 index 0000000..fda0851 --- /dev/null +++ b/.github/workflows/github-repo-stats.yml @@ -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 }} + diff --git a/README-DEV-zh.md b/README-DEV-zh.md new file mode 100644 index 0000000..1513cf9 --- /dev/null +++ b/README-DEV-zh.md @@ -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@ +``` +示例(哈希): +``` +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)按标签拉取;作业在运行时解析摘要。日志已清理。 + - 执行:单次通过,立即按测试片段发射(严格的每个文件单个 ``)。如果任何片段是裸 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/_results.xml` 下为每个测试发射一个 XML 片段,每个包含恰好一个以测试 ID 开头的 `name` 的 ``。无序言/尾声或代码围栏。 +- 保持编辑最小且可逆;包含简洁证据。 + +### 运行套件 +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。 \ No newline at end of file diff --git a/README-DEV.md b/README-DEV.md index 76b5937..ddba601 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -1,5 +1,8 @@ # MCP for Unity Development Tools +| [English](README-DEV.md) | [简体中文](README-DEV-zh.md) | +|---------------------------|------------------------------| + Welcome to the MCP for Unity development environment! This directory contains tools and utilities to streamline MCP for Unity core development. ## 🚀 Available Development Features diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 0000000..c5f0860 --- /dev/null +++ b/README-zh.md @@ -0,0 +1,353 @@ +MCP for Unity + +| [English](README.md) | [简体中文](README-zh.md) | +|----------------------|---------------------------------| + +#### 由 [Coplay](https://www.coplay.dev/?ref=unity-mcp) 荣誉赞助和维护 -- Unity 最好的 AI 助手。[在此阅读背景故事。](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces) + +[![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) +[![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) +[![python](https://img.shields.io/badge/Python-3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/CoplayDev/unity-mcp) +[![](https://img.shields.io/badge/License-MIT-red.svg 'MIT License')](https://opensource.org/licenses/MIT) +[![](https://img.shields.io/badge/Sponsor-Coplay-red.svg 'Coplay')](https://www.coplay.dev/?ref=unity-mcp) + +**使用大语言模型创建您的 Unity 应用!** + +MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本地 **MCP(模型上下文协议)客户端** 直接与您的 Unity 编辑器交互。为您的大语言模型提供管理资源、控制场景、编辑脚本和自动化 Unity 任务的工具。 + +--- + +### 💬 加入我们的 [Discord](https://discord.gg/y4p8KfzrN4) + +**获得帮助、分享想法,与其他 MCP for Unity 开发者协作!** + +--- + +## 主要功能 🚀 + +* **🗣️ 自然语言操控:** 指示您的大语言模型执行 Unity 任务。 +* **🛠️ 强大工具:** 管理资源、场景、材质、脚本和编辑器功能。 +* **🤖 自动化:** 自动化重复的 Unity 工作流程。 +* **🧩 可扩展:** 设计为与各种 MCP 客户端协作。 + +
+ 可用工具 + + 您的大语言模型可以使用以下功能: + + * `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`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 +
+ +--- + +## 工作原理 + +MCP for Unity 使用两个组件连接您的工具: + +1. **MCP for Unity Bridge:** 在编辑器内运行的 Unity 包。(通过包管理器安装)。 +2. **MCP for Unity Server:** 本地运行的 Python 服务器,在 Unity Bridge 和您的 MCP 客户端之间进行通信。(首次运行时由包自动安装或通过自动设置;手动设置作为备选方案)。 + +image + +--- + +## 安装 ⚙️ + +### 前置要求 + +* **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) | 其他客户端可通过手动配置使用 + +*
[可选] Roslyn 用于高级脚本验证 + + 对于捕获未定义命名空间、类型和方法的**严格**验证级别: + + **方法 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# 编译器诊断和精确错误报告。
+ +--- +### 🌟 步骤 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 服务器或通过手动配置(如下)。 + +MCPForUnity-Readme-Image + +**选项 A:自动设置(推荐用于 Claude/Cursor/VSC Copilot)** + +1. 在 Unity 中,前往 `Window > MCP for Unity`。 +2. 点击 `Auto-Setup`。 +3. 寻找绿色状态指示器 🟢 和"Connected ✓"。*(这会尝试自动修改 MCP 客户端的配置文件)。* + +
客户端特定故障排除 + +- **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。
+ +**选项 B:手动配置** + +如果自动设置失败或您使用不同的客户端: + +1. **找到您的 MCP 客户端配置文件。**(查看客户端文档)。 + * *Claude 示例(macOS):* `~/Library/Application Support/Claude/claude_desktop_config.json` + * *Claude 示例(Windows):* `%APPDATA%\Claude\claude_desktop_config.json` +2. **编辑文件** 以添加/更新 `mcpServers` 部分,使用步骤 1 中的*精确*路径。 + +
+点击查看客户端特定的 JSON 配置片段... + +--- +**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","/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) + +
+ +--- + +## 使用方法 ▶️ + +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) 获取完整详情 + +您的隐私对我们很重要。所有遥测都是可选的,旨在尊重您的工作流程。 + +--- + +## 故障排除 ❓ + +
+点击查看常见问题和修复方法... + +- **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 客户端配置文件的权限。 + +
+ +仍然卡住?[开启问题](https://github.com/CoplayDev/unity-mcp/issues) 或 [加入 Discord](https://discord.gg/y4p8KfzrN4)! + +--- + +## 许可证 📜 + +MIT 许可证。查看 [LICENSE](LICENSE) 文件。 + +--- + +## Star历史 + +[![Star History Chart](https://api.star-history.com/svg?repos=CoplayDev/unity-mcp&type=Date)](https://www.star-history.com/#CoplayDev/unity-mcp&Date) + +## 赞助 + +

+ + Coplay Logo + +

\ No newline at end of file diff --git a/README.md b/README.md index f7beb95..f06217f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ MCP for Unity +| [English](README.md) | [简体中文](README-zh.md) | +|----------------------|---------------------------------| + #### Proudly sponsored and maintained by [Coplay](https://www.coplay.dev/?ref=unity-mcp) -- the best AI assistant for Unity. [Read the backstory here.](https://www.coplay.dev/blog/coplay-and-open-source-unity-mcp-join-forces) [![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) @@ -73,7 +76,7 @@ MCP for Unity connects your tools using two components: curl -LsSf https://astral.sh/uv/install.sh | sh # Windows (PowerShell) - winget install Astral.Sh.Uv + winget install --id=astral-sh.uv -e # Docs: https://docs.astral.sh/uv/getting-started/installation/ ``` @@ -87,7 +90,8 @@ MCP for Unity connects your tools using two components: **Method 1: NuGet for Unity (Recommended)** 1. Install [NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity) 2. Go to `Window > NuGet Package Manager` - 3. Search for `Microsoft.CodeAnalysis.CSharp` and install the package + 3. Search for `Microsoft.CodeAnalysis`, select version 4.14.0, and install the package + 4. Also install package `SQLitePCLRaw.core` and `SQLitePCLRaw.bundle_e_sqlite3`. 5. Go to `Player Settings > Scripting Define Symbols` 6. Add `USE_ROSLYN` 7. Restart Unity diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta new file mode 100644 index 0000000..d9e9b56 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d539787bf8f6a426e94bfffb32a36d4f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs new file mode 100644 index 0000000..3ff6afd --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs @@ -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); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta new file mode 100644 index 0000000..9e043e4 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 013424dea29744a98b3dc01618f4e95e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs index c12d1fd..2bbe461 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/CommandRegistryTests.cs @@ -8,18 +8,29 @@ namespace MCPForUnityTests.Editor.Tools public class CommandRegistryTests { [Test] - public void GetHandler_ReturnsNull_ForUnknownCommand() + public void GetHandler_ThrowException_ForUnknownCommand() { var unknown = "HandleDoesNotExist"; - var handler = CommandRegistry.GetHandler(unknown); - Assert.IsNull(handler, "Expected null handler for unknown command name."); + try + { + 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] public void GetHandler_ReturnsManageGameObjectHandler() { - var handler = CommandRegistry.GetHandler("HandleManageGameObject"); - Assert.IsNotNull(handler, "Expected a handler for HandleManageGameObject."); + var handler = CommandRegistry.GetHandler("manage_gameobject"); + Assert.IsNotNull(handler, "Expected a handler for manage_gameobject."); var methodInfo = handler.Method; Assert.AreEqual("HandleCommand", methodInfo.Name, "Handler method name should be HandleCommand."); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs new file mode 100644 index 0000000..4428845 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs @@ -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("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("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("isOpen")); + Assert.AreEqual(prefabPath, data.Value("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("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("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("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(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("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("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("prefabPath"); + Assert.AreEqual(prefabPath, savedPath, "Returned prefab path should match the requested path."); + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(savedPath); + Assert.IsNotNull(prefabAsset, "Prefab asset should exist at the saved path."); + + int instanceId = data.Value("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(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); + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta new file mode 100644 index 0000000..8ef3fdb --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e7a7e542325421ba6de4992ddb3f5db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs index c8f13b0..3fd7708 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs @@ -46,6 +46,8 @@ namespace MCPForUnityTests.Editor.Windows EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir); // Ensure no lock is enabled EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); + // Disable auto-registration to avoid hitting user configs during tests + EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false); } [TearDown] @@ -54,6 +56,7 @@ namespace MCPForUnityTests.Editor.Windows // Clean up editor preferences set during SetUp EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); + EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled"); // Remove temp files try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index 19e4128..9e71884 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -159,6 +159,28 @@ namespace MCPForUnity.Editor.Data mcpType = McpTypes.Kiro, configStatus = "Not Configured", }, + // 4) Codex CLI + new() + { + name = "Codex CLI", + windowsConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + macConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + linuxConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".codex", + "config.toml" + ), + mcpType = McpTypes.Codex, + configStatus = "Not Configured", + }, }; // Initialize status enums after construction @@ -174,4 +196,3 @@ namespace MCPForUnity.Editor.Data } } } - diff --git a/UnityMcpBridge/Editor/External.meta b/UnityMcpBridge/Editor/External.meta new file mode 100644 index 0000000..ce757b1 --- /dev/null +++ b/UnityMcpBridge/Editor/External.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c11944bcfb9ec4576bab52874b7df584 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs new file mode 100644 index 0000000..95399e4 --- /dev/null +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -0,0 +1,2138 @@ +#region LICENSE + +/* + * MIT License + * + * Copyright (c) 2020 Denis Zhidkikh + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace MCPForUnity.External.Tommy +{ + #region TOML Nodes + + public abstract class TomlNode : IEnumerable + { + public virtual bool HasValue { get; } = false; + public virtual bool IsArray { get; } = false; + public virtual bool IsTable { get; } = false; + public virtual bool IsString { get; } = false; + public virtual bool IsInteger { get; } = false; + public virtual bool IsFloat { get; } = false; + public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; + public virtual bool IsDateTimeLocal { get; } = false; + public virtual bool IsDateTimeOffset { get; } = false; + public virtual bool IsBoolean { get; } = false; + public virtual string Comment { get; set; } + public virtual int CollapseLevel { get; set; } + + public virtual TomlTable AsTable => this as TomlTable; + public virtual TomlString AsString => this as TomlString; + public virtual TomlInteger AsInteger => this as TomlInteger; + public virtual TomlFloat AsFloat => this as TomlFloat; + public virtual TomlBoolean AsBoolean => this as TomlBoolean; + public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; + public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; + public virtual TomlDateTime AsDateTime => this as TomlDateTime; + public virtual TomlArray AsArray => this as TomlArray; + + public virtual int ChildrenCount => 0; + + public virtual TomlNode this[string key] + { + get => null; + set { } + } + + public virtual TomlNode this[int index] + { + get => null; + set { } + } + + public virtual IEnumerable Children + { + get { yield break; } + } + + public virtual IEnumerable Keys + { + get { yield break; } + } + + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + + public virtual bool TryGetNode(string key, out TomlNode node) + { + node = null; + return false; + } + + public virtual bool HasKey(string key) => false; + + public virtual bool HasItemAt(int index) => false; + + public virtual void Add(string key, TomlNode node) { } + + public virtual void Add(TomlNode node) { } + + public virtual void Delete(TomlNode node) { } + + public virtual void Delete(string key) { } + + public virtual void Delete(int index) { } + + public virtual void AddRange(IEnumerable nodes) + { + foreach (var tomlNode in nodes) Add(tomlNode); + } + + public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); + + public virtual string ToInlineToml() => ToString(); + + #region Native type to TOML cast + + public static implicit operator TomlNode(string value) => new TomlString {Value = value}; + + public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value}; + + public static implicit operator TomlNode(long value) => new TomlInteger {Value = value}; + + public static implicit operator TomlNode(float value) => new TomlFloat {Value = value}; + + public static implicit operator TomlNode(double value) => new TomlFloat {Value = value}; + + public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value}; + + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value}; + + public static implicit operator TomlNode(TomlNode[] nodes) + { + var result = new TomlArray(); + result.AddRange(nodes); + return result; + } + + #endregion + + #region TOML to native type cast + + public static implicit operator string(TomlNode value) => value.ToString(); + + public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value; + + public static implicit operator long(TomlNode value) => value.AsInteger.Value; + + public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value; + + public static implicit operator double(TomlNode value) => value.AsFloat.Value; + + public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; + + public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; + + public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; + + #endregion + } + + public class TomlString : TomlNode + { + public override bool HasValue { get; } = true; + public override bool IsString { get; } = true; + public bool IsMultiline { get; set; } + public bool MultilineTrimFirstLine { get; set; } + public bool PreferLiteral { get; set; } + + public string Value { get; set; } + + public override string ToString() => Value; + + public override string ToInlineToml() + { + // Automatically convert literal to non-literal if there are too many literal string symbols + if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; + var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, + IsMultiline ? 3 : 1); + var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); + if (IsMultiline) + result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); + if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) + result = $"{Environment.NewLine}{result}"; + return $"{quotes}{result}{quotes}"; + } + } + + public class TomlInteger : TomlNode + { + public enum Base + { + Binary = 2, + Octal = 8, + Decimal = 10, + Hexadecimal = 16 + } + + public override bool IsInteger { get; } = true; + public override bool HasValue { get; } = true; + public Base IntegerBase { get; set; } = Base.Decimal; + + public long Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => + IntegerBase != Base.Decimal + ? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}" + : Value.ToString(CultureInfo.InvariantCulture); + } + + public class TomlFloat : TomlNode, IFormattable + { + public override bool IsFloat { get; } = true; + public override bool HasValue { get; } = true; + + public double Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); + + public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToInlineToml() => + Value switch + { + var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, + var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, + var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, + var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() + }; + } + + public class TomlBoolean : TomlNode + { + public override bool IsBoolean { get; } = true; + public override bool HasValue { get; } = true; + + public bool Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; + } + + public class TomlDateTime : TomlNode, IFormattable + { + public int SecondsPrecision { get; set; } + public override bool HasValue { get; } = true; + public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; + public virtual string ToString(IFormatProvider formatProvider) => string.Empty; + protected virtual string ToInlineTomlInternal() => string.Empty; + + public override string ToInlineToml() => ToInlineTomlInternal() + .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) + .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); + } + + public class TomlDateTimeOffset : TomlDateTime + { + public override bool IsDateTimeOffset { get; } = true; + public DateTimeOffset Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); + } + + public class TomlDateTimeLocal : TomlDateTime + { + public enum DateTimeStyle + { + Date, + Time, + DateTime + } + + public override bool IsDateTimeLocal { get; } = true; + public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; + public DateTime Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + public override string ToInlineToml() => + Style switch + { + DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), + DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + }; + } + + public class TomlArray : TomlNode + { + private List values; + + public override bool HasValue { get; } = true; + public override bool IsArray { get; } = true; + public bool IsMultiline { get; set; } + public bool IsTableArray { get; set; } + public List RawArray => values ??= new List(); + + public override TomlNode this[int index] + { + get + { + if (index < RawArray.Count) return RawArray[index]; + var lazy = new TomlLazy(this); + this[index] = lazy; + return lazy; + } + set + { + if (index == RawArray.Count) + RawArray.Add(value); + else + RawArray[index] = value; + } + } + + public override int ChildrenCount => RawArray.Count; + + public override IEnumerable Children => RawArray.AsEnumerable(); + + public override void Add(TomlNode node) => RawArray.Add(node); + + public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes); + + public override void Delete(TomlNode node) => RawArray.Remove(node); + + public override void Delete(int index) => RawArray.RemoveAt(index); + + public override string ToString() => ToString(false); + + public string ToString(bool multiline) + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.ARRAY_START_SYMBOL); + if (ChildrenCount != 0) + { + var arrayStart = multiline ? $"{Environment.NewLine} " : " "; + var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; + var arrayEnd = multiline ? Environment.NewLine : " "; + sb.Append(arrayStart) + .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) + .Append(arrayEnd); + } + sb.Append(TomlSyntax.ARRAY_END_SYMBOL); + return sb.ToString(); + } + + public override void WriteTo(TextWriter tw, string name = null) + { + // If it's a normal array, write it as usual + if (!IsTableArray) + { + tw.WriteLine(ToString(IsMultiline)); + return; + } + + if (!(Comment is null)) + { + tw.WriteLine(); + Comment.AsComment(tw); + } + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + + var first = true; + + foreach (var tomlNode in RawArray) + { + if (!(tomlNode is TomlTable tbl)) + throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); + + // Ensure it's parsed as a section + tbl.IsInline = false; + + if (!first) + { + tw.WriteLine(); + + Comment?.AsComment(tw); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + + first = false; + + // Don't write section since it's already written here + tbl.WriteTo(tw, name, false); + } + } + } + + public class TomlTable : TomlNode + { + private Dictionary children; + internal bool isImplicit; + + public override bool HasValue { get; } = false; + public override bool IsTable { get; } = true; + public bool IsInline { get; set; } + public Dictionary RawTable => children ??= new Dictionary(); + + public override TomlNode this[string key] + { + get + { + if (RawTable.TryGetValue(key, out var result)) return result; + var lazy = new TomlLazy(this); + RawTable[key] = lazy; + return lazy; + } + set => RawTable[key] = value; + } + + public override int ChildrenCount => RawTable.Count; + public override IEnumerable Children => RawTable.Select(kv => kv.Value); + public override IEnumerable Keys => RawTable.Select(kv => kv.Key); + public override bool HasKey(string key) => RawTable.ContainsKey(key); + public override void Add(string key, TomlNode node) => RawTable.Add(key, node); + public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); + public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); + public override void Delete(string key) => RawTable.Remove(key); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); + + if (ChildrenCount != 0) + { + var collapsed = CollectCollapsedItems(normalizeOrder: false); + + if (collapsed.Count != 0) + sb.Append(' ') + .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => + $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); + sb.Append(' '); + } + + sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); + return sb.ToString(); + } + + private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) + { + var nodes = new LinkedList>(); + var postNodes = normalizeOrder ? new LinkedList>() : nodes; + + foreach (var keyValuePair in RawTable) + { + var node = keyValuePair.Value; + var key = keyValuePair.Key.AsKey(); + + if (node is TomlTable tbl) + { + var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); + // Write main table first before writing collapsed items + if (subnodes.Count == 0 && node.CollapseLevel == level) + { + postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + foreach (var kv in subnodes) + postNodes.AddLast(kv); + } + else if (node.CollapseLevel == level) + nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + + if (normalizeOrder) + foreach (var kv in postNodes) + nodes.AddLast(kv); + + return nodes; + } + + public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); + + internal void WriteTo(TextWriter tw, string name, bool writeSectionName) + { + // The table is inline table + if (IsInline && name != null) + { + tw.WriteLine(ToInlineToml()); + return; + } + + var collapsedItems = CollectCollapsedItems(); + + if (collapsedItems.Count == 0) + return; + + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true}); + + Comment?.AsComment(tw); + + if (name != null && (hasRealValues || Comment != null) && writeSectionName) + { + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + else if (Comment != null) // Add some spacing between the first node and the comment + { + tw.WriteLine(); + } + + var namePrefix = name == null ? "" : $"{name}."; + var first = true; + + foreach (var collapsedItem in collapsedItems) + { + var key = collapsedItem.Key; + if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false}) + { + if (!first) tw.WriteLine(); + first = false; + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + continue; + } + first = false; + + collapsedItem.Value.Comment?.AsComment(tw); + tw.Write(key); + tw.Write(' '); + tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); + tw.Write(' '); + + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + } + } + } + + internal class TomlLazy : TomlNode + { + private readonly TomlNode parent; + private TomlNode replacement; + + public TomlLazy(TomlNode parent) => this.parent = parent; + + public override TomlNode this[int index] + { + get => Set()[index]; + set => Set()[index] = value; + } + + public override TomlNode this[string key] + { + get => Set()[key]; + set => Set()[key] = value; + } + + public override void Add(TomlNode node) => Set().Add(node); + + public override void Add(string key, TomlNode node) => Set().Add(key, node); + + public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes); + + private TomlNode Set() where T : TomlNode, new() + { + if (replacement != null) return replacement; + + var newNode = new T + { + Comment = Comment + }; + + if (parent.IsTable) + { + var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); + if (key == null) return default(T); + + parent[key] = newNode; + } + else if (parent.IsArray) + { + var index = parent.Children.TakeWhile(child => child != this).Count(); + if (index == parent.ChildrenCount) return default(T); + parent[index] = newNode; + } + else + { + return default(T); + } + + replacement = newNode; + return newNode; + } + } + + #endregion + + #region Parser + + public class TOMLParser : IDisposable + { + public enum ParseState + { + None, + KeyValuePair, + SkipToNextLine, + Table + } + + private readonly TextReader reader; + private ParseState currentState; + private int line, col; + private List syntaxErrors; + + public TOMLParser(TextReader reader) + { + this.reader = reader; + line = col = 0; + } + + public bool ForceASCII { get; set; } + + public void Dispose() => reader?.Dispose(); + + public TomlTable Parse() + { + syntaxErrors = new List(); + line = col = 1; + var rootNode = new TomlTable(); + var currentNode = rootNode; + currentState = ParseState.None; + var keyParts = new List(); + var arrayTable = false; + StringBuilder latestComment = null; + var firstComment = true; + + int currentChar; + while ((currentChar = reader.Peek()) >= 0) + { + var c = (char) currentChar; + + if (currentState == ParseState.None) + { + // Skip white space + if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; + + if (TomlSyntax.IsNewLine(c)) + { + // Check if there are any comments and so far no items being declared + if (latestComment != null && firstComment) + { + rootNode.Comment = latestComment.ToString().TrimEnd(); + latestComment = null; + firstComment = false; + } + + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + + goto consume_character; + } + + // Start of a comment; ignore until newline + if (c == TomlSyntax.COMMENT_SYMBOL) + { + latestComment ??= new StringBuilder(); + latestComment.AppendLine(ParseComment()); + AdvanceLine(1); + continue; + } + + // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! + firstComment = false; + + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + currentState = ParseState.Table; + goto consume_character; + } + + if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) + { + currentState = ParseState.KeyValuePair; + } + else + { + AddError($"Unexpected character \"{c}\""); + continue; + } + } + + if (currentState == ParseState.KeyValuePair) + { + var keyValuePair = ReadKeyValuePair(keyParts); + + if (keyValuePair == null) + { + latestComment = null; + keyParts.Clear(); + + if (currentState != ParseState.None) + AddError("Failed to parse key-value pair!"); + continue; + } + + keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); + var inserted = InsertNode(keyValuePair, currentNode, keyParts); + latestComment = null; + keyParts.Clear(); + if (inserted) + currentState = ParseState.SkipToNextLine; + continue; + } + + if (currentState == ParseState.Table) + { + if (keyParts.Count == 0) + { + // We have array table + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + // Consume the character + ConsumeChar(); + arrayTable = true; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) + { + keyParts.Clear(); + continue; + } + + if (keyParts.Count == 0) + { + AddError("Table name is emtpy."); + arrayTable = false; + latestComment = null; + keyParts.Clear(); + } + + continue; + } + + if (c == TomlSyntax.TABLE_END_SYMBOL) + { + if (arrayTable) + { + // Consume the ending bracket so we can peek the next character + ConsumeChar(); + var nextChar = reader.Peek(); + if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL) + { + AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + continue; + } + } + + currentNode = CreateTable(rootNode, keyParts, arrayTable); + if (currentNode != null) + { + currentNode.IsInline = false; + currentNode.Comment = latestComment?.ToString()?.TrimEnd(); + } + + keyParts.Clear(); + arrayTable = false; + latestComment = null; + + if (currentNode == null) + { + if (currentState != ParseState.None) + AddError("Error creating table array!"); + // Reset a node to root in order to try and continue parsing + currentNode = rootNode; + continue; + } + + currentState = ParseState.SkipToNextLine; + goto consume_character; + } + + if (keyParts.Count != 0) + { + AddError($"Unexpected character \"{c}\""); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + } + } + + if (currentState == ParseState.SkipToNextLine) + { + if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) + goto consume_character; + + if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) + { + currentState = ParseState.None; + AdvanceLine(); + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + col++; + ParseComment(); + continue; + } + + goto consume_character; + } + + AddError($"Unexpected character \"{c}\" at the end of the line."); + } + + consume_character: + reader.Read(); + col++; + } + + if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) + AddError("Unexpected end of file!"); + + if (syntaxErrors.Count > 0) + throw new TomlParseException(rootNode, syntaxErrors); + + return rootNode; + } + + private bool AddError(string message, bool skipLine = true) + { + syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); + // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) + if (skipLine) + { + reader.ReadLine(); + AdvanceLine(1); + } + currentState = ParseState.None; + return false; + } + + private void AdvanceLine(int startCol = 0) + { + line++; + col = startCol; + } + + private int ConsumeChar() + { + col++; + return reader.Read(); + } + + #region Key-Value pair parsing + + /** + * Reads a single key-value pair. + * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). + * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). + * + * Example: + * foo = "bar" ==> foo = "bar" + * ^ ^ + */ + private TomlNode ReadKeyValuePair(List keyParts) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) + { + if (keyParts.Count != 0) + { + AddError("Encountered extra characters in key definition!"); + return null; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) + return null; + + continue; + } + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.KEY_VALUE_SEPARATOR) + { + ConsumeChar(); + return ReadValue(); + } + + AddError($"Unexpected character \"{c}\" in key name."); + return null; + } + + return null; + } + + /** + * Reads a single value. + * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). + * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private TomlNode ReadValue(bool skipNewlines = false) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("No value found!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + if (skipNewlines) + { + reader.Read(); + AdvanceLine(1); + continue; + } + + AddError("Encountered a newline when expecting a value!"); + return null; + } + + if (TomlSyntax.IsQuoted(c)) + { + var isMultiline = IsTripleQuote(c, out var excess); + + // Error occurred in triple quote parsing + if (currentState == ParseState.None) + return null; + + var value = isMultiline + ? ReadQuotedValueMultiLine(c) + : ReadQuotedValueSingleLine(c, excess); + + if (value is null) + return null; + + return new TomlString + { + Value = value, + IsMultiline = isMultiline, + PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL + }; + } + + return c switch + { + TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() + }; + } + + return null; + } + + /** + * Reads a single key name. + * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). + * Consumes all the characters until the `until` character is met (but does not consume the character itself). + * + * Example 1: + * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) + * ^ ^ + * + * Example 2: + * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) + * ^ ^ + */ + private bool ReadKeyName(ref List parts, char until) + { + var buffer = new StringBuilder(); + var quoted = false; + var prevWasSpace = false; + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + // Reached the final character + if (c == until) break; + + if (TomlSyntax.IsWhiteSpace(c)) + { + prevWasSpace = true; + goto consume_character; + } + + if (buffer.Length == 0) prevWasSpace = false; + + if (c == TomlSyntax.SUBKEY_SEPARATOR) + { + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + buffer.Length = 0; + quoted = false; + prevWasSpace = false; + goto consume_character; + } + + if (prevWasSpace) + return AddError("Invalid spacing in key name"); + + if (TomlSyntax.IsQuoted(c)) + { + if (quoted) + + return AddError("Expected a subkey separator but got extra data instead!"); + + if (buffer.Length != 0) + return AddError("Encountered a quote in the middle of subkey name!"); + + // Consume the quote character and read the key name + col++; + buffer.Append(ReadQuotedValueSingleLine((char) reader.Read())); + quoted = true; + continue; + } + + if (TomlSyntax.IsBareKey(c)) + { + buffer.Append(c); + goto consume_character; + } + + // If we see an invalid symbol, let the next parser handle it + break; + + consume_character: + reader.Read(); + col++; + } + + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + + return true; + } + + #endregion + + #region Non-string value parsing + + /** + * Reads the whole raw value until the first non-value character is encountered. + * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. + * Example: + * + * 1_0_0_0 ==> 1_0_0_0 + * ^ ^ + */ + private string ReadRawValue() + { + var result = new StringBuilder(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; + result.Append(c); + ConsumeChar(); + } + + // Replace trim with manual space counting? + return result.ToString().Trim(); + } + + /** + * Reads and parses a non-string, non-composite TOML value. + * Assumes the cursor at the first character that is related to the value (with possible spaces). + * Consumes all the characters that are related to the value. + * + * Example + * 1_0_0_0 # This is a comment + * + * ==> 1_0_0_0 # This is a comment + * ^ ^ + */ + private TomlNode ReadTomlValue() + { + var value = ReadRawValue(); + TomlNode node = value switch + { + var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), + var v when TomlSyntax.IsNaN(v) => double.NaN, + var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, + var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, + var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger + { + Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), + IntegerBase = (TomlInteger.Base) numberBase + }, + var _ => null + }; + if (node != null) return node; + + // Normalize by removing space separator + value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalDateTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out var dateTimeResult, + out var precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + SecondsPrecision = precision + }; + + if (DateTime.TryParseExact(value, + TomlSyntax.LocalDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dateTimeResult)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Date + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out dateTimeResult, + out precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Time, + SecondsPrecision = precision + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339Formats, + DateTimeStyles.None, + DateTimeOffset.TryParseExact, + out var dateTimeOffsetResult, + out precision)) + return new TomlDateTimeOffset + { + Value = dateTimeOffsetResult, + SecondsPrecision = precision + }; + + AddError($"Value \"{value}\" is not a valid TOML value!"); + return null; + } + + /** + * Reads an array value. + * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. + * + * Example: + * [1, 2, 3] ==> [1, 2, 3] + * ^ ^ + */ + private TomlArray ReadArray() + { + // Consume the start of array character + ConsumeChar(); + var result = new TomlArray(); + TomlNode currentValue = null; + var expectValue = true; + + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (c == TomlSyntax.ARRAY_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + reader.ReadLine(); + AdvanceLine(1); + continue; + } + + if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + goto consume_character; + } + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators"); + return null; + } + + result.Add(currentValue); + currentValue = null; + expectValue = true; + goto consume_character; + } + + if (!expectValue) + { + AddError("Missing separator between values"); + return null; + } + currentValue = ReadValue(true); + if (currentValue == null) + { + if (currentState != ParseState.None) + AddError("Failed to determine and parse a value!"); + return null; + } + expectValue = false; + + continue; + consume_character: + ConsumeChar(); + } + + if (currentValue != null) result.Add(currentValue); + return result; + } + + /** + * Reads an inline table. + * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. + * + * Example: + * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } + * ^ ^ + */ + private TomlNode ReadInlineTable() + { + ConsumeChar(); + var result = new TomlTable {IsInline = true}; + TomlNode currentValue = null; + var separator = false; + var keyParts = new List(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("Incomplete inline table definition!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + AddError("Inline tables are only allowed to be on single line"); + return null; + } + + if (TomlSyntax.IsWhiteSpace(c)) + goto consume_character; + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators in inline table!"); + return null; + } + + if (!InsertNode(currentValue, result, keyParts)) + return null; + keyParts.Clear(); + currentValue = null; + separator = true; + goto consume_character; + } + + separator = false; + currentValue = ReadKeyValuePair(keyParts); + continue; + + consume_character: + ConsumeChar(); + } + + if (separator) + { + AddError("Trailing commas are not allowed in inline tables."); + return null; + } + + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) + return null; + + return result; + } + + #endregion + + #region String parsing + + /** + * Checks if the string value a multiline string (i.e. a triple quoted string). + * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. + * + * If the result is false, returns the consumed character through the `excess` variable. + * + * Example 1: + * """test""" ==> """test""" + * ^ ^ + * + * Example 2: + * "test" ==> "test" (doesn't return the first quote) + * ^ ^ + * + * Example 3: + * "" ==> "" (returns the extra `"` through the `excess` variable) + * ^ ^ + */ + private bool IsTripleQuote(char quote, out char excess) + { + // Copypasta, but it's faster... + + int cur; + // Consume the first quote + ConsumeChar(); + if ((cur = reader.Peek()) < 0) + { + excess = '\0'; + return AddError("Unexpected end of file!"); + } + + if ((char) cur != quote) + { + excess = '\0'; + return false; + } + + // Consume the second quote + excess = (char) ConsumeChar(); + if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false; + + // Consume the final quote + ConsumeChar(); + excess = '\0'; + return true; + } + + /** + * A convenience method to process a single character within a quote. + */ + private bool ProcessQuotedValueCharacter(char quote, + bool isNonLiteral, + char c, + StringBuilder sb, + ref bool escaped) + { + if (TomlSyntax.MustBeEscaped(c)) + return AddError($"The character U+{(int) c:X8} must be escaped in a string!"); + + if (escaped) + { + sb.Append(c); + escaped = false; + return false; + } + + if (c == quote) + { + if (!isNonLiteral && reader.Peek() == quote) + { + reader.Read(); + col++; + sb.Append(quote); + return false; + } + + return true; + } + if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) + escaped = true; + if (c == TomlSyntax.NEWLINE_CHARACTER) + return AddError("Encountered newline in single line string!"); + + sb.Append(c); + return false; + } + + /** + * Reads a single-line string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string (including the closing quote). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') + { + var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + + if (initialData != '\0') + { + var shouldReturn = + ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); + if (currentState == ParseState.None) return null; + if (shouldReturn) + if (isNonLiteral) + { + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + else + return sb.ToString(); + } + + int cur; + var readDone = false; + while ((cur = reader.Read()) >= 0) + { + // Consume the character + col++; + var c = (char) cur; + readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); + if (readDone) + { + if (currentState == ParseState.None) return null; + break; + } + } + + if (!readDone) + { + AddError("Unclosed string."); + return null; + } + + if (!isNonLiteral) return sb.ToString(); + if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; + AddError(unescapedEx.Message); + return null; + } + + /** + * Reads a multiline string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string and the three closing quotes. + * + * Example: + * """test""" ==> """test""" + * ^ ^ + */ + private string ReadQuotedValueMultiLine(char quote) + { + var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + var skipWhitespace = false; + var skipWhitespaceLineSkipped = false; + var quotesEncountered = 0; + var first = true; + int cur; + while ((cur = ConsumeChar()) >= 0) + { + var c = (char) cur; + if (TomlSyntax.MustBeEscaped(c, true)) + { + AddError($"The character U+{(int) c:X8} must be escaped!"); + return null; + } + // Trim the first newline + if (first && TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + first = false; + else + AdvanceLine(); + continue; + } + + first = false; + //TODO: Reuse ProcessQuotedValueCharacter + // Skip the current character if it is going to be escaped later + if (escaped) + { + sb.Append(c); + escaped = false; + continue; + } + + // If we are currently skipping empty spaces, skip + if (skipWhitespace) + { + if (TomlSyntax.IsEmptySpace(c)) + { + if (TomlSyntax.IsLineBreak(c)) + { + skipWhitespaceLineSkipped = true; + AdvanceLine(); + } + continue; + } + + if (!skipWhitespaceLineSkipped) + { + AddError("Non-whitespace character after trim marker."); + return null; + } + + skipWhitespaceLineSkipped = false; + skipWhitespace = false; + } + + // If we encounter an escape sequence... + if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) + { + var next = reader.Peek(); + var nc = (char) next; + if (next >= 0) + { + // ...and the next char is empty space, we must skip all whitespaces + if (TomlSyntax.IsEmptySpace(nc)) + { + skipWhitespace = true; + continue; + } + + // ...and we have \" or \, skip the character + if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; + } + } + + // Count the consecutive quotes + if (c == quote) + quotesEncountered++; + else + quotesEncountered = 0; + + // If the are three quotes, count them as closing quotes + if (quotesEncountered == 3) break; + + sb.Append(c); + } + + // TOML actually allows to have five ending quotes like + // """"" => "" belong to the string + """ is the actual ending + quotesEncountered = 0; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + if (c == quote && ++quotesEncountered < 3) + { + sb.Append(c); + ConsumeChar(); + } + else break; + } + + // Remove last two quotes (third one wasn't included by default) + sb.Length -= 2; + if (!isBasic) return sb.ToString(); + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + + #endregion + + #region Node creation + + private bool InsertNode(TomlNode node, TomlNode root, IList path) + { + var latestNode = root; + if (path.Count > 1) + for (var index = 0; index < path.Count - 1; index++) + { + var subkey = path[index]; + if (latestNode.TryGetNode(subkey, out var currentNode)) + { + if (currentNode.HasValue) + return AddError($"The key {".".Join(path)} already has a value assigned to it!"); + } + else + { + currentNode = new TomlTable(); + latestNode[subkey] = currentNode; + } + + latestNode = currentNode; + if (latestNode is TomlTable { IsInline: true }) + return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); + } + + if (latestNode.HasKey(path[path.Count - 1])) + return AddError($"The key {".".Join(path)} is already defined!"); + latestNode[path[path.Count - 1]] = node; + node.CollapseLevel = path.Count - 1; + return true; + } + + private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable) + { + if (path.Count == 0) return null; + var latestNode = root; + for (var index = 0; index < path.Count; index++) + { + var subkey = path[index]; + + if (latestNode.TryGetNode(subkey, out var node)) + { + if (node.IsArray && arrayTable) + { + var arr = (TomlArray) node; + + if (!arr.IsTableArray) + { + AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (index == path.Count - 1) + { + latestNode = new TomlTable(); + arr.Add(latestNode); + break; + } + + latestNode = arr[arr.ChildrenCount - 1]; + continue; + } + + if (node is TomlTable { IsInline: true }) + { + AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); + return null; + } + + if (node.HasValue) + { + if (!(node is TomlArray { IsTableArray: true } array)) + { + AddError($"The key {".".Join(path)} has a value assigned to it!"); + return null; + } + + latestNode = array[array.ChildrenCount - 1]; + continue; + } + + if (index == path.Count - 1) + { + if (arrayTable && !node.IsArray) + { + AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (node is TomlTable { isImplicit: false }) + { + AddError($"The table {".".Join(path)} is defined multiple times!"); + return null; + } + } + } + else + { + if (index == path.Count - 1 && arrayTable) + { + var table = new TomlTable(); + var arr = new TomlArray + { + IsTableArray = true + }; + arr.Add(table); + latestNode[subkey] = arr; + latestNode = table; + break; + } + + node = new TomlTable { isImplicit = true }; + latestNode[subkey] = node; + } + + latestNode = node; + } + + var result = (TomlTable) latestNode; + result.isImplicit = false; + return result; + } + + #endregion + + #region Misc parsing + + private string ParseComment() + { + ConsumeChar(); + var commentLine = reader.ReadLine()?.Trim() ?? ""; + if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) + AddError("Comment must not contain control characters other than tab.", false); + return commentLine; + } + #endregion + } + + #endregion + + public static class TOML + { + public static bool ForceASCII { get; set; } = false; + + public static TomlTable Parse(TextReader reader) + { + using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII}; + return parser.Parse(); + } + } + + #region Exception Types + + public class TomlFormatException : Exception + { + public TomlFormatException(string message) : base(message) { } + } + + public class TomlParseException : Exception + { + public TomlParseException(TomlTable parsed, IEnumerable exceptions) : + base("TOML file contains format errors") + { + ParsedTable = parsed; + SyntaxErrors = exceptions; + } + + public TomlTable ParsedTable { get; } + + public IEnumerable SyntaxErrors { get; } + } + + public class TomlSyntaxException : Exception + { + public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) + { + ParseState = state; + Line = line; + Column = col; + } + + public TOMLParser.ParseState ParseState { get; } + + public int Line { get; } + + public int Column { get; } + } + + #endregion + + #region Parse utilities + + internal static class TomlSyntax + { + #region Type Patterns + + public const string TRUE_VALUE = "true"; + public const string FALSE_VALUE = "false"; + public const string NAN_VALUE = "nan"; + public const string POS_NAN_VALUE = "+nan"; + public const string NEG_NAN_VALUE = "-nan"; + public const string INF_VALUE = "inf"; + public const string POS_INF_VALUE = "+inf"; + public const string NEG_INF_VALUE = "-inf"; + + public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; + + public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; + + public static bool IsNegInf(string s) => s == NEG_INF_VALUE; + + public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; + + public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); + + public static bool IsFloat(string s) => FloatPattern.IsMatch(s); + + public static bool IsIntegerWithBase(string s, out int numberBase) + { + numberBase = 10; + var match = BasedIntegerPattern.Match(s); + if (!match.Success) return false; + IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); + return true; + } + + /** + * A pattern to verify the integer value according to the TOML specification. + */ + public static readonly Regex IntegerPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); + + /** + * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. + */ + public static readonly Regex BasedIntegerPattern = + new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A pattern to verify the float value according to the TOML specification. + */ + public static readonly Regex FloatPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A helper dictionary to map TOML base codes into the radii. + */ + public static readonly Dictionary IntegerBases = new() + { + ["x"] = 16, + ["o"] = 8, + ["b"] = 2 + }; + + /** + * A helper dictionary to map non-decimal bases to their TOML identifiers + */ + public static readonly Dictionary BaseIdentifiers = new() + { + [2] = "b", + [8] = "o", + [16] = "x" + }; + + public const string RFC3339EmptySeparator = " "; + public const string ISO861Separator = "T"; + public const string ISO861ZeroZone = "+00:00"; + public const string RFC3339ZeroZone = "Z"; + + /** + * Valid date formats with timezone as per RFC3339. + */ + public static readonly string[] RFC3339Formats = + { + "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" + }; + + /** + * Valid date formats without timezone (assumes local) as per RFC3339. + */ + public static readonly string[] RFC3339LocalDateTimeFormats = + { + "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" + }; + + /** + * Valid full date format as per TOML spec. + */ + public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; + + /** + * Valid time formats as per TOML spec. + */ + public static readonly string[] RFC3339LocalTimeFormats = + { + "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", + "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" + }; + + #endregion + + #region Character definitions + + public const char ARRAY_END_SYMBOL = ']'; + public const char ITEM_SEPARATOR = ','; + public const char ARRAY_START_SYMBOL = '['; + public const char BASIC_STRING_SYMBOL = '\"'; + public const char COMMENT_SYMBOL = '#'; + public const char ESCAPE_SYMBOL = '\\'; + public const char KEY_VALUE_SEPARATOR = '='; + public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; + public const char NEWLINE_CHARACTER = '\n'; + public const char SUBKEY_SEPARATOR = '.'; + public const char TABLE_END_SYMBOL = ']'; + public const char TABLE_START_SYMBOL = '['; + public const char INLINE_TABLE_START_SYMBOL = '{'; + public const char INLINE_TABLE_END_SYMBOL = '}'; + public const char LITERAL_STRING_SYMBOL = '\''; + public const char INT_NUMBER_SEPARATOR = '_'; + + public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER}; + + public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; + + public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; + + public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; + + public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; + + public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); + + public static bool IsBareKey(char c) => + c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; + + public static bool MustBeEscaped(char c, bool allowNewLines = false) + { + var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; + if (!allowNewLines) + result |= c is >= '\u000a' and <= '\u000e'; + return result; + } + + public static bool IsValueSeparator(char c) => + c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; + + #endregion + } + + internal static class StringUtils + { + public static string AsKey(this string key) + { + var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); + return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; + } + + public static string Join(this string self, IEnumerable subItems) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var subItem in subItems) + { + if (!first) sb.Append(self); + first = false; + sb.Append(subItem); + } + + return sb.ToString(); + } + + public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); + + public static bool TryParseDateTime(string s, + string[] formats, + DateTimeStyles styles, + TryDateParseDelegate parser, + out T dateTime, + out int parsedFormat) + { + parsedFormat = 0; + dateTime = default; + for (var i = 0; i < formats.Length; i++) + { + var format = formats[i]; + if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; + parsedFormat = i; + return true; + } + + return false; + } + + public static void AsComment(this string self, TextWriter tw) + { + foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) + tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); + } + + public static string RemoveAll(this string txt, char toRemove) + { + var sb = new StringBuilder(txt.Length); + foreach (var c in txt.Where(c => c != toRemove)) + sb.Append(c); + return sb.ToString(); + } + + public static string Escape(this string txt, bool escapeNewlines = true) + { + var stringBuilder = new StringBuilder(txt.Length + 2); + for (var i = 0; i < txt.Length; i++) + { + var c = txt[i]; + + static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) + ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" + : $"\\u{(ushort) c:X4}"; + + stringBuilder.Append(c switch + { + '\b' => @"\b", + '\t' => @"\t", + '\n' when escapeNewlines => @"\n", + '\f' => @"\f", + '\r' when escapeNewlines => @"\r", + '\\' => @"\\", + '\"' => @"\""", + var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => + CodePoint(txt, ref i, c), + var _ => c + }); + } + + return stringBuilder.ToString(); + } + + public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) + { + try + { + exception = null; + unescaped = txt.Unescape(); + return true; + } + catch (Exception e) + { + exception = e; + unescaped = null; + return false; + } + } + + public static string Unescape(this string txt) + { + if (string.IsNullOrEmpty(txt)) return txt; + var stringBuilder = new StringBuilder(txt.Length); + for (var i = 0; i < txt.Length;) + { + var num = txt.IndexOf('\\', i); + var next = num + 1; + if (num < 0 || num == txt.Length - 1) num = txt.Length; + stringBuilder.Append(txt, i, num - i); + if (num >= txt.Length) break; + var c = txt[next]; + + static string CodePoint(int next, string txt, ref int num, int size) + { + if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); + num += size; + return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); + } + + stringBuilder.Append(c switch + { + 'b' => "\b", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + '\'' => "\'", + '\"' => "\"", + '\\' => "\\", + 'u' => CodePoint(next, txt, ref num, 4), + 'U' => CodePoint(next, txt, ref num, 8), + var _ => throw new Exception("Undefined escape sequence!") + }); + i = num + 2; + } + + return stringBuilder.ToString(); + } + } + + #endregion +} diff --git a/UnityMcpBridge/Editor/External/Tommy.cs.meta b/UnityMcpBridge/Editor/External/Tommy.cs.meta new file mode 100644 index 0000000..efcb8ff --- /dev/null +++ b/UnityMcpBridge/Editor/External/Tommy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea652131dcdaa44ca8cb35cd1191be3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs new file mode 100644 index 0000000..f03b66c --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs @@ -0,0 +1,29 @@ +using System; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Provides common utility methods for working with Unity asset paths. + /// + public static class AssetPathUtility + { + /// + /// Normalizes a Unity asset path by ensuring forward slashes are used and that it is rooted under "Assets/". + /// + 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; + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta new file mode 100644 index 0000000..bd6a0c7 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/AssetPathUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d42f5b5ea5d4d43ad1a771e14bda2a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs new file mode 100644 index 0000000..fceab47 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -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 +{ + /// + /// Codex CLI specific configuration helpers. Handles TOML snippet + /// generation and lightweight parsing so Codex can join the auto-setup + /// flow alongside JSON-based clients. + /// + 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().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 values = new List(); + 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(); + } + + if (node is TomlString single) + { + return new[] { single.Value }; + } + + return null; + } + + private static string FormatTomlStringArray(IEnumerable 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("\"", "\\\""); + } + + } +} diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta new file mode 100644 index 0000000..581a447 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3e68082ffc0b4cd39d3747673a4cc22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs new file mode 100644 index 0000000..9b2e5b8 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// 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. + /// + 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; + } + } + + /// + /// Resolves the server directory to use for MCP tools, preferring + /// existing config values and falling back to installed/embedded copies. + /// + 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(); + } + } + } +} + diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta new file mode 100644 index 0000000..8f81ae9 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f69ad468942b74c0ea24e3e8e5f21a4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/MCPForUnityBridge.cs b/UnityMcpBridge/Editor/MCPForUnityBridge.cs index 88b30de..326f921 100644 --- a/UnityMcpBridge/Editor/MCPForUnityBridge.cs +++ b/UnityMcpBridge/Editor/MCPForUnityBridge.cs @@ -15,6 +15,7 @@ using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Tools.MenuItems; +using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor { @@ -63,7 +64,7 @@ namespace MCPForUnity.Editor { try { return EditorPrefs.GetBool("MCPForUnity.DebugLogs", false); } catch { return false; } } - + private static void LogBreadcrumb(string stage) { if (IsDebugEnabled()) @@ -82,7 +83,7 @@ namespace MCPForUnity.Editor public static void StartAutoConnect() { Stop(); // Stop current connection - + try { // 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 retrySleepMs = 75; int attempt = 0; - for (;;) + for (; ; ) { try { @@ -755,7 +756,7 @@ namespace MCPForUnity.Editor { byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); ulong payloadLen = ReadUInt64BigEndian(header); - if (payloadLen > MaxFrameBytes) + if (payloadLen > MaxFrameBytes) { 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 JObject paramsObject = command.@params ?? new JObject(); - - // Route command based on the new tool structure from the refactor plan - object result = command.type switch - { - // Maps the command type (tool name) to the corresponding handler's static HandleCommand method - // Assumes each handler class has a static method named 'HandleCommand' that takes JObject parameters - "manage_script" => ManageScript.HandleCommand(paramsObject), - // Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag) - "manage_scene" => HandleManageScene(paramsObject) - ?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"), - "manage_editor" => ManageEditor.HandleCommand(paramsObject), - "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), - "manage_asset" => ManageAsset.HandleCommand(paramsObject), - "manage_shader" => ManageShader.HandleCommand(paramsObject), - "read_console" => ReadConsole.HandleCommand(paramsObject), - "manage_menu_item" => ManageMenuItem.HandleCommand(paramsObject), - _ => throw new ArgumentException( - $"Unknown or unsupported command type: {command.type}" - ), - }; + object result = CommandRegistry.GetHandler(command.type)(paramsObject); // Standard success response format var response = new { status = "success", result }; diff --git a/UnityMcpBridge/Editor/Models/McpTypes.cs b/UnityMcpBridge/Editor/Models/McpTypes.cs index 32a30e2..a5a03de 100644 --- a/UnityMcpBridge/Editor/Models/McpTypes.cs +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs @@ -4,10 +4,10 @@ namespace MCPForUnity.Editor.Models { ClaudeCode, ClaudeDesktop, + Codex, Cursor, + Kiro, VSCode, Windsurf, - Kiro, } } - diff --git a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs index 912ddf5..afc1444 100644 --- a/UnityMcpBridge/Editor/Tools/CommandRegistry.cs +++ b/UnityMcpBridge/Editor/Tools/CommandRegistry.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools.MenuItems; +using MCPForUnity.Editor.Tools.Prefabs; namespace MCPForUnity.Editor.Tools { @@ -14,14 +15,15 @@ namespace MCPForUnity.Editor.Tools // to the corresponding static HandleCommand method in the appropriate tool class. private static readonly Dictionary> _handlers = new() { - { "HandleManageScript", ManageScript.HandleCommand }, - { "HandleManageScene", ManageScene.HandleCommand }, - { "HandleManageEditor", ManageEditor.HandleCommand }, - { "HandleManageGameObject", ManageGameObject.HandleCommand }, - { "HandleManageAsset", ManageAsset.HandleCommand }, - { "HandleReadConsole", ReadConsole.HandleCommand }, - { "HandleManageMenuItem", ManageMenuItem.HandleCommand }, - { "HandleManageShader", ManageShader.HandleCommand}, + { "manage_script", ManageScript.HandleCommand }, + { "manage_scene", ManageScene.HandleCommand }, + { "manage_editor", ManageEditor.HandleCommand }, + { "manage_gameobject", ManageGameObject.HandleCommand }, + { "manage_asset", ManageAsset.HandleCommand }, + { "read_console", ReadConsole.HandleCommand }, + { "manage_menu_item", ManageMenuItem.HandleCommand }, + { "manage_shader", ManageShader.HandleCommand}, + { "manage_prefabs", ManagePrefabs.HandleCommand}, }; /// @@ -31,17 +33,18 @@ namespace MCPForUnity.Editor.Tools /// The command handler function if found, null otherwise. public static Func GetHandler(string commandName) { - // Use case-insensitive comparison for flexibility, although Python side should be consistent - return _handlers.TryGetValue(commandName, out var handler) ? handler : null; - // Consider adding logging here if a handler is not found - /* - if (_handlers.TryGetValue(commandName, out var handler)) { - return handler; - } else { - UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\"); - return null; + if (!_handlers.TryGetValue(commandName, out var handler)) + { + throw new InvalidOperationException( + $"Unknown or unsupported command type: {commandName}"); } - */ + + return handler; + } + + public static void Add(string commandName, Func handler) + { + _handlers.Add(commandName, handler); } } } diff --git a/UnityMcpBridge/Editor/Tools/ManageAsset.cs b/UnityMcpBridge/Editor/Tools/ManageAsset.cs index 70e3ff6..52a5bca 100644 --- a/UnityMcpBridge/Editor/Tools/ManageAsset.cs +++ b/UnityMcpBridge/Editor/Tools/ManageAsset.cs @@ -115,7 +115,7 @@ namespace MCPForUnity.Editor.Tools { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for reimport."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -154,7 +154,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(assetType)) return Response.Error("'assetType' is required for create."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); string directory = Path.GetDirectoryName(fullPath); // Ensure directory exists @@ -280,7 +280,7 @@ namespace MCPForUnity.Editor.Tools { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for create_folder."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); string parentDir = Path.GetDirectoryName(fullPath); string folderName = Path.GetFileName(fullPath); @@ -338,7 +338,7 @@ namespace MCPForUnity.Editor.Tools if (properties == null || !properties.HasValues) return Response.Error("'properties' are required for modify."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -372,7 +372,7 @@ namespace MCPForUnity.Editor.Tools { targetComponent = gameObject.GetComponent(compType); } - + // Only warn about resolution failure if component also not found if (targetComponent == null && !resolved) { @@ -495,7 +495,7 @@ namespace MCPForUnity.Editor.Tools { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for delete."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -526,7 +526,7 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for duplicate."); - string sourcePath = SanitizeAssetPath(path); + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); @@ -538,7 +538,7 @@ namespace MCPForUnity.Editor.Tools } else { - destPath = SanitizeAssetPath(destinationPath); + destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (AssetExists(destPath)) return Response.Error($"Asset already exists at destination path: {destPath}"); // Ensure destination directory exists @@ -576,8 +576,8 @@ namespace MCPForUnity.Editor.Tools if (string.IsNullOrEmpty(destinationPath)) return Response.Error("'destination' path is required for move/rename."); - string sourcePath = SanitizeAssetPath(path); - string destPath = SanitizeAssetPath(destinationPath); + string sourcePath = AssetPathUtility.SanitizeAssetPath(path); + string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath); if (!AssetExists(sourcePath)) return Response.Error($"Source asset not found at path: {sourcePath}"); @@ -642,7 +642,7 @@ namespace MCPForUnity.Editor.Tools string[] folderScope = null; if (!string.IsNullOrEmpty(pathScope)) { - folderScope = new string[] { SanitizeAssetPath(pathScope) }; + folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) }; if (!AssetDatabase.IsValidFolder(folderScope[0])) { // Maybe the user provided a file path instead of a folder? @@ -732,7 +732,7 @@ namespace MCPForUnity.Editor.Tools { if (string.IsNullOrEmpty(path)) return Response.Error("'path' is required for get_info."); - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -761,7 +761,7 @@ namespace MCPForUnity.Editor.Tools return Response.Error("'path' is required for get_components."); // 2. Sanitize and check existence - string fullPath = SanitizeAssetPath(path); + string fullPath = AssetPathUtility.SanitizeAssetPath(path); if (!AssetExists(fullPath)) return Response.Error($"Asset not found at path: {fullPath}"); @@ -829,18 +829,6 @@ namespace MCPForUnity.Editor.Tools /// /// Ensures the asset path starts with "Assets/". /// - 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; - } - /// /// Checks if an asset exists at the given path (file or folder). /// @@ -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"; - try { + string propName = "_Color"; + try + { if (colorArr.Count >= 3) { Color newColor = new Color( colorArr[0].ToObject(), - colorArr[1].ToObject(), - colorArr[2].ToObject(), + colorArr[1].ToObject(), + colorArr[2].ToObject(), colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f ); if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) @@ -948,8 +938,9 @@ namespace MCPForUnity.Editor.Tools modified = true; } } - } - catch (Exception ex) { + } + catch (Exception ex) + { Debug.LogWarning( $"Error parsing color property '{propName}': {ex.Message}" ); @@ -989,7 +980,7 @@ namespace MCPForUnity.Editor.Tools if (!string.IsNullOrEmpty(texPath)) { Texture newTex = AssetDatabase.LoadAssetAtPath( - SanitizeAssetPath(texPath) + AssetPathUtility.SanitizeAssetPath(texPath) ); if ( newTex != null @@ -1217,7 +1208,7 @@ namespace MCPForUnity.Editor.Tools && token.Type == JTokenType.String ) { - string assetPath = SanitizeAssetPath(token.ToString()); + string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString()); UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( assetPath, targetType @@ -1337,4 +1328,3 @@ namespace MCPForUnity.Editor.Tools } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageEditor.cs b/UnityMcpBridge/Editor/Tools/ManageEditor.cs index 7ed6300..f26502d 100644 --- a/UnityMcpBridge/Editor/Tools/ManageEditor.cs +++ b/UnityMcpBridge/Editor/Tools/ManageEditor.cs @@ -5,8 +5,9 @@ using System.IO; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management +using UnityEditor.SceneManagement; using UnityEngine; -using MCPForUnity.Editor.Helpers; // For Response class +using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools { @@ -98,6 +99,8 @@ namespace MCPForUnity.Editor.Tools return GetActiveTool(); case "get_selection": return GetSelection(); + case "get_prefab_stage": + return GetPrefabStageInfo(); case "set_active_tool": string toolName = @params["toolName"]?.ToString(); if (string.IsNullOrEmpty(toolName)) @@ -140,7 +143,7 @@ namespace MCPForUnity.Editor.Tools default: return Response.Error( - $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." + $"Unknown action: '{action}'. Supported actions include play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, get_prefab_stage, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers." ); } } @@ -244,6 +247,35 @@ namespace MCPForUnity.Editor.Tools } } + private static object GetPrefabStageInfo() + { + try + { + PrefabStage stage = PrefabStageUtility.GetCurrentPrefabStage(); + if (stage == null) + { + return Response.Success + ("No prefab stage is currently open.", new { isOpen = false }); + } + + return Response.Success( + "Prefab stage info retrieved.", + new + { + isOpen = true, + assetPath = stage.assetPath, + prefabRootName = stage.prefabContentsRoot != null ? stage.prefabContentsRoot.name : null, + mode = stage.mode.ToString(), + isDirty = stage.scene.isDirty + } + ); + } + catch (Exception e) + { + return Response.Error($"Error getting prefab stage info: {e.Message}"); + } + } + private static object GetActiveTool() { try @@ -610,4 +642,3 @@ namespace MCPForUnity.Editor.Tools } } } - diff --git a/UnityMcpBridge/Editor/Tools/ManageScript.cs b/UnityMcpBridge/Editor/Tools/ManageScript.cs index 0ed65af..0231c85 100644 --- a/UnityMcpBridge/Editor/Tools/ManageScript.cs +++ b/UnityMcpBridge/Editor/Tools/ManageScript.cs @@ -90,7 +90,7 @@ namespace MCPForUnity.Editor.Tools return false; } var atAssets = string.Equals( - di.FullName.Replace('\\','/'), + di.FullName.Replace('\\', '/'), assets, StringComparison.OrdinalIgnoreCase ); @@ -115,7 +115,7 @@ namespace MCPForUnity.Editor.Tools { return Response.Error("invalid_params", "Parameters cannot be null."); } - + // Extract parameters string action = @params["action"]?.ToString()?.ToLower(); string name = @params["name"]?.ToString(); @@ -207,81 +207,81 @@ namespace MCPForUnity.Editor.Tools case "delete": return DeleteScript(fullPath, relativePath); case "apply_text_edits": - { - var textEdits = @params["edits"] as JArray; - string precondition = @params["precondition_sha256"]?.ToString(); - // Respect optional options - string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); - string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); - return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); - } + { + var textEdits = @params["edits"] as JArray; + string precondition = @params["precondition_sha256"]?.ToString(); + // Respect optional options + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); + } case "validate": - { - string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; - var chosen = level switch { - "basic" => ValidationLevel.Basic, - "standard" => ValidationLevel.Standard, - "strict" => ValidationLevel.Strict, - "comprehensive" => ValidationLevel.Comprehensive, - _ => ValidationLevel.Standard - }; - string fileText; - try { fileText = File.ReadAllText(fullPath); } - catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => ValidationLevel.Standard + }; + 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); - var diags = (diagsRaw ?? Array.Empty()).Select(s => - { - var m = Regex.Match( - s, - @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", - RegexOptions.CultureInvariant | RegexOptions.Multiline, - TimeSpan.FromMilliseconds(250) - ); - string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; - string message = m.Success ? m.Groups[2].Value : s; - int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; - return new { line = lineNum, col = 0, severity, message }; - }).ToArray(); + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + var m = Regex.Match( + s, + @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", + RegexOptions.CultureInvariant | RegexOptions.Multiline, + TimeSpan.FromMilliseconds(250) + ); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); - var result = new { diagnostics = diags }; - return ok ? Response.Success("Validation completed.", result) - : Response.Error("Validation failed.", result); - } + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); + } case "edit": Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); var structEdits = @params["edits"] as JArray; var options = @params["options"] as JObject; return EditScript(fullPath, relativePath, name, structEdits, options); case "get_sha": - { - try { - if (!File.Exists(fullPath)) - 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 + try { - 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); + if (!File.Exists(fullPath)) + 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}", + 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: return Response.Error( $"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 { 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) return Response.Error("Refusing to edit a symlinked script path."); @@ -640,7 +640,7 @@ namespace MCPForUnity.Editor.Tools }; structEdits.Add(op); // 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(); 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 } }; 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 == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } - if (c == '{') brace++; else if (c == '}') brace--; - else if (c == '(') paren++; else if (c == ')') paren--; + if (c == '{') brace++; + else if (c == '}') brace--; + else if (c == '(') paren++; + else if (c == ')') paren--; else if (c == '[') bracket++; else if (c == ']') bracket--; // Allow temporary negative balance - will check tolerance at end } @@ -1035,291 +1037,291 @@ namespace MCPForUnity.Editor.Tools switch (mode) { case "replace_class": - { - string className = op.Value("className"); - string ns = op.Value("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++; + string className = op.Value("className"); + string ns = op.Value("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": - { - string className = op.Value("className"); - string ns = op.Value("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++; + string className = op.Value("className"); + string ns = op.Value("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": - { - string className = op.Value("className"); - string ns = op.Value("namespace"); - string methodName = op.Value("methodName"); - string replacement = ExtractReplacement(op); - string returnType = op.Value("returnType"); - string parametersSignature = op.Value("parametersSignature"); - string attributesContains = op.Value("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.Equals(jo.Value("className"), className, StringComparison.Ordinal) && - string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && - ((jo.Value("mode") ?? jo.Value("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}"); - } + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); - if (applySequentially) - { - working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); - appliedCount++; + 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.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("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": - { - string className = op.Value("className"); - string ns = op.Value("namespace"); - string methodName = op.Value("methodName"); - string returnType = op.Value("returnType"); - string parametersSignature = op.Value("parametersSignature"); - string attributesContains = op.Value("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.Equals(jo.Value("className"), className, StringComparison.Ordinal) && - string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && - ((jo.Value("mode") ?? jo.Value("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}"); - } + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); - if (applySequentially) - { - working = working.Remove(mStart, mLen); - appliedCount++; + 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.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("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": - { - string className = op.Value("className"); - string ns = op.Value("namespace"); - string position = (op.Value("position") ?? "end").ToLowerInvariant(); - string afterMethodName = op.Value("afterMethodName"); - string afterReturnType = op.Value("afterReturnType"); - string afterParameters = op.Value("afterParametersSignature"); - string afterAttributesContains = op.Value("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'."); - 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) - { - working = working.Insert(insAt, text); - appliedCount++; - } - else - { - 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; - } + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("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."); - case "anchor_insert": - { - string anchor = op.Value("anchor"); - string position = (op.Value("position") ?? "before").ToLowerInvariant(); - string text = op.Value("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'."); + 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."); - 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"; - } + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); - // Duplicate guard: if identical snippet already exists within this class, skip insert - if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + if (position == "after") { - string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); - if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + 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 - break; + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); } } - if (applySequentially) - { - working = working.Insert(insAt, norm); - appliedCount++; - } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); 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("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("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": - { - string anchor = op.Value("anchor"); - if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); - try { - var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); - 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) + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try { - working = working.Remove(delAt, delLen); - appliedCount++; + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + 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": - { - string anchor = op.Value("anchor"); - string replacement = op.Value("text") ?? op.Value("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)); - 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) + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + try { - working = working.Remove(at, len).Insert(at, norm); - appliedCount++; + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + 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: 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 : ...' - for (;;) + for (; ; ) { // Skip whitespace/comments before checking for 'where' for (; i < searchEnd; i++) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs index 8cca35a..0f213c6 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/ManageMenuItem.cs @@ -1,15 +1,9 @@ using System; using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems { - /// - /// Facade handler for managing Unity Editor menu items. - /// Routes actions to read or execute implementations. - /// public static class ManageMenuItem { /// diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs index fe6180f..193a80f 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemExecutor.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using Newtonsoft.Json.Linq; using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems @@ -37,23 +36,12 @@ namespace MCPForUnity.Editor.Tools.MenuItems try { - // Execute on main thread using delayCall - EditorApplication.delayCall += () => + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (!executed) { - try - { - bool executed = EditorApplication.ExecuteMenuItem(menuPath); - if (!executed) - { - McpLog.Error($"[MenuItemExecutor] Failed to execute menu item via delayCall: '{menuPath}'. It might be invalid, disabled, or context-dependent."); - } - } - catch (Exception delayEx) - { - McpLog.Error($"[MenuItemExecutor] Exception during delayed execution of '{menuPath}': {delayEx}"); - } - }; - + 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."); + } return Response.Success($"Attempted to execute menu item: '{menuPath}'. Check Unity logs for confirmation or errors."); } catch (Exception e) diff --git a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs index db91feb..60c9412 100644 --- a/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs +++ b/UnityMcpBridge/Editor/Tools/MenuItems/MenuItemsReader.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; using UnityEditor; -using UnityEngine; using MCPForUnity.Editor.Helpers; namespace MCPForUnity.Editor.Tools.MenuItems diff --git a/UnityMcpBridge/Editor/Tools/Prefabs.meta b/UnityMcpBridge/Editor/Tools/Prefabs.meta new file mode 100644 index 0000000..4fb95c5 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1bd48a1b7555c46bba168078ce0291cc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs new file mode 100644 index 0000000..aaf67b1 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -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(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() ?? 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() ?? 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() ?? false; + string finalPath = sanitizedPath; + + if (!allowOverwrite && AssetDatabase.LoadAssetAtPath(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(includeInactive)) + { + if (transform.name == name) + { + return transform.gameObject; + } + } + } + + Scene activeScene = SceneManager.GetActiveScene(); + foreach (GameObject root in activeScene.GetRootGameObjects()) + { + foreach (Transform transform in root.GetComponentsInChildren(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 + }; + } + + } +} diff --git a/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta new file mode 100644 index 0000000..27182e7 --- /dev/null +++ b/UnityMcpBridge/Editor/Tools/Prefabs/ManagePrefabs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c14e76b2aa7bb4570a88903b061e946e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 84113f7..cdaa6c1 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -568,8 +568,9 @@ namespace MCPForUnity.Editor.Windows } else { - // For Cursor/others, skip if already configured - if (!IsCursorConfigured(pythonDir)) + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; + if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; @@ -581,7 +582,10 @@ namespace MCPForUnity.Editor.Windows MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port @@ -658,7 +662,9 @@ namespace MCPForUnity.Editor.Windows } else { - if (!IsCursorConfigured(pythonDir)) + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; + if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; @@ -670,7 +676,10 @@ namespace MCPForUnity.Editor.Windows UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); @@ -686,11 +695,11 @@ namespace MCPForUnity.Editor.Windows } } - private static bool IsCursorConfigured(string pythonDir) - { - try - { - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + private static bool IsCursorConfigured(string pythonDir) + { + try + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -708,24 +717,9 @@ namespace MCPForUnity.Editor.Windows string[] strArgs = ((System.Collections.Generic.IEnumerable)args) .Select(x => x?.ToString() ?? string.Empty) .ToArray(); - string dir = ExtractDirectoryArg(strArgs); + string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); if (string.IsNullOrEmpty(dir)) return false; - return PathsEqual(dir, pythonDir); - } - catch { return false; } - } - - private static bool PathsEqual(string a, string b) - { - if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; - try - { - string na = System.IO.Path.GetFullPath(a.Trim()); - string nb = System.IO.Path.GetFullPath(b.Trim()); - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) - return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); - // Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed - return string.Equals(na, nb, StringComparison.Ordinal); + return McpConfigFileHelper.PathsEqual(dir, pythonDir); } catch { return false; } } @@ -1136,19 +1130,6 @@ namespace MCPForUnity.Editor.Windows catch { return false; } } - private static string ExtractDirectoryArg(string[] args) - { - if (args == null) return null; - for (int i = 0; i < args.Length - 1; i++) - { - if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase)) - { - return args[i + 1]; - } - } - return null; - } - private static bool ArgsEqual(string[] a, string[] b) { if (a == null || b == null) return a == b; @@ -1236,48 +1217,7 @@ namespace MCPForUnity.Editor.Windows } catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ExtractDirectoryArg(existingArgs); - bool serverValid = !string.IsNullOrEmpty(serverSrc) - && System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py")); - if (!serverValid) - { - // Prefer the provided pythonDir if valid; fall back to resolver - if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py"))) - { - serverSrc = pythonDir; - } - else - { - serverSrc = ResolveServerSrc(); - } - } - - // macOS normalization: map XDG-style ~/.local/share to canonical Application Support - try - { - if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX) - && !string.IsNullOrEmpty(serverSrc)) - { - string norm = serverSrc.Replace('\\', '/'); - int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal); - if (idx >= 0) - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty; - string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/... - serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix); - } - } - } - catch { } - - // Hard-block PackageCache on Windows unless dev override is set - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - && !string.IsNullOrEmpty(serverSrc) - && serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0 - && !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false)) - { - serverSrc = ServerInstaller.GetServerPath(); - } + string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); // 2) Canonical args order var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; @@ -1301,60 +1241,7 @@ namespace MCPForUnity.Editor.Windows string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - // Robust atomic write without redundant backup or race on existence - string tmp = configPath + ".tmp"; - string backup = configPath + ".backup"; - bool writeDone = false; - try - { - // Write to temp file first (in same directory for atomicity) - System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false)); - - try - { - // Try atomic replace; creates 'backup' only on success (platform-dependent) - System.IO.File.Replace(tmp, configPath, backup); - writeDone = true; - } - catch (System.IO.FileNotFoundException) - { - // Destination didn't exist; fall back to move - System.IO.File.Move(tmp, configPath); - writeDone = true; - } - catch (System.PlatformNotSupportedException) - { - // Fallback: rename existing to backup, then move tmp into place - if (System.IO.File.Exists(configPath)) - { - try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } - System.IO.File.Move(configPath, backup); - } - System.IO.File.Move(tmp, configPath); - writeDone = true; - } - } - catch (Exception ex) - { - - // If write did not complete, attempt restore from backup without deleting current file first - try - { - if (!writeDone && System.IO.File.Exists(backup)) - { - try { System.IO.File.Copy(backup, configPath, true); } catch { } - } - } - catch { } - throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex); - } - finally - { - // Best-effort cleanup of temp - try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { } - // Only remove backup after a confirmed successful write - try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { } - } + McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); try { @@ -1377,54 +1264,27 @@ namespace MCPForUnity.Editor.Windows } // New method to show manual instructions without changing status - 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() + private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) { - 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); - if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) - { - return remembered; - } - - ServerInstaller.EnsureServerInstalled(); - string installed = ServerInstaller.GetServerPath(); - if (File.Exists(Path.Combine(installed, "server.py"))) - { - return installed; - } - - bool useEmbedded = UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false); - if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded) - && File.Exists(Path.Combine(embedded, "server.py"))) - { - return embedded; - } - - return installed; + UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration."); + return; } - 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 { @@ -1508,12 +1368,12 @@ namespace MCPForUnity.Editor.Windows } } - private string ConfigureMcpClient(McpClient mcpClient) - { - try - { - // Determine the config file path based on OS - string configPath; + private string ConfigureMcpClient(McpClient mcpClient) + { + try + { + // Determine the config file path based on OS + string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -1541,21 +1401,23 @@ namespace MCPForUnity.Editor.Windows // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - // Find the server.py file location using the same logic as FindPackagePythonDirectory - string pythonDir = FindPackagePythonDirectory(); + // Find the server.py file location using the same logic as FindPackagePythonDirectory + string pythonDir = FindPackagePythonDirectory(); - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - ShowManualInstructionsWindow(configPath, mcpClient); - return "Manual Configuration Required"; - } + if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + ShowManualInstructionsWindow(configPath, mcpClient); + return "Manual Configuration Required"; + } - string result = WriteToConfig(pythonDir, configPath, mcpClient); + string result = mcpClient.mcpType == McpTypes.Codex + ? ConfigureCodexClient(pythonDir, configPath, mcpClient) + : WriteToConfig(pythonDir, configPath, mcpClient); - // Update the client status after successful configuration - if (result == "Configured successfully") - { - mcpClient.SetStatus(McpStatus.Configured); + // Update the client status after successful configuration + if (result == "Configured successfully") + { + mcpClient.SetStatus(McpStatus.Configured); } return result; @@ -1588,8 +1450,82 @@ namespace MCPForUnity.Editor.Windows $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); 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( string configPath, @@ -1721,28 +1657,36 @@ namespace MCPForUnity.Editor.Windows string[] args = null; bool configExists = false; - switch (mcpClient.mcpType) - { - case McpTypes.VSCode: - dynamic config = JsonConvert.DeserializeObject(configJson); - - // New schema: top-level servers - if (config?.servers?.unityMCP != null) - { - args = config.servers.unityMCP.args.ToObject(); - configExists = true; - } - // Back-compat: legacy mcp.servers - else if (config?.mcp?.servers?.unityMCP != null) - { - args = config.mcp.servers.unityMCP.args.ToObject(); - configExists = true; - } - break; - - default: - // Standard MCP configuration check for Claude Desktop, Cursor, etc. - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); + switch (mcpClient.mcpType) + { + case McpTypes.VSCode: + dynamic config = JsonConvert.DeserializeObject(configJson); + + // New schema: top-level servers + if (config?.servers?.unityMCP != null) + { + args = config.servers.unityMCP.args.ToObject(); + configExists = true; + } + // Back-compat: legacy mcp.servers + else if (config?.mcp?.servers?.unityMCP != null) + { + args = config.mcp.servers.unityMCP.args.ToObject(); + configExists = true; + } + break; + + case McpTypes.Codex: + if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) + { + args = codexArgs; + configExists = true; + } + break; + + default: + // Standard MCP configuration check for Claude Desktop, Cursor, etc. + McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { @@ -1755,8 +1699,8 @@ namespace MCPForUnity.Editor.Windows // Common logic for checking configuration status if (configExists) { - string configuredDir = ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); + string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); + bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); @@ -1766,7 +1710,9 @@ namespace MCPForUnity.Editor.Windows // Attempt auto-rewrite once if the package path changed try { - string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); + string rewriteResult = mcpClient.mcpType == McpTypes.Codex + ? ConfigureCodexClient(pythonDir, configPath, mcpClient) + : WriteToConfig(pythonDir, configPath, mcpClient); if (rewriteResult == "Configured successfully") { if (debugLogsEnabled) diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index 501e37a..ecccbef 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -101,6 +101,13 @@ namespace MCPForUnity.Editor.Windows instructionStyle ); } + else if (mcpClient?.mcpType == McpTypes.Codex) + { + EditorGUILayout.LabelField( + " a) Running `codex config edit` in a terminal", + instructionStyle + ); + } EditorGUILayout.LabelField(" OR", instructionStyle); EditorGUILayout.LabelField( " b) Opening the configuration file at:", @@ -201,10 +208,10 @@ namespace MCPForUnity.Editor.Windows EditorGUILayout.Space(10); - EditorGUILayout.LabelField( - "2. Paste the following JSON configuration:", - instructionStyle - ); + string configLabel = mcpClient?.mcpType == McpTypes.Codex + ? "2. Paste the following TOML configuration:" + : "2. Paste the following JSON configuration:"; + EditorGUILayout.LabelField(configLabel, instructionStyle); // JSON section with improved styling EditorGUILayout.BeginVertical(EditorStyles.helpBox); diff --git a/UnityMcpBridge/UnityMcpServer~/src/Dockerfile b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile index 3f884f3..5fcbc4e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/Dockerfile +++ b/UnityMcpBridge/UnityMcpServer~/src/Dockerfile @@ -24,4 +24,4 @@ RUN uv pip install --system -e . # Command to run the server -CMD ["uv", "run", "server.py"] \ No newline at end of file +CMD ["uv", "run", "server.py"] diff --git a/UnityMcpBridge/UnityMcpServer~/src/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/__init__.py index bf3404d..ad59ec7 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/__init__.py @@ -1,3 +1,3 @@ """ MCP for Unity Server package. -""" \ No newline at end of file +""" diff --git a/UnityMcpBridge/UnityMcpServer~/src/config.py b/UnityMcpBridge/UnityMcpServer~/src/config.py index f934ece..526522d 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/config.py +++ b/UnityMcpBridge/UnityMcpServer~/src/config.py @@ -5,26 +5,30 @@ This file contains all configurable parameters for the server. from dataclasses import dataclass + @dataclass class ServerConfig: """Main configuration class for the MCP server.""" - + # Network settings unity_host: str = "localhost" unity_port: int = 6400 mcp_port: int = 6500 - + # Connection settings - connection_timeout: float = 1.0 # short initial timeout; retries use shorter timeouts + # short initial timeout; retries use shorter timeouts + connection_timeout: float = 1.0 buffer_size: int = 16 * 1024 * 1024 # 16MB buffer # Framed receive behavior - framed_receive_timeout: float = 2.0 # max seconds to wait while consuming heartbeats only - max_heartbeat_frames: int = 16 # cap heartbeat frames consumed before giving up - + # max seconds to wait while consuming heartbeats only + framed_receive_timeout: float = 2.0 + # cap heartbeat frames consumed before giving up + max_heartbeat_frames: int = 16 + # Logging settings log_level: str = "INFO" log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - + # Server settings max_retries: int = 10 retry_delay: float = 0.25 @@ -33,11 +37,12 @@ class ServerConfig: # Number of polite retries when Unity reports reloading # 40 × 250ms ≈ 10s default window reload_max_retries: int = 40 - + # Telemetry settings telemetry_enabled: bool = True # Align with telemetry.py default Cloud Run endpoint - telemetry_endpoint: str = "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events" + telemetry_endpoint: str = "https://api-prod.coplay.dev/telemetry/events" + # Create a global config instance -config = ServerConfig() \ No newline at end of file +config = ServerConfig() diff --git a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py index 6f15415..b936f96 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py +++ b/UnityMcpBridge/UnityMcpServer~/src/port_discovery.py @@ -11,31 +11,31 @@ What changed and why: (quick socket connect + ping) before choosing it. """ +import glob import json -import os import logging from pathlib import Path -from typing import Optional, List -import glob import socket +from typing import Optional, List logger = logging.getLogger("mcp-for-unity-server") + class PortDiscovery: """Handles port discovery from Unity Bridge registry""" REGISTRY_FILE = "unity-mcp-port.json" # legacy single-project file DEFAULT_PORT = 6400 CONNECT_TIMEOUT = 0.3 # seconds, keep this snappy during discovery - + @staticmethod def get_registry_path() -> Path: """Get the path to the port registry file""" return Path.home() / ".unity-mcp" / PortDiscovery.REGISTRY_FILE - + @staticmethod def get_registry_dir() -> Path: return Path.home() / ".unity-mcp" - + @staticmethod def list_candidate_files() -> List[Path]: """Return candidate registry files, newest first. @@ -52,7 +52,7 @@ class PortDiscovery: # Put legacy at the end so hashed, per-project files win hashed.append(legacy) return hashed - + @staticmethod def _try_probe_unity_mcp(port: int) -> bool: """Quickly check if a MCP for Unity listener is on this port. @@ -78,7 +78,8 @@ class PortDiscovery: try: base = PortDiscovery.get_registry_dir() status_files = sorted( - (Path(p) for p in glob.glob(str(base / "unity-mcp-status-*.json"))), + (Path(p) + for p in glob.glob(str(base / "unity-mcp-status-*.json"))), key=lambda p: p.stat().st_mtime, reverse=True, ) @@ -88,14 +89,14 @@ class PortDiscovery: return json.load(f) except Exception: return None - + @staticmethod def discover_unity_port() -> int: """ Discover Unity port by scanning per-project and legacy registry files. Prefer the newest file whose port responds; fall back to first parsed value; finally default to 6400. - + Returns: Port number to connect to """ @@ -120,26 +121,29 @@ class PortDiscovery: if first_seen_port is None: first_seen_port = unity_port if PortDiscovery._try_probe_unity_mcp(unity_port): - logger.info(f"Using Unity port from {path.name}: {unity_port}") + logger.info( + f"Using Unity port from {path.name}: {unity_port}") return unity_port except Exception as e: logger.warning(f"Could not read port registry {path}: {e}") if first_seen_port is not None: - logger.info(f"No responsive port found; using first seen value {first_seen_port}") + logger.info( + f"No responsive port found; using first seen value {first_seen_port}") return first_seen_port # Fallback to default port - logger.info(f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") + logger.info( + f"No port registry found; using default port {PortDiscovery.DEFAULT_PORT}") return PortDiscovery.DEFAULT_PORT - + @staticmethod def get_port_config() -> Optional[dict]: """ Get the most relevant port configuration from registry. Returns the most recent hashed file's config if present, otherwise the legacy file's config. Returns None if nothing exists. - + Returns: Port configuration dict or None if not found """ @@ -151,5 +155,6 @@ class PortDiscovery: with open(path, 'r') as f: return json.load(f) except Exception as e: - logger.warning(f"Could not read port configuration {path}: {e}") - return None \ No newline at end of file + logger.warning( + f"Could not read port configuration {path}: {e}") + return None diff --git a/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml index 067f320..fb78365 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml +++ b/UnityMcpBridge/UnityMcpServer~/src/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "MCPForUnityServer" -version = "3.4.0" +version = "4.1.0" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.10" -dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.4.1"] +dependencies = ["httpx>=0.27.2", "mcp[cli]>=1.15.0"] [build-system] requires = ["setuptools>=64.0.0", "wheel"] diff --git a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py index e224844..71e5f62 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py +++ b/UnityMcpBridge/UnityMcpServer~/src/reload_sentinel.py @@ -4,5 +4,6 @@ Deprecated: Sentinel flipping is handled inside Unity via the MCP menu All functions are no-ops to prevent accidental external writes. """ + def flip_reload_sentinel(*args, **kwargs) -> str: return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'" diff --git a/UnityMcpBridge/UnityMcpServer~/src/server.py b/UnityMcpBridge/UnityMcpServer~/src/server.py index db64e12..a2765cc 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server.py +++ b/UnityMcpBridge/UnityMcpServer~/src/server.py @@ -1,10 +1,9 @@ -from mcp.server.fastmcp import FastMCP, Context, Image +from mcp.server.fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler import os -from dataclasses import dataclass from contextlib import asynccontextmanager -from typing import AsyncIterator, Dict, Any, List +from typing import AsyncIterator, Dict, Any from config import config from tools import register_all_tools from unity_connection import get_unity_connection, UnityConnection @@ -150,8 +149,7 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: # Initialize MCP server mcp = FastMCP( - "mcp-for-unity-server", - description="Unity Editor integration via Model Context Protocol", + name="mcp-for-unity-server", lifespan=server_lifespan ) diff --git a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt index 1809198..ee74734 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/server_version.txt +++ b/UnityMcpBridge/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -3.4.0 +4.1.0 diff --git a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py index c679854..7efc469 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/telemetry.py +++ b/UnityMcpBridge/UnityMcpServer~/src/telemetry.py @@ -1,29 +1,28 @@ """ Privacy-focused, anonymous telemetry system for Unity MCP Inspired by Onyx's telemetry implementation with Unity-specific adaptations -""" -import uuid -import threading -""" Fire-and-forget telemetry sender with a single background worker. - No context/thread-local propagation to avoid re-entrancy into tool resolution. - Small network timeouts to prevent stalls. """ -import json -import time -import os -import sys -import platform -import logging -from enum import Enum -from urllib.parse import urlparse -from dataclasses import dataclass, asdict -from typing import Optional, Dict, Any, List -from pathlib import Path -import importlib -import queue + import contextlib +from dataclasses import dataclass +from enum import Enum +import importlib +import json +import logging +import os +from pathlib import Path +import platform +import queue +import sys +import threading +import time +from typing import Optional, Dict, Any +from urllib.parse import urlparse +import uuid try: import httpx @@ -34,10 +33,11 @@ except ImportError: logger = logging.getLogger("unity-mcp-telemetry") + class RecordType(str, Enum): """Types of telemetry records we collect""" VERSION = "version" - STARTUP = "startup" + STARTUP = "startup" USAGE = "usage" LATENCY = "latency" FAILURE = "failure" @@ -45,6 +45,7 @@ class RecordType(str, Enum): UNITY_CONNECTION = "unity_connection" CLIENT_CONNECTION = "client_connection" + class MilestoneType(str, Enum): """Major user journey milestones""" FIRST_STARTUP = "first_startup" @@ -55,6 +56,7 @@ class MilestoneType(str, Enum): DAILY_ACTIVE_USER = "daily_active_user" WEEKLY_ACTIVE_USER = "weekly_active_user" + @dataclass class TelemetryRecord: """Structure for telemetry data""" @@ -65,8 +67,10 @@ class TelemetryRecord: data: Dict[str, Any] milestone: Optional[MilestoneType] = None + class TelemetryConfig: """Telemetry configuration""" + def __init__(self): # Prefer config file, then allow env overrides server_config = None @@ -85,12 +89,14 @@ class TelemetryConfig: continue # Determine enabled flag: config -> env DISABLE_* opt-out - cfg_enabled = True if server_config is None else bool(getattr(server_config, "telemetry_enabled", True)) + cfg_enabled = True if server_config is None else bool( + getattr(server_config, "telemetry_enabled", True)) self.enabled = cfg_enabled and not self._is_disabled() - + # Telemetry endpoint (Cloud Run default; override via env) - cfg_default = None if server_config is None else getattr(server_config, "telemetry_endpoint", None) - default_ep = cfg_default or "https://unity-mcp-telemetry-375728817078.us-central1.run.app/telemetry/events" + cfg_default = None if server_config is None else getattr( + server_config, "telemetry_endpoint", None) + default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events" self.default_endpoint = default_ep self.endpoint = self._validated_endpoint( os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT", default_ep), @@ -105,50 +111,53 @@ class TelemetryConfig: ) except Exception: pass - + # Local storage for UUID and milestones self.data_dir = self._get_data_directory() self.uuid_file = self.data_dir / "customer_uuid.txt" self.milestones_file = self.data_dir / "milestones.json" - + # Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT try: - self.timeout = float(os.environ.get("UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) + self.timeout = float(os.environ.get( + "UNITY_MCP_TELEMETRY_TIMEOUT", "1.5")) except Exception: self.timeout = 1.5 try: logger.info("Telemetry timeout=%.2fs", self.timeout) except Exception: pass - + # Session tracking self.session_id = str(uuid.uuid4()) - + def _is_disabled(self) -> bool: """Check if telemetry is disabled via environment variables""" disable_vars = [ "DISABLE_TELEMETRY", - "UNITY_MCP_DISABLE_TELEMETRY", + "UNITY_MCP_DISABLE_TELEMETRY", "MCP_DISABLE_TELEMETRY" ] - + for var in disable_vars: if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"): return True return False - + def _get_data_directory(self) -> Path: """Get directory for storing telemetry data""" if os.name == 'nt': # Windows - base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) + base_dir = Path(os.environ.get( + 'APPDATA', Path.home() / 'AppData' / 'Roaming')) elif os.name == 'posix': # macOS/Linux if 'darwin' in os.uname().sysname.lower(): # macOS base_dir = Path.home() / 'Library' / 'Application Support' else: # Linux - base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share')) + base_dir = Path(os.environ.get('XDG_DATA_HOME', + Path.home() / '.local' / 'share')) else: base_dir = Path.home() / '.unity-mcp' - + data_dir = base_dir / 'UnityMCP' data_dir.mkdir(parents=True, exist_ok=True) return data_dir @@ -167,7 +176,8 @@ class TelemetryConfig: # Reject localhost/loopback endpoints in production to avoid accidental local overrides host = parsed.hostname or "" if host in ("localhost", "127.0.0.1", "::1"): - raise ValueError("Localhost endpoints are not allowed for telemetry") + raise ValueError( + "Localhost endpoints are not allowed for telemetry") return candidate except Exception as e: logger.debug( @@ -176,9 +186,10 @@ class TelemetryConfig: ) return fallback + class TelemetryCollector: """Main telemetry collection class""" - + def __init__(self): self.config = TelemetryConfig() self._customer_uuid: Optional[str] = None @@ -188,23 +199,27 @@ class TelemetryCollector: self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000) # Load persistent data before starting worker so first events have UUID self._load_persistent_data() - self._worker: threading.Thread = threading.Thread(target=self._worker_loop, daemon=True) + self._worker: threading.Thread = threading.Thread( + target=self._worker_loop, daemon=True) self._worker.start() - + def _load_persistent_data(self): """Load UUID and milestones from disk""" # Load customer UUID try: if self.config.uuid_file.exists(): - self._customer_uuid = self.config.uuid_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4()) + self._customer_uuid = self.config.uuid_file.read_text( + encoding="utf-8").strip() or str(uuid.uuid4()) else: self._customer_uuid = str(uuid.uuid4()) try: - self.config.uuid_file.write_text(self._customer_uuid, encoding="utf-8") + self.config.uuid_file.write_text( + self._customer_uuid, encoding="utf-8") if os.name == "posix": os.chmod(self.config.uuid_file, 0o600) except OSError as e: - logger.debug(f"Failed to persist customer UUID: {e}", exc_info=True) + logger.debug( + f"Failed to persist customer UUID: {e}", exc_info=True) except OSError as e: logger.debug(f"Failed to load customer UUID: {e}", exc_info=True) self._customer_uuid = str(uuid.uuid4()) @@ -212,14 +227,15 @@ class TelemetryCollector: # Load milestones (failure here must not affect UUID) try: if self.config.milestones_file.exists(): - content = self.config.milestones_file.read_text(encoding="utf-8") + content = self.config.milestones_file.read_text( + encoding="utf-8") self._milestones = json.loads(content) or {} if not isinstance(self._milestones, dict): self._milestones = {} except (OSError, json.JSONDecodeError, ValueError) as e: logger.debug(f"Failed to load milestones: {e}", exc_info=True) self._milestones = {} - + def _save_milestones(self): """Save milestones to disk. Caller must hold self._lock.""" try: @@ -229,7 +245,7 @@ class TelemetryCollector: ) except OSError as e: logger.warning(f"Failed to save milestones: {e}", exc_info=True) - + 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""" if not self.config.enabled: @@ -244,26 +260,26 @@ class TelemetryCollector: } self._milestones[milestone_key] = milestone_data self._save_milestones() - + # Also send as telemetry record self.record( record_type=RecordType.USAGE, data={"milestone": milestone_key, **(data or {})}, milestone=milestone ) - + return True - - def record(self, - record_type: RecordType, - data: Dict[str, Any], + + def record(self, + record_type: RecordType, + data: Dict[str, Any], milestone: Optional[MilestoneType] = None): """Record a telemetry event (async, non-blocking)""" if not self.config.enabled: return - + # Allow fallback sender when httpx is unavailable (no early return) - + record = TelemetryRecord( record_type=record_type, timestamp=time.time(), @@ -276,7 +292,8 @@ class TelemetryCollector: try: self._queue.put_nowait(record) except queue.Full: - logger.debug("Telemetry queue full; dropping %s", record.record_type) + logger.debug("Telemetry queue full; dropping %s", + record.record_type) def _worker_loop(self): """Background worker that serializes telemetry sends.""" @@ -290,7 +307,7 @@ class TelemetryCollector: finally: with contextlib.suppress(Exception): self._queue.task_done() - + def _send_telemetry(self, record: TelemetryRecord): """Send telemetry data to endpoint""" try: @@ -323,17 +340,20 @@ class TelemetryCollector: if httpx: with httpx.Client(timeout=self.config.timeout) as client: # Re-validate endpoint at send time to handle dynamic changes - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) response = client.post(endpoint, json=payload) if 200 <= response.status_code < 300: logger.debug(f"Telemetry sent: {record.record_type}") else: - logger.warning(f"Telemetry failed: HTTP {response.status_code}") + logger.warning( + f"Telemetry failed: HTTP {response.status_code}") else: import urllib.request import urllib.error data_bytes = json.dumps(payload).encode("utf-8") - endpoint = self.config._validated_endpoint(self.config.endpoint, self.config.default_endpoint) + endpoint = self.config._validated_endpoint( + self.config.endpoint, self.config.default_endpoint) req = urllib.request.Request( endpoint, data=data_bytes, @@ -343,9 +363,11 @@ class TelemetryCollector: try: with urllib.request.urlopen(req, timeout=self.config.timeout) as resp: if 200 <= resp.getcode() < 300: - logger.debug(f"Telemetry sent (urllib): {record.record_type}") + logger.debug( + f"Telemetry sent (urllib): {record.record_type}") else: - logger.warning(f"Telemetry failed (urllib): HTTP {resp.getcode()}") + logger.warning( + f"Telemetry failed (urllib): HTTP {resp.getcode()}") except urllib.error.URLError as ue: logger.warning(f"Telemetry send failed (urllib): {ue}") @@ -357,6 +379,7 @@ class TelemetryCollector: # Global telemetry instance _telemetry_collector: Optional[TelemetryCollector] = None + def get_telemetry() -> TelemetryCollector: """Get the global telemetry collector instance""" global _telemetry_collector @@ -364,16 +387,19 @@ def get_telemetry() -> TelemetryCollector: _telemetry_collector = TelemetryCollector() return _telemetry_collector -def record_telemetry(record_type: RecordType, - data: Dict[str, Any], - milestone: Optional[MilestoneType] = None): + +def record_telemetry(record_type: RecordType, + data: Dict[str, Any], + milestone: Optional[MilestoneType] = None): """Convenience function to record telemetry""" get_telemetry().record(record_type, data, milestone) + def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool: """Convenience function to record a milestone""" return get_telemetry().record_milestone(milestone, data) + def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None, sub_action: Optional[str] = None): """Record tool usage telemetry @@ -396,36 +422,39 @@ def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: except Exception: # Ensure telemetry is never disruptive data["sub_action"] = "unknown" - + if error: data["error"] = str(error)[:200] # Limit error message length - + record_telemetry(RecordType.TOOL_EXECUTION, data) + def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None): """Record latency telemetry""" data = { "operation": operation, "duration_ms": round(duration_ms, 2) } - + if metadata: data.update(metadata) - + record_telemetry(RecordType.LATENCY, data) + def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None): """Record failure telemetry""" data = { "component": component, "error": str(error)[:500] # Limit error message length } - + if metadata: data.update(metadata) - + record_telemetry(RecordType.FAILURE, data) + def is_telemetry_enabled() -> bool: """Check if telemetry is enabled""" - return get_telemetry().config.enabled \ No newline at end of file + return get_telemetry().config.enabled diff --git a/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py b/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py index de94fb2..7e89280 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py +++ b/UnityMcpBridge/UnityMcpServer~/src/telemetry_decorator.py @@ -3,15 +3,17 @@ Telemetry decorator for Unity MCP tools """ import functools -import time import inspect import logging +import time from typing import Callable, Any + from telemetry import record_tool_usage, record_milestone, MilestoneType _log = logging.getLogger("unity-mcp-telemetry") _decorator_log_count = 0 + def telemetry_tool(tool_name: str): """Decorator to add telemetry tracking to MCP tools""" def decorator(func: Callable) -> Callable: @@ -41,7 +43,8 @@ def telemetry_tool(tool_name: str): if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) @@ -52,7 +55,8 @@ def telemetry_tool(tool_name: str): finally: duration_ms = (time.time() - start_time) * 1000 try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) @@ -82,7 +86,8 @@ def telemetry_tool(tool_name: str): if tool_name == "manage_script" and action_val == "create": record_milestone(MilestoneType.FIRST_SCRIPT_CREATION) elif tool_name.startswith("manage_scene"): - record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION) + record_milestone( + MilestoneType.FIRST_SCENE_MODIFICATION) record_milestone(MilestoneType.FIRST_TOOL_USAGE) except Exception: _log.debug("milestone emit failed", exc_info=True) @@ -93,9 +98,10 @@ def telemetry_tool(tool_name: str): finally: duration_ms = (time.time() - start_time) * 1000 try: - record_tool_usage(tool_name, success, duration_ms, error, sub_action=sub_action) + record_tool_usage(tool_name, success, + duration_ms, error, sub_action=sub_action) except Exception: _log.debug("record_tool_usage failed", exc_info=True) return _async_wrapper if inspect.iscoroutinefunction(func) else _sync_wrapper - return decorator \ No newline at end of file + return decorator diff --git a/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py b/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py index c9e3013..145f14e 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py +++ b/UnityMcpBridge/UnityMcpServer~/src/test_telemetry.py @@ -5,30 +5,30 @@ Run this to verify telemetry is working correctly """ import os -import time -import sys from pathlib import Path +import sys # Add src to Python path for imports sys.path.insert(0, str(Path(__file__).parent)) + def test_telemetry_basic(): """Test basic telemetry functionality""" # Avoid stdout noise in tests - + try: from telemetry import ( - get_telemetry, record_telemetry, record_milestone, + get_telemetry, record_telemetry, record_milestone, RecordType, MilestoneType, is_telemetry_enabled ) pass except ImportError as e: # Silent failure path for tests return False - + # Test telemetry enabled status _ = is_telemetry_enabled() - + # Test basic record try: record_telemetry(RecordType.VERSION, { @@ -39,7 +39,7 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + # Test milestone recording try: is_first = record_milestone(MilestoneType.FIRST_STARTUP, { @@ -49,7 +49,7 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + # Test telemetry collector try: collector = get_telemetry() @@ -57,79 +57,83 @@ def test_telemetry_basic(): except Exception as e: # Silent failure path for tests return False - + return True + def test_telemetry_disabled(): """Test telemetry with disabled state""" # Silent for tests - + # Set environment variable to disable telemetry os.environ["DISABLE_TELEMETRY"] = "true" - + # Re-import to get fresh config import importlib import telemetry importlib.reload(telemetry) - + from telemetry import is_telemetry_enabled, record_telemetry, RecordType - + _ = is_telemetry_enabled() - + if not is_telemetry_enabled(): pass - + # Test that records are ignored when disabled record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"}) pass - + return True else: pass return False + def test_data_storage(): """Test data storage functionality""" # Silent for tests - + try: from telemetry import get_telemetry - + collector = get_telemetry() data_dir = collector.config.data_dir - - _ = (data_dir, collector.config.uuid_file, collector.config.milestones_file) - + + _ = (data_dir, collector.config.uuid_file, + collector.config.milestones_file) + # Check if files exist if collector.config.uuid_file.exists(): pass else: pass - + if collector.config.milestones_file.exists(): pass else: pass - + return True - + except Exception as e: # Silent failure path for tests return False + def main(): """Run all telemetry tests""" # Silent runner for CI - + tests = [ test_telemetry_basic, test_data_storage, test_telemetry_disabled, ] - + passed = 0 failed = 0 - + for test in tests: try: if test(): @@ -141,9 +145,9 @@ def main(): except Exception as e: failed += 1 pass - + _ = (passed, failed) - + if failed == 0: pass return True @@ -151,6 +155,7 @@ def main(): pass return False + if __name__ == "__main__": success = main() - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py index c14f6ce..5bf45f2 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/__init__.py @@ -1,10 +1,14 @@ import logging + +from mcp.server.fastmcp import FastMCP + from .manage_script_edits import register_manage_script_edits_tools from .manage_script import register_manage_script_tools from .manage_scene import register_manage_scene_tools from .manage_editor import register_manage_editor_tools from .manage_gameobject import register_manage_gameobject_tools from .manage_asset import register_manage_asset_tools +from .manage_prefabs import register_manage_prefabs_tools from .manage_shader import register_manage_shader_tools from .read_console import register_read_console_tools from .manage_menu_item import register_manage_menu_item_tools @@ -12,7 +16,8 @@ from .resource_tools import register_resource_tools logger = logging.getLogger("mcp-for-unity-server") -def register_all_tools(mcp): + +def register_all_tools(mcp: FastMCP): """Register all refactored tools with the MCP server.""" # Prefer the surgical edits tool so LLMs discover it first logger.info("Registering MCP for Unity Server refactored tools...") @@ -22,6 +27,7 @@ def register_all_tools(mcp): register_manage_editor_tools(mcp) register_manage_gameobject_tools(mcp) register_manage_asset_tools(mcp) + register_manage_prefabs_tools(mcp) register_manage_shader_tools(mcp) register_read_console_tools(mcp) register_manage_menu_item_tools(mcp) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py index 49a8aee..a442b42 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_asset.py @@ -1,58 +1,45 @@ """ Defines the manage_asset tool for interacting with Unity assets. """ -import asyncio # Added: Import asyncio for running sync code in async -from typing import Dict, Any -from mcp.server.fastmcp import FastMCP, Context -# from ..unity_connection import get_unity_connection # Original line that caused error -from unity_connection import get_unity_connection, async_send_command_with_retry # Use centralized retry helper -from config import config -import time +import asyncio +from typing import Annotated, Any, Literal +from mcp.server.fastmcp import FastMCP, Context + +from unity_connection import async_send_command_with_retry from telemetry_decorator import telemetry_tool + def register_manage_asset_tools(mcp: FastMCP): """Registers the manage_asset tool with the MCP server.""" - @mcp.tool() + @mcp.tool(name="manage_asset", description="Performs asset operations (import, create, modify, delete, etc.) in Unity.") @telemetry_tool("manage_asset") async def manage_asset( - ctx: Any, - action: str, - path: str, - asset_type: str = None, - properties: Dict[str, Any] = None, - destination: str = None, - generate_preview: bool = False, - search_pattern: str = None, - filter_type: str = None, - filter_date_after: str = None, - page_size: Any = None, - page_number: Any = None - ) -> Dict[str, Any]: - """Performs asset operations (import, create, modify, delete, etc.) in Unity. - - Args: - ctx: The MCP context. - action: Operation to perform (e.g., 'import', 'create', 'modify', 'delete', 'duplicate', 'move', 'rename', 'search', 'get_info', 'create_folder', 'get_components'). - path: Asset path (e.g., "Materials/MyMaterial.mat") or search scope. - asset_type: Asset type (e.g., 'Material', 'Folder') - required for 'create'. - properties: Dictionary of properties for 'create'/'modify'. - example properties for Material: {"color": [1, 0, 0, 1], "shader": "Standard"}. - example properties for Texture: {"width": 1024, "height": 1024, "format": "RGBA32"}. - example properties for PhysicsMaterial: {"bounciness": 1.0, "staticFriction": 0.5, "dynamicFriction": 0.5}. - destination: Target path for 'duplicate'/'move'. - search_pattern: Search pattern (e.g., '*.prefab'). - filter_*: Filters for search (type, date). - page_*: Pagination for search. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ + ctx: Context, + action: Annotated[Literal["import", "create", "modify", "delete", "duplicate", "move", "rename", "search", "get_info", "create_folder", "get_components"], "Perform CRUD operations on assets."], + path: Annotated[str, "Asset path (e.g., 'Materials/MyMaterial.mat') or search scope."], + asset_type: Annotated[str, + "Asset type (e.g., 'Material', 'Folder') - required for 'create'."] | None = None, + properties: Annotated[dict[str, Any], + "Dictionary of properties for 'create'/'modify'."] | None = None, + destination: Annotated[str, + "Target path for 'duplicate'/'move'."] | None = None, + generate_preview: Annotated[bool, + "Generate a preview/thumbnail for the asset when supported."] = False, + search_pattern: Annotated[str, + "Search pattern (e.g., '*.prefab')."] | None = None, + filter_type: Annotated[str, "Filter type for search"] | None = None, + filter_date_after: Annotated[str, + "Date after which to filter"] | None = None, + page_size: Annotated[int, "Page size for pagination"] | None = None, + page_number: Annotated[int, "Page number for pagination"] | None = None + ) -> dict[str, Any]: + ctx.info(f"Processing manage_asset: {action}") # Ensure properties is a dict if None if properties is None: properties = {} - + # Coerce numeric inputs defensively def _coerce_int(value, default=None): if value is None: @@ -86,15 +73,13 @@ def register_manage_asset_tools(mcp: FastMCP): "pageSize": page_size, "pageNumber": page_number } - + # Remove None values to avoid sending unnecessary nulls params_dict = {k: v for k, v in params_dict.items() if v is not None} # Get the current asyncio event loop loop = asyncio.get_running_loop() - # Get the Unity connection instance - connection = get_unity_connection() - + # Use centralized async retry helper to avoid blocking the event loop result = await async_send_command_with_retry("manage_asset", params_dict, loop=loop) # Return the result obtained from Unity diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py index f5508a4..644209f 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_editor.py @@ -1,37 +1,31 @@ -from mcp.server.fastmcp import FastMCP, Context -import time -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config +from typing import Annotated, Any, Literal +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool from telemetry import is_telemetry_enabled, record_tool_usage +from unity_connection import send_command_with_retry + + def register_manage_editor_tools(mcp: FastMCP): """Register all editor management tools with the MCP server.""" - @mcp.tool(description=( - "Controls and queries the Unity editor's state and settings.\n\n" - "Args:\n" - "- ctx: Context object (required)\n" - "- action: Operation (e.g., 'play', 'pause', 'get_state', 'set_active_tool', 'add_tag')\n" - "- wait_for_completion: Optional. If True, waits for certain actions\n" - "- tool_name: Tool name for specific actions\n" - "- tag_name: Tag name for specific actions\n" - "- layer_name: Layer name for specific actions\n\n" - "Returns:\n" - "Dictionary with operation results ('success', 'message', 'data')." - )) + @mcp.tool(name="manage_editor", description="Controls and queries the Unity editor's state and settings") @telemetry_tool("manage_editor") def manage_editor( ctx: Context, - action: str, - wait_for_completion: bool = None, - # --- Parameters for specific actions --- - tool_name: str = None, - tag_name: str = None, - layer_name: str = None, - ) -> Dict[str, Any]: + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", + "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], + wait_for_completion: Annotated[bool, + "Optional. If True, waits for certain actions"] | None = None, + tool_name: Annotated[str, + "Tool name when setting active tool"] | None = None, + tag_name: Annotated[str, + "Tag name when adding and removing tags"] | None = None, + layer_name: Annotated[str, + "Layer name when adding and removing layers"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing manage_editor: {action}") try: # Diagnostics: quick telemetry checks if action == "telemetry_status": @@ -44,16 +38,16 @@ def register_manage_editor_tools(mcp: FastMCP): params = { "action": action, "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 - "layerName": layer_name, # Pass layer name + "layerName": layer_name, # Pass layer name # Add other parameters based on the action being performed # "width": width, # "height": height, # etc. } params = {k: v for k, v in params.items() if v is not None} - + # Send command using centralized retry helper 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)} except Exception as e: - return {"success": False, "message": f"Python error managing editor: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing editor: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py index a2ffe0e..285b046 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,87 +1,74 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time +from typing import Annotated, Any, Literal +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_gameobject_tools(mcp: FastMCP): """Register all GameObject management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(name="manage_gameobject", description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties.") @telemetry_tool("manage_gameobject") def manage_gameobject( - ctx: Any, - action: str, - target: str = None, # GameObject identifier by name or path - search_method: str = None, - # --- Combined Parameters for Create/Modify --- - name: str = None, # Used for both 'create' (new object name) and 'modify' (rename) - tag: str = None, # Used for both 'create' (initial tag) and 'modify' (change tag) - parent: str = None, # Used for both 'create' (initial parent) and 'modify' (change parent) - position: List[float] = None, - rotation: List[float] = None, - scale: List[float] = None, - components_to_add: List[str] = None, # List of component names to add - primitive_type: str = None, - save_as_prefab: bool = False, - prefab_path: str = None, - prefab_folder: str = "Assets/Prefabs", + ctx: Context, + action: Annotated[Literal["create", "modify", "delete", "find", "add_component", "remove_component", "set_component_property", "get_components"], "Perform CRUD operations on GameObjects and components."], + target: Annotated[str, + "GameObject identifier by name or path for modify/delete/component actions"] | None = None, + search_method: Annotated[str, + "How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups."] | None = None, + name: Annotated[str, + "GameObject name - used for both 'create' (initial name) and 'modify' (rename)"] | None = None, + tag: Annotated[str, + "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, + parent: Annotated[str, + "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, + position: Annotated[list[float], + "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, + rotation: Annotated[list[float], + "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, + scale: Annotated[list[float], + "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, + components_to_add: Annotated[list[str], + "List of component names to add"] | None = None, + primitive_type: Annotated[str, + "Primitive type for 'create' action"] | None = None, + save_as_prefab: Annotated[bool, + "If True, saves the created GameObject as a prefab"] | None = None, + prefab_path: Annotated[str, "Path for prefab creation"] | None = None, + prefab_folder: Annotated[str, + "Folder for prefab creation"] | None = None, # --- Parameters for 'modify' --- - set_active: bool = None, - layer: str = None, # Layer name - components_to_remove: List[str] = None, - component_properties: Dict[str, Dict[str, Any]] = None, + set_active: Annotated[bool, + "If True, sets the GameObject active"] | None = None, + layer: Annotated[str, "Layer name"] | None = None, + components_to_remove: Annotated[list[str], + "List of component names to remove"] | None = None, + component_properties: Annotated[dict[str, dict[str, Any]], + """Dictionary of component names to their properties to set. For example: + `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject + `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component + Example set nested property: + - Access shared material: `{"MeshRenderer": {"sharedMaterial.color": [1, 0, 0, 1]}}`"""] | None = None, # --- Parameters for 'find' --- - search_term: str = None, - find_all: bool = False, - search_in_children: bool = False, - search_inactive: bool = False, + search_term: Annotated[str, + "Search term for 'find' action"] | None = None, + find_all: Annotated[bool, + "If True, finds all GameObjects matching the search term"] | None = None, + search_in_children: Annotated[bool, + "If True, searches in children of the GameObject"] | None = None, + search_inactive: Annotated[bool, + "If True, searches inactive GameObjects"] | None = None, # -- Component Management Arguments -- - component_name: str = None, - includeNonPublicSerialized: bool = None, # Controls serialization of private [SerializeField] fields - ) -> Dict[str, Any]: - """Manages GameObjects: create, modify, delete, find, and component operations. - - Args: - action: Operation (e.g., 'create', 'modify', 'find', 'add_component', 'remove_component', 'set_component_property', 'get_components'). - target: GameObject identifier (name or path string) for modify/delete/component actions. - search_method: How to find objects ('by_name', 'by_id', 'by_path', etc.). Used with 'find' and some 'target' lookups. - name: GameObject name - used for both 'create' (initial name) and 'modify' (rename). - tag: Tag name - used for both 'create' (initial tag) and 'modify' (change tag). - parent: Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent). - layer: Layer name - used for both 'create' (initial layer) and 'modify' (change layer). - component_properties: Dict mapping Component names to their properties to set. - Example: {"Rigidbody": {"mass": 10.0, "useGravity": True}}, - To set references: - - Use asset path string for Prefabs/Materials, e.g., {"MeshRenderer": {"material": "Assets/Materials/MyMat.mat"}} - - Use a dict for scene objects/components, e.g.: - {"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}} (assigns GameObject) - {"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}} (assigns Component) - 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. - """ + component_name: Annotated[str, + "Component name for 'add_component' and 'remove_component' actions"] | None = None, + # Controls whether serialization of private [SerializeField] fields is included + includeNonPublicSerialized: Annotated[bool, + "Controls whether serialization of private [SerializeField] fields is included"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing manage_gameobject: {action}") try: - # --- Early check for attempting to modify a prefab asset --- - # ---------------------------------------------------------- - # Prepare parameters, removing None values params = { "action": action, @@ -110,9 +97,10 @@ def register_manage_gameobject_tools(mcp: FastMCP): "includeNonPublicSerialized": includeNonPublicSerialized } params = {k: v for k, v in params.items() if v is not None} - + # --- Handle Prefab Path Logic --- - if action == "create" and params.get("saveAsPrefab"): # Check if 'saveAsPrefab' is explicitly True in params + # Check if 'saveAsPrefab' is explicitly True in params + if action == "create" and params.get("saveAsPrefab"): if "prefabPath" not in params: if "name" not in params or not params["name"]: return {"success": False, "message": "Cannot create default prefab path: 'name' parameter is missing."} @@ -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"} # Ensure prefabFolder itself isn't sent if prefabPath was constructed or provided # The C# side only needs the final prefabPath - params.pop("prefabFolder", None) + params.pop("prefabFolder", None) # -------------------------------- - + # Use centralized retry helper 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)} except Exception as e: - return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing GameObject: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py index ba5601d..3e7620a 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_menu_item.py @@ -7,36 +7,25 @@ from typing import Annotated, Any, Literal from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool -from unity_connection import get_unity_connection, async_send_command_with_retry +from unity_connection import async_send_command_with_retry def register_manage_menu_item_tools(mcp: FastMCP): """Registers the manage_menu_item tool with the MCP server.""" - @mcp.tool() + @mcp.tool(name="manage_menu_item", description="Manage Unity menu items (execute/list/exists). If you're not sure what menu item to use, use the 'list' action to find it before using 'execute'.") @telemetry_tool("manage_menu_item") async def manage_menu_item( ctx: Context, - action: Annotated[Literal["execute", "list", "exists"], "One of 'execute', 'list', 'exists'"], - menu_path: Annotated[str | None, - "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] = None, - search: Annotated[str | None, - "Optional filter string for 'list' (e.g., 'Save')"] = None, - refresh: Annotated[bool | None, - "Optional flag to force refresh of the menu cache when listing"] = None, + action: Annotated[Literal["execute", "list", "exists"], "Read and execute Unity menu items."], + menu_path: Annotated[str, + "Menu path for 'execute' or 'exists' (e.g., 'File/Save Project')"] | None = None, + search: Annotated[str, + "Optional filter string for 'list' (e.g., 'Save')"] | None = None, + refresh: Annotated[bool, + "Optional flag to force refresh of the menu cache when listing"] | None = None, ) -> dict[str, Any]: - """Manage Unity menu items (execute/list/exists). - - Args: - ctx: The MCP context. - action: One of 'execute', 'list', 'exists'. - menu_path: Menu path for 'execute' or 'exists' (e.g., "File/Save Project"). - search: Optional filter string for 'list'. - refresh: Optional flag to force refresh of the menu cache when listing. - - Returns: - A dictionary with operation results ('success', 'data', 'error'). - """ + ctx.info(f"Processing manage_menu_item: {action}") # Prepare parameters for the C# handler params_dict: dict[str, Any] = { "action": action, @@ -49,8 +38,6 @@ def register_manage_menu_item_tools(mcp: FastMCP): # Get the current asyncio event loop loop = asyncio.get_running_loop() - # Touch the connection to ensure availability (mirrors other tools' pattern) - _ = get_unity_connection() # Use centralized async retry helper result = await async_send_command_with_retry("manage_menu_item", params_dict, loop=loop) diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py new file mode 100644 index 0000000..7c65f28 --- /dev/null +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_prefabs.py @@ -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}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py index 9435f03..fb5a1bc 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_scene.py @@ -1,35 +1,27 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time +from typing import Annotated, Literal, Any +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_scene_tools(mcp: FastMCP): """Register all scene management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(name="manage_scene", description="Manage Unity scenes") @telemetry_tool("manage_scene") def manage_scene( ctx: Context, - action: str, - name: str = "", - path: str = "", - build_index: Any = None, - ) -> Dict[str, Any]: - """Manages Unity scenes (load, save, create, get hierarchy, etc.). - - Args: - action: Operation (e.g., 'load', 'save', 'create', 'get_hierarchy'). - name: Scene name (no extension) for create/load/save. - path: Asset path for scene operations (default: "Assets/"). - build_index: Build index for load/build settings actions. - # Add other action-specific args as needed (e.g., for hierarchy depth) - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], + name: Annotated[str, + "Scene name. Not required get_active/get_build_settings"] | None = None, + path: Annotated[str, + "Asset path for scene operations (default: 'Assets/')"] | None = None, + build_index: Annotated[int, + "Build index for load/build settings actions"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing manage_scene: {action}") try: # Coerce numeric inputs defensively def _coerce_int(value, default=None): @@ -56,7 +48,7 @@ def register_manage_scene_tools(mcp: FastMCP): params["path"] = path if coerced_build_index is not None: params["buildIndex"] = coerced_build_index - + # Use centralized retry helper 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)} except Exception as e: - return {"success": False, "message": f"Python error managing scene: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing scene: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py index 0c27eb0..fef1e92 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script.py @@ -1,21 +1,24 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List -from unity_connection import send_command_with_retry import base64 import os +from typing import Annotated, Any, Literal from urllib.parse import urlparse, unquote +from mcp.server.fastmcp import FastMCP, Context + +from unity_connection import send_command_with_retry + try: from telemetry_decorator import telemetry_tool - from telemetry import record_milestone, MilestoneType HAS_TELEMETRY = True except ImportError: HAS_TELEMETRY = False + def telemetry_tool(tool_name: str): def decorator(func): return func return decorator + def register_manage_script_tools(mcp: FastMCP): """Register all script management tools with the MCP server.""" @@ -32,7 +35,7 @@ def register_manage_script_tools(mcp: FastMCP): """ raw_path: str if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/") :] + raw_path = uri[len("unity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) 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) parts = [p for p in norm.split("/") if p not in ("", ".")] - idx = next((i for i, seg in enumerate(parts) if seg.lower() == "assets"), None) + idx = next((i for i, seg in enumerate(parts) + if seg.lower() == "assets"), None) assets_rel = "/".join(parts[idx:]) if idx is not None else None effective_path = assets_rel if assets_rel else norm @@ -69,51 +73,47 @@ def register_manage_script_tools(mcp: FastMCP): directory = os.path.dirname(effective_path) return name, directory - @mcp.tool(description=( - "Apply small text edits to a C# script identified by URI.\n\n" - "⚠️ IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing!\n" - "Common mistakes:\n" - "- Assuming what's on a line without checking\n" - "- Using wrong line numbers (they're 1-indexed)\n" - "- Miscounting column positions (also 1-indexed, tabs count as 1)\n\n" - "RECOMMENDED WORKFLOW:\n" - "1) First call resources/read with start_line/line_count to verify exact content\n" - "2) Count columns carefully (or use find_in_file to locate patterns)\n" - "3) Apply your edit with precise coordinates\n" - "4) Consider script_apply_edits with anchors for safer pattern-based replacements\n\n" - "Args:\n" - "- uri: unity://path/Assets/... or file://... or Assets/...\n" - "- edits: list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)\n" - "- precondition_sha256: optional SHA of current file (prevents concurrent edit conflicts)\n\n" - "Notes:\n" - "- Path must resolve under Assets/\n" - "- For method/class operations, use script_apply_edits (safer, structured edits)\n" - "- For pattern-based replacements, consider anchor operations in script_apply_edits\n" + @mcp.tool(name="apply_text_edits", description=( + """Apply small text edits to a C# script identified by URI. + IMPORTANT: This tool replaces EXACT character positions. Always verify content at target lines/columns BEFORE editing! + RECOMMENDED WORKFLOW: + 1. First call resources/read with start_line/line_count to verify exact content + 2. Count columns carefully (or use find_in_file to locate patterns) + 3. Apply your edit with precise coordinates + 4. Consider script_apply_edits with anchors for safer pattern-based replacements + Notes: + - For method/class operations, use script_apply_edits (safer, structured edits) + - For pattern-based replacements, consider anchor operations in script_apply_edits + - Lines, columns are 1-indexed + - Tabs count as 1 column""" )) @telemetry_tool("apply_text_edits") def apply_text_edits( ctx: Context, - uri: str, - edits: List[Dict[str, Any]], - precondition_sha256: str | None = None, - strict: bool | None = None, - options: Dict[str, Any] | None = None, - ) -> Dict[str, Any]: - """Apply small text edits to a C# script identified by URI.""" + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], + precondition_sha256: Annotated[str, + "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, + strict: Annotated[bool, + "Optional strict flag, used to enforce strict mode"] | None = None, + options: Annotated[dict[str, Any], + "Optional options, used to pass additional options to the script editor"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing apply_text_edits: {uri}") name, directory = _split_uri(uri) # Normalize common aliases/misuses for resilience: # - Accept LSP-style range objects: {range:{start:{line,character}, end:{...}}, newText|text} # - Accept index ranges as a 2-int array: {range:[startIndex,endIndex], text} # If normalization is required, read current contents to map indices -> 1-based line/col. - def _needs_normalization(arr: List[Dict[str, Any]]) -> bool: + def _needs_normalization(arr: list[dict[str, Any]]) -> bool: for e in arr or []: if ("startLine" not in e) or ("startCol" not in e) or ("endLine" not in e) or ("endCol" not in e) or ("newText" not in e and "text" in e): return True return False - normalized_edits: List[Dict[str, Any]] = [] - warnings: List[str] = [] + normalized_edits: list[dict[str, Any]] = [] + warnings: list[str] = [] if _needs_normalization(edits): # Read file to support index->line/col conversion when needed read_resp = send_command_with_retry("manage_script", { @@ -127,7 +127,8 @@ def register_manage_script_tools(mcp: FastMCP): contents = data.get("contents") if not contents and data.get("contentsEncoded"): try: - contents = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") + contents = base64.b64decode(data.get("encodedContents", "").encode( + "utf-8")).decode("utf-8", "replace") except Exception: contents = contents or "" @@ -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: # Guard: explicit fields must be 1-based. zero_based = False - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True @@ -161,13 +162,14 @@ def register_manage_script_tools(mcp: FastMCP): 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}} # Normalize by clamping to 1 and warn - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass - warnings.append("zero_based_explicit_fields_normalized") + warnings.append( + "zero_based_explicit_fields_normalized") normalized_edits.append(e2) continue @@ -205,17 +207,18 @@ def register_manage_script_tools(mcp: FastMCP): "success": False, "code": "missing_field", "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: # Even when edits appear already in explicit form, validate 1-based coordinates. normalized_edits = [] for e in edits or []: e2 = dict(e) - has_all = all(k in e2 for k in ("startLine","startCol","endLine","endCol")) + has_all = all(k in e2 for k in ( + "startLine", "startCol", "endLine", "endCol")) if has_all: zero_based = False - for k in ("startLine","startCol","endLine","endCol"): + for k in ("startLine", "startCol", "endLine", "endCol"): try: if int(e2.get(k, 1)) < 1: zero_based = True @@ -224,21 +227,24 @@ def register_manage_script_tools(mcp: FastMCP): if zero_based: if strict: 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: if int(e2.get(k, 1)) < 1: e2[k] = 1 except Exception: pass if "zero_based_explicit_fields_normalized" not in warnings: - warnings.append("zero_based_explicit_fields_normalized") + warnings.append( + "zero_based_explicit_fields_normalized") normalized_edits.append(e2) # Preflight: detect overlapping ranges among normalized line/col spans - def _pos_tuple(e: Dict[str, Any], key_start: bool) -> tuple[int, int]: + def _pos_tuple(e: dict[str, Any], key_start: bool) -> tuple[int, int]: return ( - int(e.get("startLine", 1)) if key_start else int(e.get("endLine", 1)), - int(e.get("startCol", 1)) if key_start else int(e.get("endCol", 1)), + int(e.get("startLine", 1)) if key_start else int( + e.get("endLine", 1)), + int(e.get("startCol", 1)) if key_start else int( + e.get("endCol", 1)), ) def _le(a: tuple[int, int], b: tuple[int, int]) -> bool: @@ -276,7 +282,7 @@ def register_manage_script_tools(mcp: FastMCP): # preserves existing call-count expectations in clients/tests. # Default options: for multi-span batches, prefer atomic to avoid mid-apply imbalance - opts: Dict[str, Any] = dict(options or {}) + opts: dict[str, Any] = dict(options or {}) try: if len(normalized_edits) > 1 and "applyMode" not in opts: opts["applyMode"] = "atomic" @@ -320,10 +326,16 @@ def register_manage_script_tools(mcp: FastMCP): if resp.get("success") and (options or {}).get("force_sentinel_reload"): # Optional: flip sentinel via menu if explicitly requested try: - import threading, time, json, glob, os + import threading + import time + import json + import glob + import os + def _latest_status() -> dict | None: try: - files = sorted(glob.glob(os.path.expanduser("~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) + files = sorted(glob.glob(os.path.expanduser( + "~/.unity-mcp/unity-mcp-status-*.json")), key=os.path.getmtime, reverse=True) if not files: return None with open(files[0], "r") as f: @@ -352,24 +364,21 @@ def register_manage_script_tools(mcp: FastMCP): return resp return {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Create a new C# script at the given project path.\n\n" - "Args: path (e.g., 'Assets/Scripts/My.cs'), contents (string), script_type, namespace.\n" - "Rules: path must be under Assets/. Contents will be Base64-encoded over transport.\n" - )) + @mcp.tool(name="create_script", description=("Create a new C# script at the given project path.")) @telemetry_tool("create_script") def create_script( ctx: Context, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Create a new C# script at the given path.""" + path: Annotated[str, "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create. Note, this is Base64 encoded over transport."], + script_type: Annotated[str, "Script type (e.g., 'C#')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing create_script: {path}") name = os.path.splitext(os.path.basename(path))[0] directory = os.path.dirname(path) # Local validation to avoid round-trips on obviously bad input - norm_path = os.path.normpath((path or "").replace("\\", "/")).replace("\\", "/") + norm_path = os.path.normpath( + (path or "").replace("\\", "/")).replace("\\", "/") if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": f"path must be under 'Assets/'; got '{path}'."} if ".." in norm_path.split("/") or norm_path.startswith("/"): @@ -378,7 +387,7 @@ def register_manage_script_tools(mcp: FastMCP): return {"success": False, "code": "bad_path", "message": "path must include a script file name."} if not norm_path.lower().endswith(".cs"): return {"success": False, "code": "bad_extension", "message": "script file must end with .cs."} - params: Dict[str, Any] = { + params: dict[str, Any] = { "action": "create", "name": name, "path": directory, @@ -386,20 +395,21 @@ def register_manage_script_tools(mcp: FastMCP): "scriptType": script_type, } if contents: - params["encodedContents"] = base64.b64encode(contents.encode("utf-8")).decode("utf-8") + params["encodedContents"] = base64.b64encode( + contents.encode("utf-8")).decode("utf-8") params["contentsEncoded"] = True params = {k: v for k, v in params.items() if v is not None} resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Delete a C# script by URI or Assets-relative path.\n\n" - "Args: uri (unity://path/... or file://... or Assets/...).\n" - "Rules: Target must resolve under Assets/.\n" - )) + @mcp.tool(name="delete_script", description=("Delete a C# script by URI or Assets-relative path.")) @telemetry_tool("delete_script") - def delete_script(ctx: Context, uri: str) -> Dict[str, Any]: + def delete_script( + ctx: Context, + uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] + ) -> dict[str, Any]: """Delete a C# script by URI.""" + ctx.info(f"Processing delete_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} @@ -407,18 +417,17 @@ def register_manage_script_tools(mcp: FastMCP): resp = send_command_with_retry("manage_script", params) return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Validate a C# script and return diagnostics.\n\n" - "Args: uri, level=('basic'|'standard'), include_diagnostics (bool, optional).\n" - "- basic: quick syntax checks.\n" - "- standard: deeper checks (performance hints, common pitfalls).\n" - "- include_diagnostics: when true, returns full diagnostics and summary; default returns counts only.\n" - )) + @mcp.tool(name="validate_script", description=("Validate a C# script and return diagnostics.")) @telemetry_tool("validate_script") def validate_script( - ctx: Context, uri: str, level: str = "basic", include_diagnostics: bool = False - ) -> Dict[str, Any]: - """Validate a C# script and return diagnostics.""" + ctx: Context, + uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + level: Annotated[Literal['basic', 'standard'], + "Validation level"] = "basic", + include_diagnostics: Annotated[bool, + "Include full diagnostics and summary"] = False + ) -> dict[str, Any]: + ctx.info(f"Processing validate_script: {uri}") name, directory = _split_uri(uri) if not directory or directory.split("/")[0].lower() != "assets": return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."} @@ -433,103 +442,30 @@ def register_manage_script_tools(mcp: FastMCP): resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): diags = resp.get("data", {}).get("diagnostics", []) or [] - warnings = sum(1 for d in diags if str(d.get("severity", "")).lower() == "warning") - errors = sum(1 for d in diags if str(d.get("severity", "")).lower() in ("error", "fatal")) + warnings = sum(1 for d in diags if str( + d.get("severity", "")).lower() == "warning") + errors = sum(1 for d in diags if str( + d.get("severity", "")).lower() in ("error", "fatal")) if include_diagnostics: return {"success": True, "data": {"diagnostics": diags, "summary": {"warnings": warnings, "errors": errors}}} return {"success": True, "data": {"warnings": warnings, "errors": errors}} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} - @mcp.tool(description=( - "Compatibility router for legacy script operations.\n\n" - "Actions: create|read|delete (update is routed to apply_text_edits with precondition).\n" - "Args: name (no .cs), path (Assets/...), contents (for create), script_type, namespace.\n" - "Notes: prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.\n" - )) + @mcp.tool(name="manage_script", description=("Compatibility router for legacy script operations. Prefer apply_text_edits (ranges) or script_apply_edits (structured) for edits.")) @telemetry_tool("manage_script") def manage_script( ctx: Context, - action: str, - name: str, - path: str, - contents: str = "", - script_type: str | None = None, - namespace: str | None = None, - ) -> Dict[str, Any]: - """Compatibility router for legacy script operations. - - IMPORTANT: - - Direct file reads should use resources/read. - - Edits should use apply_text_edits. - - Args: - action: Operation ('create', 'read', 'delete'). - name: Script name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: C# code for 'create'/'update'. - script_type: Type hint (e.g., 'MonoBehaviour'). - namespace: Script namespace. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + action: Annotated[Literal['create', 'read', 'delete'], "Perform CRUD operations on C# scripts."], + name: Annotated[str, "Script name (no .cs extension)", "Name of the script to create"], + path: Annotated[str, "Asset path (default: 'Assets/')", "Path under Assets/ to create the script at, e.g., 'Assets/Scripts/My.cs'"], + contents: Annotated[str, "Contents of the script to create", + "C# code for 'create'/'update'"] | None = None, + script_type: Annotated[str, "Script type (e.g., 'C#')", + "Type hint (e.g., 'MonoBehaviour')"] | None = None, + namespace: Annotated[str, "Namespace for the script"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing manage_script: {action}") try: - # Graceful migration for legacy 'update': route to apply_text_edits (whole-file replace) - if action == 'update': - try: - # 1) Read current contents to compute end range and precondition - read_resp = send_command_with_retry("manage_script", { - "action": "read", - "name": name, - "path": path, - }) - if not (isinstance(read_resp, dict) and read_resp.get("success")): - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; automatic migration failed to read current file."} - data = read_resp.get("data", {}) - current = data.get("contents") - if not current and data.get("contentsEncoded"): - current = base64.b64decode(data.get("encodedContents", "").encode("utf-8")).decode("utf-8", "replace") - if current is None: - return {"success": False, "code": "deprecated_update", "message": "Use apply_text_edits; current file read returned no contents."} - - # 2) Compute whole-file range (1-based, end exclusive) and SHA - import hashlib as _hashlib - old_lines = current.splitlines(keepends=True) - end_line = len(old_lines) + 1 - sha = _hashlib.sha256(current.encode("utf-8")).hexdigest() - - # 3) Apply single whole-file text edit with provided 'contents' - edits = [{ - "startLine": 1, - "startCol": 1, - "endLine": end_line, - "endCol": 1, - "newText": contents or "", - }] - route_params = { - "action": "apply_text_edits", - "name": name, - "path": path, - "edits": edits, - "precondition_sha256": sha, - "options": {"refresh": "debounced", "validate": "standard"}, - } - # Preflight size vs. default cap (256 KiB) to avoid opaque server errors - try: - import json as _json - payload_bytes = len(_json.dumps({"edits": edits}, ensure_ascii=False).encode("utf-8")) - if payload_bytes > 256 * 1024: - return {"success": False, "code": "payload_too_large", "message": f"Edit payload {payload_bytes} bytes exceeds 256 KiB cap; try structured ops or chunking."} - except Exception: - pass - routed = send_command_with_retry("manage_script", route_params) - if isinstance(routed, dict): - routed.setdefault("message", "Routed legacy update to apply_text_edits") - return routed - return {"success": False, "message": str(routed)} - except Exception as e: - return {"success": False, "code": "deprecated_update", "message": f"Use apply_text_edits; migration error: {e}"} - # Prepare parameters for Unity params = { "action": action, @@ -542,7 +478,8 @@ def register_manage_script_tools(mcp: FastMCP): # Base64 encode the contents if they exist to avoid JSON escaping issues if contents: if action == 'create': - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents @@ -554,7 +491,8 @@ def register_manage_script_tools(mcp: FastMCP): if isinstance(response, dict): if response.get("success"): if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] @@ -574,19 +512,24 @@ def register_manage_script_tools(mcp: FastMCP): "message": f"Python error managing script: {str(e)}", } - @mcp.tool(description=( - "Get manage_script capabilities (supported ops, limits, and guards).\n\n" - "Returns:\n- ops: list of supported structured ops\n- text_ops: list of supported text ops\n- max_edit_payload_bytes: server edit payload cap\n- guards: header/using guard enabled flag\n" + @mcp.tool(name="manage_script_capabilities", description=( + """Get manage_script capabilities (supported ops, limits, and guards). + Returns: + - ops: list of supported structured ops + - text_ops: list of supported text ops + - max_edit_payload_bytes: server edit payload cap + - guards: header/using guard enabled flag""" )) @telemetry_tool("manage_script_capabilities") - def manage_script_capabilities(ctx: Context) -> Dict[str, Any]: + def manage_script_capabilities(ctx: Context) -> dict[str, Any]: + ctx.info("Processing manage_script_capabilities") try: # Keep in sync with server/Editor ManageScript implementation ops = [ - "replace_class","delete_class","replace_method","delete_method", - "insert_method","anchor_insert","anchor_delete","anchor_replace" + "replace_class", "delete_class", "replace_method", "delete_method", + "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 max_edit_payload_bytes = 256 * 1024 guards = {"using_guard": True} @@ -601,21 +544,21 @@ def register_manage_script_tools(mcp: FastMCP): except Exception as e: return {"success": False, "error": f"capabilities error: {e}"} - @mcp.tool(description=( - "Get SHA256 and basic metadata for a Unity C# script without returning file contents.\n\n" - "Args: uri (unity://path/Assets/... or file://... or Assets/...).\n" - "Returns: {sha256, lengthBytes}." - )) + @mcp.tool(name="get_sha", description="Get SHA256 and basic metadata for a Unity C# script without returning file contents") @telemetry_tool("get_sha") - def get_sha(ctx: Context, uri: str) -> Dict[str, Any]: - """Return SHA256 and basic metadata for a script.""" + def get_sha( + ctx: Context, + uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."] + ) -> dict[str, Any]: + ctx.info(f"Processing get_sha: {uri}") try: name, directory = _split_uri(uri) params = {"action": "get_sha", "name": name, "path": directory} resp = send_command_with_retry("manage_script", params) if isinstance(resp, dict) and resp.get("success"): data = resp.get("data", {}) - minimal = {"sha256": data.get("sha256"), "lengthBytes": data.get("lengthBytes")} + minimal = {"sha256": data.get( + "sha256"), "lengthBytes": data.get("lengthBytes")} return {"success": True, "data": minimal} return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} except Exception as e: diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py index 3d66da0..261eb50 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_script_edits.py @@ -1,14 +1,15 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any, List, Tuple, Optional import base64 +import hashlib import re -import os -from unity_connection import send_command_with_retry +from typing import Annotated, Any +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry -def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str: + +def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str: text = original_text for edit in edits or []: op = ( @@ -29,7 +30,8 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str if op == "prepend": prepend_text = edit.get("text", "") - text = (prepend_text if prepend_text.endswith("\n") else prepend_text + "\n") + text + text = (prepend_text if prepend_text.endswith( + "\n") else prepend_text + "\n") + text elif op == "append": append_text = edit.get("text", "") if not text.endswith("\n"): @@ -41,10 +43,12 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str anchor = edit.get("anchor", "") position = (edit.get("position") or "before").lower() insert_text = edit.get("text", "") - flags = re.MULTILINE | (re.IGNORECASE if edit.get("ignore_case") else 0) - + flags = re.MULTILINE | ( + re.IGNORECASE if edit.get("ignore_case") else 0) + # Find the best match using improved heuristics - match = _find_best_anchor_match(anchor, text, flags, bool(edit.get("prefer_last", True))) + match = _find_best_anchor_match( + anchor, text, flags, bool(edit.get("prefer_last", True))) if not match: if edit.get("allow_noop", True): continue @@ -53,15 +57,16 @@ def _apply_edits_locally(original_text: str, edits: List[Dict[str, Any]]) -> str text = text[:idx] + insert_text + text[idx:] elif op == "replace_range": start_line = int(edit.get("startLine", 1)) - start_col = int(edit.get("startCol", 1)) - end_line = int(edit.get("endLine", start_line)) - end_col = int(edit.get("endCol", 1)) + start_col = int(edit.get("startCol", 1)) + end_line = int(edit.get("endLine", start_line)) + end_col = int(edit.get("endCol", 1)) replacement = edit.get("text", "") lines = text.splitlines(keepends=True) max_line = len(lines) + 1 # 1-based, exclusive end if (start_line < 1 or end_line < start_line or end_line > max_line or start_col < 1 or end_col < 1): raise RuntimeError("replace_range out of bounds") + def index_of(line: int, col: int) -> int: if line <= len(lines): return sum(len(l) for l in lines[: line - 1]) + (col - 1) @@ -81,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) else: allowed = "anchor_insert, prepend, append, replace_range, regex_replace" - raise RuntimeError(f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") + raise RuntimeError( + f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).") return text def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True): """ Find the best anchor match using improved heuristics. - + For patterns like \\s*}\\s*$ that are meant to find class-ending braces, 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) 2. Use indentation levels to distinguish class vs method braces 3. Consider context to avoid matches inside strings/comments - + Args: pattern: Regex pattern to search for text: Text to search in flags: Regex flags prefer_last: If True, prefer the last match over the first - + Returns: Match object of the best match, or None if no match found """ - import re - + # Find all matches matches = list(re.finditer(pattern, text, flags)) if not matches: return None - + # If only one match, return it if len(matches) == 1: return matches[0] - + # For patterns that look like they're trying to match closing braces at end of lines - is_closing_brace_pattern = '}' in pattern and ('$' in pattern or pattern.endswith(r'\s*')) - + is_closing_brace_pattern = '}' in pattern and ( + '$' in pattern or pattern.endswith(r'\s*')) + if is_closing_brace_pattern and prefer_last: # Use heuristics to find the best closing brace match return _find_best_closing_brace_match(matches, text) - + # Default behavior: use last match if prefer_last, otherwise first match 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): """ Find the best closing brace match using C# structure heuristics. - + Enhanced heuristics for scope-aware matching: 1. Prefer matches with lower indentation (likely class-level) 2. Prefer matches closer to end of file 3. Avoid matches that seem to be inside method bodies 4. For #endregion patterns, ensure class-level context 5. Validate insertion point is at appropriate scope - + Args: matches: List of regex match objects text: The full text being searched - + Returns: The best match object """ if not matches: return None - + scored_matches = [] lines = text.splitlines() - + for match in matches: score = 0 start_pos = match.start() - + # Find which line this match is on lines_before = text[:start_pos].count('\n') line_num = lines_before - + if line_num < len(lines): line_content = lines[line_num] - + # Calculate indentation level (lower is better for class braces) indentation = len(line_content) - len(line_content.lstrip()) - + # Prefer lower indentation (class braces are typically less indented than method braces) - score += max(0, 20 - indentation) # Max 20 points for indentation=0 - + # Max 20 points for indentation=0 + score += max(0, 20 - indentation) + # Prefer matches closer to end of file (class closing braces are typically at the end) distance_from_end = len(lines) - line_num - score += max(0, 10 - distance_from_end) # More points for being closer to end - + # More points for being closer to end + score += max(0, 10 - distance_from_end) + # Look at surrounding context to avoid method braces context_start = max(0, line_num - 3) - context_end = min(len(lines), line_num + 2) + context_end = min(len(lines), line_num + 2) context_lines = lines[context_start:context_end] - + # Penalize if this looks like it's inside a method (has method-like patterns above) for context_line in context_lines: if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line): score -= 5 # Penalty for being near method signatures - + # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF) if indentation <= 4 and distance_from_end <= 3: score += 15 # Bonus for likely class-ending brace - + scored_matches.append((score, match)) - + # Return the match with the highest score scored_matches.sort(key=lambda x: x[0], reverse=True) best_match = scored_matches[0][1] - + 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 - -def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: +def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: """Best-effort normalization of script "name" and "path". Accepts any of: @@ -258,7 +265,8 @@ def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: parts = candidate.split("/") file_name = parts[-1] dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets" - base = file_name[:-3] if file_name.lower().endswith(".cs") else file_name + base = file_name[:- + 3] if file_name.lower().endswith(".cs") else file_name return base, dir_path # Fall back: remove extension from name if present and return given path @@ -266,7 +274,7 @@ def _normalize_script_locator(name: str, path: str) -> Tuple[str, str]: return base_name, (p or "Assets") -def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: str | None = None) -> Dict[str, Any] | Any: +def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any: if not isinstance(resp, dict): return resp data = resp.setdefault("data", {}) @@ -276,10 +284,11 @@ def _with_norm(resp: Dict[str, Any] | Any, edits: List[Dict[str, Any]], routing: return resp -def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rewrite: Dict[str, Any] | None = None, - normalized: List[Dict[str, Any]] | None = None, routing: str | None = None, extra: Dict[str, Any] | None = None) -> Dict[str, Any]: - payload: Dict[str, Any] = {"success": False, "code": code, "message": message} - data: Dict[str, Any] = {} +def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None, + normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"success": False, + "code": code, "message": message} + data: dict[str, Any] = {} if expected: data["expected"] = expected if rewrite: @@ -298,77 +307,78 @@ def _err(code: str, message: str, *, expected: Dict[str, Any] | None = None, rew def register_manage_script_edits_tools(mcp: FastMCP): - @mcp.tool(description=( - "Structured C# edits (methods/classes) with safer boundaries — prefer this over raw text.\n\n" - "Best practices:\n" - "- Prefer anchor_* ops for pattern-based insert/replace near stable markers\n" - "- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)\n" - "- Avoid whole-file regex deletes; validators will guard unbalanced braces\n" - "- For tail insertions, prefer anchor/regex_replace on final brace (class closing)\n" - "- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits\n\n" - "Canonical fields (use these exact keys):\n" - "- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace\n" - "- className: string (defaults to 'name' if omitted on method/class ops)\n" - "- methodName: string (required for replace_method, delete_method)\n" - "- replacement: string (required for replace_method, insert_method)\n" - "- position: start | end | after | before (insert_method only)\n" - "- afterMethodName / beforeMethodName: string (required when position='after'/'before')\n" - "- anchor: regex string (for anchor_* ops)\n" - "- text: string (for anchor_insert/anchor_replace)\n\n" - "Do NOT use: new_method, anchor_method, content, newText (aliases accepted but normalized).\n\n" - "Examples:\n" - "1) Replace a method:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"replace_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"methodName\": \"HasTarget\",\n" - " \"replacement\": \"public bool HasTarget(){ return currentTarget!=null; }\"\n" - " }\n" - " ],\n" - " \"options\": {\"validate\": \"standard\", \"refresh\": \"immediate\"}\n" - "}\n\n" - "2) Insert a method after another:\n" - "{\n" - " \"name\": \"SmartReach\",\n" - " \"path\": \"Assets/Scripts/Interaction\",\n" - " \"edits\": [\n" - " {\n" - " \"op\": \"insert_method\",\n" - " \"className\": \"SmartReach\",\n" - " \"replacement\": \"public void PrintSeries(){ Debug.Log(seriesName); }\",\n" - " \"position\": \"after\",\n" - " \"afterMethodName\": \"GetCurrentTarget\"\n" - " }\n" - " ]\n" - "}\n\n" - "Note: 'options' must be an object/dict, not a string. Use proper JSON syntax.\n" + @mcp.tool(name="script_apply_edits", description=( + """Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text. + Best practices: + - Prefer anchor_* ops for pattern-based insert/replace near stable markers + - Use replace_method/delete_method for whole-method changes (keeps signatures balanced) + - Avoid whole-file regex deletes; validators will guard unbalanced braces + - For tail insertions, prefer anchor/regex_replace on final brace (class closing) + - Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits + Canonical fields (use these exact keys): + - op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace + - className: string (defaults to 'name' if omitted on method/class ops) + - methodName: string (required for replace_method, delete_method) + - replacement: string (required for replace_method, insert_method) + - position: start | end | after | before (insert_method only) + - afterMethodName / beforeMethodName: string (required when position='after'/'before') + - anchor: regex string (for anchor_* ops) + - text: string (for anchor_insert/anchor_replace) + Examples: + 1) Replace a method: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "replace_method", + "className": "SmartReach", + "methodName": "HasTarget", + "replacement": "public bool HasTarget(){ return currentTarget!=null; }" + } + ], + "options": {"validate": "standard", "refresh": "immediate"} + } + "2) Insert a method after another: + { + "name": "SmartReach", + "path": "Assets/Scripts/Interaction", + "edits": [ + { + "op": "insert_method", + "className": "SmartReach", + "replacement": "public void PrintSeries(){ Debug.Log(seriesName); }", + "position": "after", + "afterMethodName": "GetCurrentTarget" + } + ], + } + ]""" )) @telemetry_tool("script_apply_edits") def script_apply_edits( ctx: Context, - name: str, - path: str, - edits: List[Dict[str, Any]], - options: Optional[Dict[str, Any]] = None, - script_type: str = "MonoBehaviour", - namespace: str = "", - ) -> Dict[str, Any]: + name: Annotated[str, "Name of the script to edit"], + path: Annotated[str, "Path to the script to edit under Assets/ directory"], + edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script"], + options: Annotated[dict[str, Any], + "Options for the script edit"] | None = None, + script_type: Annotated[str, + "Type of the script to edit"] = "MonoBehaviour", + namespace: Annotated[str, + "Namespace of the script to edit"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing script_apply_edits: {name}") # Normalize locator first so downstream calls target the correct script file. name, path = _normalize_script_locator(name, path) - - # No NL path: clients must provide structured edits in 'edits'. - # Normalize unsupported or aliased ops to known structured/text paths - def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]: + + def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]: # Unwrap single-key wrappers like {"replace_method": {...}} for wrapper_key in ( - "replace_method","insert_method","delete_method", - "replace_class","delete_class", - "anchor_insert","anchor_replace","anchor_delete", + "replace_method", "insert_method", "delete_method", + "replace_class", "delete_class", + "anchor_insert", "anchor_replace", "anchor_delete", ): if wrapper_key in edit and isinstance(edit[wrapper_key], dict): inner = dict(edit[wrapper_key]) @@ -377,7 +387,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): break e = dict(edit) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() if op: e["op"] = op @@ -452,13 +463,14 @@ def register_manage_script_edits_tools(mcp: FastMCP): e["text"] = edit.get("newText", "") return e - normalized_edits: List[Dict[str, Any]] = [] + normalized_edits: list[dict[str, Any]] = [] for raw in edits or []: e = _unwrap_and_alias(raw) - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() # Default className to script name if missing on structured method/class ops - if op in ("replace_class","delete_class","replace_method","delete_method","insert_method") and not e.get("className"): + if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"): e["className"] = name # Map common aliases for text ops @@ -475,7 +487,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): if "text" in e: e["replacement"] = e.get("text", "") elif "insert" in e or "content" in e: - e["replacement"] = e.get("insert") or e.get("content") or "" + e["replacement"] = e.get( + "insert") or e.get("content") or "" if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")): e["op"] = "anchor_delete" normalized_edits.append(e) @@ -486,7 +499,7 @@ def register_manage_script_edits_tools(mcp: FastMCP): normalized_for_echo = edits # Validate required fields and produce machine-parsable hints - def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str, Any]) -> Dict[str, Any]: + def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]: return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo) for e in edits or []: @@ -495,40 +508,46 @@ def register_manage_script_edits_tools(mcp: FastMCP): if not e.get("methodName"): return error_with_hint( "replace_method requires 'methodName'.", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, {"edits[0].methodName": "HasTarget"} ) if not (e.get("replacement") or e.get("text")): return error_with_hint( "replace_method requires 'replacement' (inline or base64).", - {"op": "replace_method", "required": ["className", "methodName", "replacement"]}, + {"op": "replace_method", "required": [ + "className", "methodName", "replacement"]}, {"edits[0].replacement": "public bool X(){ return true; }"} ) elif op == "insert_method": if not (e.get("replacement") or e.get("text")): return error_with_hint( "insert_method requires a non-empty 'replacement'.", - {"op": "insert_method", "required": ["className", "replacement"], "position": {"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, + {"op": "insert_method", "required": ["className", "replacement"], "position": { + "after_requires": "afterMethodName", "before_requires": "beforeMethodName"}}, {"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"} ) pos = (e.get("position") or "").lower() if pos == "after" and not e.get("afterMethodName"): return error_with_hint( "insert_method with position='after' requires 'afterMethodName'.", - {"op": "insert_method", "position": {"after_requires": "afterMethodName"}}, + {"op": "insert_method", "position": { + "after_requires": "afterMethodName"}}, {"edits[0].afterMethodName": "GetCurrentTarget"} ) if pos == "before" and not e.get("beforeMethodName"): return error_with_hint( "insert_method with position='before' requires 'beforeMethodName'.", - {"op": "insert_method", "position": {"before_requires": "beforeMethodName"}}, + {"op": "insert_method", "position": { + "before_requires": "beforeMethodName"}}, {"edits[0].beforeMethodName": "GetCurrentTarget"} ) elif op == "delete_method": if not e.get("methodName"): return error_with_hint( "delete_method requires 'methodName'.", - {"op": "delete_method", "required": ["className", "methodName"]}, + {"op": "delete_method", "required": [ + "className", "methodName"]}, {"edits[0].methodName": "PrintSeries"} ) elif op in ("anchor_insert", "anchor_replace", "anchor_delete"): @@ -546,9 +565,10 @@ def register_manage_script_edits_tools(mcp: FastMCP): ) # Decide routing: structured vs text vs mixed - STRUCT = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_delete","anchor_replace","anchor_insert"} - TEXT = {"prepend","append","replace_range","regex_replace"} - ops_set = { (e.get("op") or "").lower() for e in edits or [] } + STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method", + "insert_method", "anchor_delete", "anchor_replace", "anchor_insert"} + TEXT = {"prepend", "append", "replace_range", "regex_replace"} + ops_set = {(e.get("op") or "").lower() for e in edits or []} all_struct = ops_set.issubset(STRUCT) all_text = ops_set.issubset(TEXT) mixed = not (all_struct or all_text) @@ -558,7 +578,7 @@ def register_manage_script_edits_tools(mcp: FastMCP): opts2 = dict(options or {}) # For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused opts2.setdefault("refresh", "immediate") - params_struct: Dict[str, Any] = { + params_struct: dict[str, Any] = { "action": "edit", "name": name, "path": path, @@ -567,7 +587,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): "edits": edits, "options": opts2, } - resp_struct = send_command_with_retry("manage_script", params_struct) + resp_struct = send_command_with_retry( + "manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured") @@ -583,10 +604,12 @@ def register_manage_script_edits_tools(mcp: FastMCP): if not isinstance(read_resp, dict) or not read_resp.get("success"): return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)} - data = read_resp.get("data") or read_resp.get("result", {}).get("data") or {} + data = read_resp.get("data") or read_resp.get( + "result", {}).get("data") or {} contents = data.get("contents") if contents is None and data.get("contentsEncoded") and data.get("encodedContents"): - contents = base64.b64decode(data["encodedContents"]).decode("utf-8") + contents = base64.b64decode( + data["encodedContents"]).decode("utf-8") if contents is None: return {"success": False, "message": "No contents returned from Unity read."} @@ -595,28 +618,35 @@ def register_manage_script_edits_tools(mcp: FastMCP): # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured if mixed: - text_edits = [e for e in edits or [] if (e.get("op") or "").lower() in TEXT] - struct_edits = [e for e in edits or [] if (e.get("op") or "").lower() in STRUCT] + text_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in TEXT] + struct_edits = [e for e in edits or [] if ( + e.get("op") or "").lower() in STRUCT] try: base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: + + def line_col_from_index(idx: int) -> tuple[int, int]: line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 return line, col - at_edits: List[Dict[str, Any]] = [] - import re as _re + at_edits: list[dict[str, Any]] = [] for e in text_edits: - opx = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() - text_field = e.get("text") or e.get("insert") or e.get("content") or e.get("replacement") or "" + opx = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() + text_field = e.get("text") or e.get("insert") or e.get( + "content") or e.get("replacement") or "" if opx == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) try: # Use improved anchor matching logic - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first") if not m: @@ -629,10 +659,11 @@ def register_manage_script_edits_tools(mcp: FastMCP): if not text_field_norm.endswith("\n"): text_field_norm = text_field_norm + "\n" sl, sc = line_col_from_index(idx) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm}) # do not mutate base_text when building atomic spans elif opx == "replace_range": - if all(k in e for k in ("startLine","startCol","endLine","endCol")): + if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")): at_edits.append({ "startLine": int(e.get("startLine", 1)), "startCol": int(e.get("startCol", 1)), @@ -645,39 +676,44 @@ def register_manage_script_edits_tools(mcp: FastMCP): elif opx == "regex_replace": pattern = e.get("pattern") or "" try: - regex_obj = _re.compile(pattern, _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0)) + regex_obj = re.compile(pattern, re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0)) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first") m = regex_obj.search(base_text) if not m: continue # Expand $1, $2... in replacement using this match + def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl = _expand_dollars(text_field) sl, sc = line_col_from_index(m.start()) el, ec = line_col_from_index(m.end()) - at_edits.append({"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl}) # do not mutate base_text when building atomic spans - elif opx in ("prepend","append"): + elif opx in ("prepend", "append"): if opx == "prepend": sl, sc = 1, 1 - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field}) # prepend can be applied atomically without local mutation else: # Insert at true EOF position (handles both \n and \r\n correctly) eof_idx = len(base_text) sl, sc = line_col_from_index(eof_idx) - new_text = ("\n" if not base_text.endswith("\n") else "") + text_field - at_edits.append({"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) + new_text = ("\n" if not base_text.endswith( + "\n") else "") + text_field + at_edits.append( + {"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text}) # do not mutate base_text when building atomic spans else: return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first") - import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() if at_edits: - params_text: Dict[str, Any] = { + params_text: dict[str, Any] = { "action": "apply_text_edits", "name": name, "path": path, @@ -687,7 +723,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): "precondition_sha256": sha, "options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))} } - resp_text = send_command_with_retry("manage_script", params_text) + resp_text = send_command_with_retry( + "manage_script", params_text) if not (isinstance(resp_text, dict) and resp_text.get("success")): return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first") # Optional sentinel reload removed (deprecated) @@ -698,7 +735,7 @@ def register_manage_script_edits_tools(mcp: FastMCP): opts2 = dict(options or {}) # Prefer debounced background refresh unless explicitly overridden opts2.setdefault("refresh", "debounced") - params_struct: Dict[str, Any] = { + params_struct: dict[str, Any] = { "action": "edit", "name": name, "path": path, @@ -707,7 +744,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): "edits": struct_edits, "options": opts2 } - resp_struct = send_command_with_retry("manage_script", params_struct) + resp_struct = send_command_with_retry( + "manage_script", params_struct) if isinstance(resp_struct, dict) and resp_struct.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first") @@ -717,32 +755,40 @@ def register_manage_script_edits_tools(mcp: FastMCP): # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition # so header guards and validation run on the C# side. # Supported conversions: anchor_insert, replace_range, regex_replace (first match only). - text_ops = { (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() for e in (edits or []) } - structured_kinds = {"replace_class","delete_class","replace_method","delete_method","insert_method","anchor_insert"} + text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get( + "mode") or "").strip().lower() for e in (edits or [])} + structured_kinds = {"replace_class", "delete_class", + "replace_method", "delete_method", "insert_method", "anchor_insert"} if not text_ops.issubset(structured_kinds): # Convert to apply_text_edits payload try: base_text = contents - def line_col_from_index(idx: int) -> Tuple[int, int]: + + def line_col_from_index(idx: int) -> tuple[int, int]: # 1-based line/col against base buffer line = base_text.count("\n", 0, idx) + 1 last_nl = base_text.rfind("\n", 0, idx) - col = (idx - (last_nl + 1)) + 1 if last_nl >= 0 else idx + 1 + col = (idx - (last_nl + 1)) + \ + 1 if last_nl >= 0 else idx + 1 return line, col - at_edits: List[Dict[str, Any]] = [] + at_edits: list[dict[str, Any]] = [] import re as _re for e in edits or []: - op = (e.get("op") or e.get("operation") or e.get("type") or e.get("mode") or "").strip().lower() + op = (e.get("op") or e.get("operation") or e.get( + "type") or e.get("mode") or "").strip().lower() # aliasing for text field - text_field = e.get("text") or e.get("insert") or e.get("content") or "" + text_field = e.get("text") or e.get( + "insert") or e.get("content") or "" if op == "anchor_insert": anchor = e.get("anchor") or "" position = (e.get("position") or "after").lower() # Use improved anchor matching logic with helpful errors, honoring ignore_case try: - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) - m = _find_best_anchor_match(anchor, base_text, flags, prefer_last=True) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) + m = _find_best_anchor_match( + anchor, base_text, flags, prefer_last=True) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text") if not m: @@ -778,19 +824,22 @@ def register_manage_script_edits_tools(mcp: FastMCP): elif op == "regex_replace": pattern = e.get("pattern") or "" repl = text_field - flags = _re.MULTILINE | (_re.IGNORECASE if e.get("ignore_case") else 0) + flags = re.MULTILINE | ( + re.IGNORECASE if e.get("ignore_case") else 0) # Early compile for clearer error messages try: - regex_obj = _re.compile(pattern, flags) + regex_obj = re.compile(pattern, flags) except Exception as ex: return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text") # Use smart anchor matching for consistent behavior with anchor_insert - m = _find_best_anchor_match(pattern, base_text, flags, prefer_last=True) + m = _find_best_anchor_match( + pattern, base_text, flags, prefer_last=True) if not m: continue # Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior) + def _expand_dollars(rep: str, _m=m) -> str: - return _re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) + return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep) repl_expanded = _expand_dollars(repl) # Let C# side handle validation using Unity's built-in compiler services sl, sc = line_col_from_index(m.start()) @@ -809,10 +858,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): if not at_edits: return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text") - # Send to Unity with precondition SHA to enforce guards and immediate refresh - import hashlib sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest() - params: Dict[str, Any] = { + params: dict[str, Any] = { "action": "apply_text_edits", "name": name, "path": path, @@ -830,7 +877,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): if isinstance(resp, dict) and resp.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm( - resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}, + resp if isinstance(resp, dict) else { + "success": False, "message": str(resp)}, normalized_for_echo, routing="text" ) @@ -843,7 +891,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): try: preview_text = _apply_edits_locally(contents, edits) import difflib - diff = list(difflib.unified_diff(contents.splitlines(), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) + diff = list(difflib.unified_diff(contents.splitlines( + ), preview_text.splitlines(), fromfile="before", tofile="after", n=2)) if len(diff) > 800: diff = diff[:800] + ["... (diff truncated) ..."] if preview: @@ -870,7 +919,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): import difflib a = contents.splitlines() b = new_contents.splitlines() - diff = list(difflib.unified_diff(a, b, fromfile="before", tofile="after", n=3)) + diff = list(difflib.unified_diff( + a, b, fromfile="before", tofile="after", n=3)) # Limit diff size to keep responses small if len(diff) > 2000: diff = diff[:2000] + ["... (diff truncated) ..."] @@ -882,7 +932,6 @@ def register_manage_script_edits_tools(mcp: FastMCP): options.setdefault("validate", "standard") options.setdefault("refresh", "debounced") - import hashlib # Compute the SHA of the current file contents for the precondition old_lines = contents.splitlines(keepends=True) end_line = len(old_lines) + 1 # 1-based exclusive end @@ -912,13 +961,8 @@ def register_manage_script_edits_tools(mcp: FastMCP): if isinstance(write_resp, dict) and write_resp.get("success"): pass # Optional sentinel reload removed (deprecated) return _with_norm( - write_resp if isinstance(write_resp, dict) - else {"success": False, "message": str(write_resp)}, + write_resp if isinstance(write_resp, dict) + else {"success": False, "message": str(write_resp)}, normalized_for_echo, routing="text", ) - - - - - # safe_script_edit removed to simplify API; clients should call script_apply_edits directly diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py index abf1d70..e9ccc14 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/manage_shader.py @@ -1,36 +1,26 @@ -from mcp.server.fastmcp import FastMCP, Context -from typing import Dict, Any -from unity_connection import get_unity_connection, send_command_with_retry -from config import config -import time -import os import base64 +from typing import Annotated, Any, Literal +from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_manage_shader_tools(mcp: FastMCP): """Register all shader script management tools with the MCP server.""" - @mcp.tool() + @mcp.tool(name="manage_shader", description="Manages shader scripts in Unity (create, read, update, delete).") @telemetry_tool("manage_shader") def manage_shader( - ctx: Any, - action: str, - name: str, - path: str, - contents: str, - ) -> Dict[str, Any]: - """Manages shader scripts in Unity (create, read, update, delete). - - Args: - action: Operation ('create', 'read', 'update', 'delete'). - name: Shader name (no .cs extension). - path: Asset path (default: "Assets/"). - contents: Shader code for 'create'/'update'. - - Returns: - Dictionary with results ('success', 'message', 'data'). - """ + ctx: Context, + action: Annotated[Literal['create', 'read', 'update', 'delete'], "Perform CRUD operations on shader scripts."], + name: Annotated[str, "Shader name (no .cs extension)"], + path: Annotated[str, "Asset path (default: \"Assets/\")"], + contents: Annotated[str, + "Shader code for 'create'/'update'"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing manage_shader: {action}") try: # Prepare parameters for Unity params = { @@ -38,34 +28,36 @@ def register_manage_shader_tools(mcp: FastMCP): "name": name, "path": path, } - + # Base64 encode the contents if they exist to avoid JSON escaping issues if contents is not None: if action in ['create', 'update']: # Encode content for safer transmission - params["encodedContents"] = base64.b64encode(contents.encode('utf-8')).decode('utf-8') + params["encodedContents"] = base64.b64encode( + contents.encode('utf-8')).decode('utf-8') params["contentsEncoded"] = True else: params["contents"] = contents - + # 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} # Send command via centralized retry helper response = send_command_with_retry("manage_shader", params) - + # Process response from Unity if isinstance(response, dict) and response.get("success"): # If the response contains base64 encoded content, decode it if response.get("data", {}).get("contentsEncoded"): - decoded_contents = base64.b64decode(response["data"]["encodedContents"]).decode('utf-8') + decoded_contents = base64.b64decode( + response["data"]["encodedContents"]).decode('utf-8') response["data"]["contents"] = decoded_contents del response["data"]["encodedContents"] del response["data"]["contentsEncoded"] - + 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)} except Exception as e: # Handle Python-side errors (e.g., connection issues) - return {"success": False, "message": f"Python error managing shader: {str(e)}"} \ No newline at end of file + return {"success": False, "message": f"Python error managing shader: {str(e)}"} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py index 9b39845..c647cf8 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/read_console.py @@ -1,47 +1,34 @@ """ Defines the read_console tool for accessing Unity Editor console messages. """ -from typing import List, Dict, Any -import time +from typing import Annotated, Any, Literal + from mcp.server.fastmcp import FastMCP, Context -from unity_connection import get_unity_connection, send_command_with_retry -from config import config from telemetry_decorator import telemetry_tool +from unity_connection import send_command_with_retry + + def register_read_console_tools(mcp: FastMCP): """Registers the read_console tool with the MCP server.""" - @mcp.tool() + @mcp.tool(name="read_console", description="Gets messages from or clears the Unity Editor console.") @telemetry_tool("read_console") def read_console( ctx: Context, - action: str = None, - types: List[str] = None, - count: Any = None, - filter_text: str = None, - since_timestamp: str = None, - format: str = None, - include_stacktrace: bool = None - ) -> Dict[str, Any]: - """Gets messages from or clears the Unity Editor console. - - Args: - ctx: The MCP context. - action: Operation ('get' or 'clear'). - types: Message types to get ('error', 'warning', 'log', 'all'). - count: Max messages to return. - filter_text: Text filter for messages. - since_timestamp: Get messages after this timestamp (ISO 8601). - format: Output format ('plain', 'detailed', 'json'). - include_stacktrace: Include stack traces in output. - - Returns: - Dictionary with results. For 'get', includes 'data' (messages). - """ - - # Get the connection instance - bridge = get_unity_connection() - + action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."], + types: Annotated[list[Literal['error', 'warning', + 'log', 'all']], "Message types to get"] | None = None, + count: Annotated[int, "Max messages to return"] | None = None, + filter_text: Annotated[str, "Text filter for messages"] | None = None, + since_timestamp: Annotated[str, + "Get messages after this timestamp (ISO 8601)"] | None = None, + format: Annotated[Literal['plain', 'detailed', + 'json'], "Output format"] | None = None, + include_stacktrace: Annotated[bool, + "Include stack traces in output"] | None = None + ) -> dict[str, Any]: + ctx.info(f"Processing read_console: {action}") # Set defaults if values are None action = action if action is not None else 'get' types = types if types is not None else ['error', 'warning', 'log'] @@ -51,7 +38,7 @@ def register_read_console_tools(mcp: FastMCP): # Normalize action if it's a string if isinstance(action, str): action = action.lower() - + # Coerce count defensively (string/float -> int) def _coerce_int(value, default=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') - params_dict = {k: v for k, v in params_dict.items() if v is not None or k == 'count'} - + params_dict = {k: v for k, v in params_dict.items() + if v is not None or k == 'count'} + # Add count back if it was None, explicitly sending null might be important for C# logic if 'count' not in params_dict: - params_dict['count'] = None + params_dict['count'] = None # Use centralized retry helper resp = send_command_with_retry("read_console", params_dict) @@ -99,4 +87,4 @@ def register_read_console_tools(mcp: FastMCP): line.pop("stacktrace", None) except Exception: pass - return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} \ No newline at end of file + return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)} diff --git a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py index 5024fd4..2ae06e8 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py +++ b/UnityMcpBridge/UnityMcpServer~/src/tools/resource_tools.py @@ -3,21 +3,21 @@ Resource wrapper tools so clients that do not expose MCP resources primitives can still list and read files via normal tools. These call into the same safe path logic (re-implemented here to avoid importing server.py). """ - -from typing import Dict, Any, List, Optional -import re -from pathlib import Path -from urllib.parse import urlparse, unquote import fnmatch import hashlib import os +from pathlib import Path +import re +from typing import Annotated, Any +from urllib.parse import urlparse, unquote from mcp.server.fastmcp import FastMCP, Context from telemetry_decorator import telemetry_tool + from unity_connection import send_command_with_retry -def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int] = None) -> Optional[int]: +def _coerce_int(value: Any, default: int | None = None, minimum: int | None = None) -> int | None: """Safely coerce various inputs (str/float/etc.) to an int. Returns default on failure; clamps to minimum when provided. """ @@ -41,6 +41,7 @@ def _coerce_int(value: Any, default: Optional[int] = None, minimum: Optional[int except Exception: return default + def _resolve_project_root(override: str | None) -> Path: # 1) Explicit override if override: @@ -52,14 +53,17 @@ def _resolve_project_root(override: str | None) -> Path: if env: env_path = Path(env).expanduser() # If UNITY_PROJECT_ROOT is relative, resolve against repo root (cwd's repo) instead of src dir - pr = (Path.cwd() / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() + pr = (Path.cwd( + ) / env_path).resolve() if not env_path.is_absolute() else env_path.resolve() if (pr / "Assets").exists(): return pr # 3) Ask Unity via manage_editor.get_project_root try: - resp = send_command_with_retry("manage_editor", {"action": "get_project_root"}) + resp = send_command_with_retry( + "manage_editor", {"action": "get_project_root"}) if isinstance(resp, dict) and resp.get("success"): - pr = Path(resp.get("data", {}).get("projectRoot", "")).expanduser().resolve() + pr = Path(resp.get("data", {}).get( + "projectRoot", "")).expanduser().resolve() if pr and (pr / "Assets").exists(): return pr except Exception: @@ -132,26 +136,17 @@ def _resolve_safe_path_from_uri(uri: str, project: Path) -> Path | None: def register_resource_tools(mcp: FastMCP) -> None: """Registers list_resources and read_resource wrapper tools.""" - @mcp.tool(description=( - "List project URIs (unity://path/...) under a folder (default: Assets).\n\n" - "Args: pattern (glob, default *.cs), under (folder under project root), limit, project_root.\n" - "Security: restricted to Assets/ subtree; symlinks are resolved and must remain under Assets/.\n" - "Notes: Only .cs files are returned by default; always appends unity://spec/script-edits.\n" - )) + @mcp.tool(name="list_resources", description=("List project URIs (unity://path/...) under a folder (default: Assets). Only .cs files are returned by default; always appends unity://spec/script-edits.\n")) @telemetry_tool("list_resources") async def list_resources( - ctx: Optional[Context] = None, - pattern: Optional[str] = "*.cs", - under: str = "Assets", - limit: Any = 200, - project_root: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Lists project URIs (unity://path/...) under a folder (default: Assets). - - pattern: glob like *.cs or *.shader (None to list all files) - - under: relative folder under project root - - limit: max results - """ + ctx: Context, + pattern: Annotated[str, "Glob, default is *.cs"] | None = "*.cs", + under: Annotated[str, + "Folder under project root, default is Assets"] = "Assets", + limit: Annotated[int, "Page limit"] = 200, + project_root: Annotated[str, "Project path"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing list_resources: {pattern}") try: project = _resolve_project_root(project_root) base = (project / under).resolve() @@ -165,7 +160,7 @@ def register_resource_tools(mcp: FastMCP) -> None: except ValueError: return {"success": False, "error": "Listing is restricted to Assets/"} - matches: List[str] = [] + matches: list[str] = [] limit_int = _coerce_int(limit, default=200, minimum=1) for p in base.rglob("*"): if not p.is_file(): @@ -194,33 +189,30 @@ def register_resource_tools(mcp: FastMCP) -> None: except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool(description=( - "Read a resource by unity://path/... URI with optional slicing.\n\n" - "Args: uri, start_line/line_count or head_bytes, tail_lines (optional), project_root, request (NL hints).\n" - "Security: uri must resolve under Assets/.\n" - "Examples: head_bytes=1024; start_line=100,line_count=40; tail_lines=120.\n" - )) + @mcp.tool(name="read_resource", description=("Reads a resource by unity://path/... URI with optional slicing.")) @telemetry_tool("read_resource") async def read_resource( - uri: str, - ctx: Optional[Context] = None, - start_line: Any = None, - line_count: Any = None, - head_bytes: Any = None, - tail_lines: Any = None, - project_root: Optional[str] = None, - request: Optional[str] = None, - ) -> Dict[str, Any]: - """ - Reads a resource by unity://path/... URI with optional slicing. - One of line window (start_line/line_count) or head_bytes can be used to limit size. - """ + ctx: Context, + uri: Annotated[str, "The resource URI to read under Assets/"], + start_line: Annotated[int, + "The starting line number (0-based)"] | None = None, + line_count: Annotated[int, + "The number of lines to read"] | None = None, + head_bytes: Annotated[int, + "The number of bytes to read from the start of the file"] | None = None, + tail_lines: Annotated[int, + "The number of lines to read from the end of the file"] | None = None, + project_root: Annotated[str, + "The project root directory"] | None = None, + request: Annotated[str, "The request ID"] | None = None, + ) -> dict[str, Any]: + ctx.info(f"Processing read_resource: {uri}") try: # Serve the canonical spec directly when requested (allow bare or with scheme) if uri in ("unity://spec/script-edits", "spec/script-edits", "script-edits"): spec_json = ( '{\n' - ' "name": "Unity MCP — Script Edits v1",\n' + ' "name": "Unity MCP - Script Edits v1",\n' ' "target_tool": "script_apply_edits",\n' ' "canonical_rules": {\n' ' "always_use": ["op","className","methodName","replacement","afterMethodName","beforeMethodName"],\n' @@ -300,14 +292,16 @@ def register_resource_tools(mcp: FastMCP) -> None: m = re.search(r"first\s+(\d+)\s*bytes", req) if m: head_bytes = int(m.group(1)) - m = re.search(r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) + m = re.search( + r"show\s+(\d+)\s+lines\s+around\s+([A-Za-z_][A-Za-z0-9_]*)", req) if m: window = int(m.group(1)) method = m.group(2) # naive search for method header to get a line number text_all = p.read_text(encoding="utf-8") lines_all = text_all.splitlines() - pat = re.compile(rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) + pat = re.compile( + rf"^\s*(?:\[[^\]]+\]\s*)*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial).*?\b{re.escape(method)}\s*\(", re.MULTILINE) hit_line = None for i, line in enumerate(lines_all, start=1): if pat.search(line): @@ -329,7 +323,8 @@ def register_resource_tools(mcp: FastMCP) -> None: full_sha = hashlib.sha256(full_bytes).hexdigest() # Selection only when explicitly requested via windowing args or request text hints - selection_requested = bool(head_bytes or tail_lines or (start_line is not None and line_count is not None) or request) + selection_requested = bool(head_bytes or tail_lines or ( + start_line is not None and line_count is not None) or request) if selection_requested: # Mutually exclusive windowing options precedence: # 1) head_bytes, 2) tail_lines, 3) start_line+line_count, else full text @@ -354,24 +349,19 @@ def register_resource_tools(mcp: FastMCP) -> None: except Exception as e: return {"success": False, "error": str(e)} - @mcp.tool() + @mcp.tool(name="find_in_file", description="Searches a file with a regex pattern and returns line numbers and excerpts.") @telemetry_tool("find_in_file") async def find_in_file( - uri: str, - pattern: str, - ctx: Optional[Context] = None, - ignore_case: Optional[bool] = True, - project_root: Optional[str] = None, - max_results: Any = 200, - ) -> Dict[str, Any]: - """ - Searches a file with a regex pattern and returns line numbers and excerpts. - - uri: unity://path/Assets/... or file path form supported by read_resource - - pattern: regular expression (Python re) - - ignore_case: case-insensitive by default - - max_results: cap results to avoid huge payloads - """ - # re is already imported at module level + ctx: Context, + uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], + pattern: Annotated[str, "The regex pattern to search for"], + ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, + project_root: Annotated[str, + "The project root directory"] | None = None, + max_results: Annotated[int, + "Cap results to avoid huge payloads"] = 200, + ) -> dict[str, Any]: + ctx.info(f"Processing find_in_file: {uri}") try: project = _resolve_project_root(project_root) p = _resolve_safe_path_from_uri(uri, project) @@ -404,5 +394,3 @@ def register_resource_tools(mcp: FastMCP) -> None: return {"success": True, "data": {"matches": results, "count": len(results)}} except Exception as e: return {"success": False, "error": str(e)} - - diff --git a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py index f41b7a2..f688da3 100644 --- a/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py +++ b/UnityMcpBridge/UnityMcpServer~/src/unity_connection.py @@ -1,17 +1,18 @@ +from config import config import contextlib +from dataclasses import dataclass import errno import json import logging +from pathlib import Path +from port_discovery import PortDiscovery import random import socket import struct import threading import time -from dataclasses import dataclass -from pathlib import Path from typing import Any, Dict -from config import config -from port_discovery import PortDiscovery + # Configure logging using settings from config logging.basicConfig( @@ -26,6 +27,7 @@ _connection_lock = threading.Lock() # Maximum allowed framed payload size (64 MiB) FRAMED_MAX = 64 * 1024 * 1024 + @dataclass class UnityConnection: """Manages the socket connection to the Unity Editor.""" @@ -33,7 +35,7 @@ class UnityConnection: port: int = None # Will be set dynamically sock: socket.socket = None # Socket for Unity communication use_framing: bool = False # Negotiated per-connection - + def __post_init__(self): """Set port from discovery if not explicitly provided""" if self.port is None: @@ -50,11 +52,14 @@ class UnityConnection: return True try: # Bounded connect to avoid indefinite blocking - connect_timeout = float(getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) - self.sock = socket.create_connection((self.host, self.port), connect_timeout) + connect_timeout = float( + getattr(config, "connect_timeout", getattr(config, "connection_timeout", 1.0))) + self.sock = socket.create_connection( + (self.host, self.port), connect_timeout) # Disable Nagle's algorithm to reduce small RPC latency with contextlib.suppress(Exception): - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.sock.setsockopt( + socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) logger.debug(f"Connected to Unity at {self.host}:{self.port}") # Strict handshake: require FRAMING=1 @@ -78,16 +83,20 @@ class UnityConnection: if 'FRAMING=1' in text: self.use_framing = True - logger.debug('Unity MCP handshake received: FRAMING=1 (strict)') + logger.debug( + 'Unity MCP handshake received: FRAMING=1 (strict)') else: if require_framing: # Best-effort plain-text advisory for legacy peers with contextlib.suppress(Exception): - self.sock.sendall(b'Unity MCP requires FRAMING=1\n') - raise ConnectionError(f'Unity MCP requires FRAMING=1, got: {text!r}') + self.sock.sendall( + b'Unity MCP requires FRAMING=1\n') + raise ConnectionError( + f'Unity MCP requires FRAMING=1, got: {text!r}') else: self.use_framing = False - logger.warning('Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') + logger.warning( + 'Unity MCP handshake missing FRAMING=1; proceeding in legacy mode by configuration') finally: self.sock.settimeout(config.connection_timeout) return True @@ -116,7 +125,8 @@ class UnityConnection: while len(data) < count: chunk = sock.recv(count - len(data)) if not chunk: - raise ConnectionError("Connection closed before reading expected bytes") + raise ConnectionError( + "Connection closed before reading expected bytes") data.extend(chunk) return bytes(data) @@ -136,13 +146,16 @@ class UnityConnection: heartbeat_count += 1 if heartbeat_count >= getattr(config, 'max_heartbeat_frames', 16) or time.monotonic() > deadline: # Treat as empty successful response to match C# server behavior - logger.debug("Heartbeat threshold reached; returning empty response") + logger.debug( + "Heartbeat threshold reached; returning empty response") return b"" continue if payload_len > FRAMED_MAX: - raise ValueError(f"Invalid framed length: {payload_len}") + raise ValueError( + f"Invalid framed length: {payload_len}") payload = self._read_exact(sock, payload_len) - logger.debug(f"Received framed response ({len(payload)} bytes)") + logger.debug( + f"Received framed response ({len(payload)} bytes)") return payload except socket.timeout as e: logger.warning("Socket timeout during framed receive") @@ -158,21 +171,22 @@ class UnityConnection: chunk = sock.recv(buffer_size) if not chunk: if not chunks: - raise Exception("Connection closed before receiving data") + raise Exception( + "Connection closed before receiving data") break chunks.append(chunk) - + # Process the data received so far data = b''.join(chunks) decoded_data = data.decode('utf-8') - + # Check if we've received a complete response try: # Special case for ping-pong if decoded_data.strip().startswith('{"status":"success","result":{"message":"pong"'): logger.debug("Received ping response") return data - + # Handle escaped quotes in the content if '"content":' in decoded_data: # Find the content field and its value @@ -182,19 +196,22 @@ class UnityConnection: # Replace escaped quotes in content with regular quotes content = decoded_data[content_start:content_end] content = content.replace('\\"', '"') - decoded_data = decoded_data[:content_start] + content + decoded_data[content_end:] - + decoded_data = decoded_data[:content_start] + \ + content + decoded_data[content_end:] + # Validate JSON format json.loads(decoded_data) - + # If we get here, we have valid JSON - logger.info(f"Received complete response ({len(data)} bytes)") + logger.info( + f"Received complete response ({len(data)} bytes)") return data except json.JSONDecodeError: # We haven't received a complete valid JSON response yet continue except Exception as e: - logger.warning(f"Error processing response chunk: {str(e)}") + logger.warning( + f"Error processing response chunk: {str(e)}") # Continue reading more chunks as this might not be the complete response continue except socket.timeout: @@ -217,7 +234,8 @@ class UnityConnection: def read_status_file() -> dict | None: try: - status_files = sorted(Path.home().joinpath('.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) + status_files = sorted(Path.home().joinpath( + '.unity-mcp').glob('unity-mcp-status-*.json'), key=lambda p: p.stat().st_mtime, reverse=True) if not status_files: return None latest = status_files[0] @@ -253,7 +271,8 @@ class UnityConnection: payload = b'ping' else: command = {"type": command_type, "params": params or {}} - payload = json.dumps(command, ensure_ascii=False).encode('utf-8') + payload = json.dumps( + command, ensure_ascii=False).encode('utf-8') # Send/receive are serialized to protect the shared socket with self._io_lock: @@ -280,7 +299,8 @@ class UnityConnection: try: response_data = self.receive_full_response(self.sock) with contextlib.suppress(Exception): - logger.debug("recv %d bytes; mode=%s", len(response_data), mode) + logger.debug("recv %d bytes; mode=%s", + len(response_data), mode) finally: if restore_timeout is not None: self.sock.settimeout(restore_timeout) @@ -295,11 +315,13 @@ class UnityConnection: resp = json.loads(response_data.decode('utf-8')) if resp.get('status') == 'error': - err = resp.get('error') or resp.get('message', 'Unknown Unity error') + err = resp.get('error') or resp.get( + 'message', 'Unknown Unity error') raise Exception(err) return resp.get('result', {}) except Exception as e: - logger.warning(f"Unity communication attempt {attempt+1} failed: {e}") + logger.warning( + f"Unity communication attempt {attempt+1} failed: {e}") try: if self.sock: self.sock.close() @@ -310,7 +332,8 @@ class UnityConnection: try: new_port = PortDiscovery.discover_unity_port() if new_port != self.port: - logger.info(f"Unity port changed {self.port} -> {new_port}") + logger.info( + f"Unity port changed {self.port} -> {new_port}") self.port = new_port except Exception as de: logger.debug(f"Port discovery failed: {de}") @@ -324,11 +347,13 @@ class UnityConnection: jitter = random.uniform(0.1, 0.3) # Fast‑retry for transient socket failures - fast_error = isinstance(e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) + fast_error = isinstance( + e, (ConnectionRefusedError, ConnectionResetError, TimeoutError)) if not fast_error: try: err_no = getattr(e, 'errno', None) - fast_error = err_no in (errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) + fast_error = err_no in ( + errno.ECONNREFUSED, errno.ECONNRESET, errno.ETIMEDOUT) except Exception: pass @@ -345,9 +370,11 @@ class UnityConnection: continue raise + # Global Unity connection _unity_connection = None + def get_unity_connection() -> UnityConnection: """Retrieve or establish a persistent Unity connection. @@ -366,7 +393,8 @@ def get_unity_connection() -> UnityConnection: _unity_connection = UnityConnection() if not _unity_connection.connect(): _unity_connection = None - raise ConnectionError("Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") + raise ConnectionError( + "Could not connect to Unity. Ensure the Unity Editor and MCP Bridge are running.") logger.info("Connected to Unity on startup") return _unity_connection @@ -400,7 +428,8 @@ def send_command_with_retry(command_type: str, params: Dict[str, Any], *, max_re response = conn.send_command(command_type, params) retries = 0 while _is_reloading_response(response) and retries < max_retries: - delay_ms = int(response.get("retry_after_ms", retry_ms)) if isinstance(response, dict) else retry_ms + delay_ms = int(response.get("retry_after_ms", retry_ms) + ) if isinstance(response, dict) else retry_ms time.sleep(max(0.0, delay_ms / 1000.0)) retries += 1 response = conn.send_command(command_type, params) @@ -415,7 +444,8 @@ async def async_send_command_with_retry(command_type: str, params: Dict[str, Any loop = asyncio.get_running_loop() return await loop.run_in_executor( None, - lambda: send_command_with_retry(command_type, params, max_retries=max_retries, retry_ms=retry_ms), + lambda: send_command_with_retry( + command_type, params, max_retries=max_retries, retry_ms=retry_ms), ) except Exception as e: # Return a structured error dict for consistency with other responses diff --git a/UnityMcpBridge/package.json b/UnityMcpBridge/package.json index 59db094..66a2dfd 100644 --- a/UnityMcpBridge/package.json +++ b/UnityMcpBridge/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "3.4.0", + "version": "4.1.0", "displayName": "MCP for Unity", "description": "A bridge that connects an LLM to Unity via the MCP (Model Context Protocol). This allows MCP Clients like Claude Desktop or Cursor to directly control your Unity Editor.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3",