From 1896bc20181c26cdffc764cca486790c9ba6cb13 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Sun, 30 Aug 2026 03:59:38 +0800 Subject: [PATCH 1/2] feat: add built-in MCP server --- docs/MCP.md | 265 +++++ docs/MCP_SECURITY.md | 201 ++++ src/Config/ConfigManager.cs | 80 +- src/ExplorerBehaviour.cs | 4 + src/ExplorerCore.cs | 2 + src/MCP/McpManager.cs | 817 ++++++++++++++ src/MCP/Runtime/McpGameCapabilityExecutor.cs | 280 +++++ src/MCP/Runtime/McpGameInvokeCommand.cs | 50 + src/MCP/Runtime/McpGameLifecycleCommands.cs | 60 ++ src/MCP/Runtime/McpGameMutationCommands.cs | 51 + src/MCP/Runtime/McpGameQueryCommands.cs | 74 ++ src/MCP/Runtime/McpJsonValue.cs | 371 +++++++ src/MCP/Runtime/McpMemberPath.cs | 239 +++++ src/MCP/Runtime/McpNativeProtocolHandler.cs | 307 ++++++ src/MCP/Runtime/McpObjectRegistry.cs | 147 +++ src/MCP/Runtime/McpValueCodec.cs | 462 ++++++++ src/MCP/Transport/IMcpTransport.cs | 105 ++ src/MCP/Transport/JsonWire.cs | 377 +++++++ src/MCP/Transport/McpHttpBridge.cs | 934 ++++++++++++++++ src/MCP/Transport/McpHttpTransportMode.cs | 163 +++ src/MCP/Transport/McpMessages.cs | 89 ++ src/MCP/Transport/McpRequestRouter.cs | 76 ++ src/MCP/Transport/McpTransportMode.cs | 16 + src/UI/Panels/McpPanel.cs | 1014 ++++++++++++++++++ src/UI/UIManager.cs | 4 +- 25 files changed, 6186 insertions(+), 2 deletions(-) create mode 100644 docs/MCP.md create mode 100644 docs/MCP_SECURITY.md create mode 100644 src/MCP/McpManager.cs create mode 100644 src/MCP/Runtime/McpGameCapabilityExecutor.cs create mode 100644 src/MCP/Runtime/McpGameInvokeCommand.cs create mode 100644 src/MCP/Runtime/McpGameLifecycleCommands.cs create mode 100644 src/MCP/Runtime/McpGameMutationCommands.cs create mode 100644 src/MCP/Runtime/McpGameQueryCommands.cs create mode 100644 src/MCP/Runtime/McpJsonValue.cs create mode 100644 src/MCP/Runtime/McpMemberPath.cs create mode 100644 src/MCP/Runtime/McpNativeProtocolHandler.cs create mode 100644 src/MCP/Runtime/McpObjectRegistry.cs create mode 100644 src/MCP/Runtime/McpValueCodec.cs create mode 100644 src/MCP/Transport/IMcpTransport.cs create mode 100644 src/MCP/Transport/JsonWire.cs create mode 100644 src/MCP/Transport/McpHttpBridge.cs create mode 100644 src/MCP/Transport/McpHttpTransportMode.cs create mode 100644 src/MCP/Transport/McpMessages.cs create mode 100644 src/MCP/Transport/McpRequestRouter.cs create mode 100644 src/MCP/Transport/McpTransportMode.cs create mode 100644 src/UI/Panels/McpPanel.cs diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 000000000..fdd5732c4 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,265 @@ +# Cinematic Unity Explorer MCP + +本文说明 Cinematic Unity Explorer(CUE)的 Agent/MCP 集成:架构、启动方式、客户端配置、工具发现、安全开关、运行时限制与故障排查。安全设计与威胁模型见 [MCP_SECURITY.md](MCP_SECURITY.md)。 + +> MCP 能力直接操作正在运行的游戏。先在离线、可恢复的测试存档中验证;不要对不受信任的游戏、服务器或 MCP 客户端开放写权限。 + +## 1. 架构 + +```text +MCP 客户端 / Agent + │ SSE 或 Streamable HTTP(JSON-RPC 2.0) + ▼ +CUE DLL 内置 MCP Server ── 请求队列 ── Unity 主线程 Pump + │ + ├─ 场景、GameObject、Component、Transform + ├─ 反射字段/属性读取与受控写入、方法调用 + └─ CUE 的相机、灯光、动画、截图等能力(按构建和运行时提供) +``` + +MCP Server、协议处理和游戏能力执行器均位于 CUE DLL 内,不需要启动 Node.js、stdio 适配器或其他外部转发进程。 + +各层职责: + +- **MCP 客户端**:通过 SSE 或 Streamable HTTP 调用标准 MCP 方法 `initialize`、`tools/list`、`tools/call`,并负责向用户展示工具审批。 +- **DLL 内置 MCP Server**:只监听回环地址,处理 MCP 会话和 JSON-RPC,校验 token、请求大小、队列容量与安全模式,然后把请求排队。 +- **Unity 主线程执行器**:在 Unity `Update`/主线程钩子中泵出请求。任何 UnityEngine 对象访问、场景操作和多数反射调用都必须在这里执行,不能在网络工作线程执行。 +- **MCP UI**:配置启停、传输模式、端口、token、只读模式和危险操作开关;显示监听状态、最近错误、连接地址以及可复制的客户端配置。UI 中显示的实际值高于本文示例值。 + +传输模式: + +- **SSE**:兼容使用旧版 HTTP+SSE MCP 传输的客户端。客户端对 MCP 地址建立事件流,并通过服务端公布的消息端点发送请求。 +- **Streamable HTTP**:使用同一 MCP 地址完成标准 HTTP 请求;当前 DLL 实现采用无状态模式,JSON-RPC request 直接返回 JSON,notification 返回 `202 Accepted`,不会强制生成 `Mcp-Session-Id`。 + +两种模式都使用 JSON-RPC 2.0,且任一时刻由 MCP UI 选择一种模式。切换后应停止并重新启动 MCP Server,再让客户端重连。对象引用通常只在当前进程/场景期间有效;切换场景、销毁对象或 Domain Reload 后,客户端必须重新查询对象,不能长期缓存旧句柄。 + +## 2. 前置条件与启动 + +1. 按项目原有方式安装并启动包含 MCP 功能的 CUE DLL。 +2. 打开 CUE 的 **MCP 设置 UI**,选择传输模式: + - 客户端标注为 `SSE` 时选择 **SSE**; + - 客户端标注为 `Streamable HTTP`、`HTTP` 或新版远程 MCP 时选择 **Streamable HTTP**。 +3. 首次使用保持: + - 监听地址:`127.0.0.1`; + - **只读模式:开启**; + - **危险操作:关闭**; + - token:使用 UI 生成的高熵随机值; + - 端口与路径:以 UI 显示值为准,默认示例为 `http://127.0.0.1:17891/mcp`。 +4. 启动 MCP Server;如果修改了传输模式、端口、路径或 token,先重启 Server,再重连客户端。 +5. 在 MCP 客户端中直接填写 UI 提供的 URL 和鉴权头。**不需要安装 Node.js、stdio 适配器或其他外部转发程序。** + +不要把真实 token 提交到 Git、聊天记录、截图或公开日志。客户端请求使用: + +```http +Authorization: Bearer REPLACE_WITH_TOKEN_FROM_CUE_UI +``` + +## 3. 配置 MCP 客户端 + +优先使用 MCP UI 的“复制配置”结果,因为它会反映当前传输模式、端口、路径和 token。以下示例假定地址为 `http://127.0.0.1:17891/mcp`。 + +### 3.1 Trae:SSE + +在 CUE MCP UI 中选择 **SSE**,启动或重启 Server,然后在 Trae 的用户级或项目级 MCP 配置中直接添加 URL: + +```json +{ + "mcpServers": { + "cue-mcp": { + "type": "sse", + "url": "http://127.0.0.1:17891/mcp", + "headers": { + "Authorization": "Bearer REPLACE_WITH_TOKEN_FROM_CUE_UI" + } + } + } +} +``` + +Trae 日志显示 `sse type` 时,CUE UI 也必须选择 **SSE**。成功连接时,`GET /mcp` 应返回 `200 OK` 和 `Content-Type: text/event-stream`,而不是 `405 Method Not Allowed`。 + +### 3.2 Trae:Streamable HTTP + +如果当前 Trae 版本支持 Streamable HTTP,在 CUE MCP UI 中选择 **Streamable HTTP**,重启 Server,并在 Trae 中选择对应的传输类型。支持显式 `type` 字段的配置可写为: + +```json +{ + "mcpServers": { + "cue-mcp": { + "type": "streamableHttp", + "url": "http://127.0.0.1:17891/mcp", + "headers": { + "Authorization": "Bearer REPLACE_WITH_TOKEN_FROM_CUE_UI" + } + } + } +} +``` + +不同 Trae 版本可能由 UI 保存传输类型,而不接受手写 `type`。这种情况下,在 Trae 的“添加 MCP”界面选择 **Streamable HTTP**,只填写 URL 和请求头即可;以 Trae 实际生成的配置格式及 CUE MCP UI 的复制结果为准。 + +当前实现是协议允许的无状态 Streamable HTTP:`POST /mcp` 处理请求;`GET /mcp` 与 `DELETE /mcp` 返回 `405`;初始化响应可以不包含 `Mcp-Session-Id`。客户端不得把缺少 session header 当作连接失败。 + +### 3.3 切换传输 + +切换流程:停止 MCP Server → 在滚动布局的 MCP 设置 UI 中切换 **SSE / Streamable HTTP** → 重新启动 Server → 在 Trae 中使用匹配的传输类型重连。客户端与 DLL 模式不匹配时,常见表现为 HTTP `405`、连接立即关闭或初始化超时。 + + + +## 4. 工具清单与发现 + +`tools/list` 的返回值是**唯一权威清单**。可用工具会因 CUE 构建、Mono/IL2CPP、当前场景、已加载程序集、安全模式及游戏组件而变化。Agent 在会话开始、切换场景后,以及安全设置变化后都应重新调用 `tools/list`。 + +标准 MCP 层至少使用: + +| MCP 方法 | 用途 | 是否修改游戏 | +|---|---|---:| +| `initialize` | 协商协议版本和服务能力 | 否 | +| `notifications/initialized` | 完成初始化通知 | 否 | +| `tools/list` | 动态取得名称、说明与 JSON Schema | 否 | +| `tools/call` | 调用一个已发现工具 | 取决于工具 | + +当前通用工具集如下;最终以 Agent 实际收到的 `tools/list` 为准: + +| 工具/能力 | 作用 | 权限级别 | +|---|---|---| +| `get_status` | 读取 Bridge、游戏、Unity、场景和 capability 元数据 | R0,只读、幂等 | +| `list_scenes` | 列出当前已加载场景 | R1,只读、幂等 | +| `search_objects` | 按名称、路径、类型和场景搜索,返回会话内对象句柄 | R1,只读、幂等 | +| `get_object` | 读取对象快照、成员和 Unity 对象摘要 | R1,只读;getter 仍可能有副作用 | +| `get_member` / `game.get_member` | 读取字段、属性以及 `items[0].value` 形式的嵌套路径 | R1,只读 | +| `list_methods` / `game.list_methods` | 枚举可调用方法、签名、参数和返回类型 | R1,只读 | +| `set_member` | 设置字段、属性或嵌套 member path,并执行 JSON 到 CLR/Unity 类型转换 | W1;要求关闭只读模式 | +| `set_transform` / `game.set_transform` | 修改位置、旋转、缩放或父对象 | W1;要求关闭只读模式 | +| `set_enabled` / `game.set_enabled` | 启用/禁用 GameObject 或支持 `enabled` 的组件 | W1;要求关闭只读模式 | +| `invoke_method` | 调用实例/静态方法并解析参数和重载 | D2;要求关闭只读并开启危险操作 | +| `create_object` / `game.create` | 创建 GameObject、Primitive 或添加组件 | D1/D2;要求关闭只读并开启危险操作 | +| `destroy_object` / `game.destroy` | 销毁对象,可包含立即销毁 | D1;要求关闭只读并开启危险操作 | +| `execute_batch` | 按顺序执行多项查询或修改;每个子操作独立做权限检查 | 取决于最危险的子操作 | +| `game.execute` | 游戏内通用命令入口;供高级客户端转发 Runtime 命令 | 取决于内部命令;不能绕过策略 | + +部分高级命令可能仅作为游戏内 Bridge 方法存在,而未作为独立的标准 MCP tool 发布;普通 Agent 不应猜测名称,应只调用 `tools/list` 返回的工具。扩展能力(相机、灯光、动画、截图等)同样只有在实际出现在 `tools/list` 时才可用。 + +### 4.1 权限矩阵 + +| 操作 | 只读开启 | 只读关闭、危险关闭 | 只读关闭、危险开启 | +|---|---:|---:|---:| +| 状态、场景、搜索、对象/成员读取 | 允许 | 允许 | 允许 | +| 字段/属性、Transform、enabled 修改 | 拒绝 | 允许 | 允许 | +| 方法调用、创建、销毁 | 拒绝 | 拒绝 | 允许 | +| 批处理 | 仅允许全部为只读子操作 | 允许只读与普通写入 | 允许全部,但仍逐项校验 | + +MCP annotation 只是给 Agent 的提示,真正权限由游戏内 Runtime 强制检查。将工具加入客户端 allowlist、关闭客户端审批或通过 `execute_batch` 包装,都不能绕过 `MCP_Read_Only` 与 `MCP_Allow_Dangerous_Operations`。 + +### 4.2 批处理不是事务 + +`execute_batch` 默认是**有序、非原子**执行:前面的操作成功后,后面的操作可能失败。`stop_on_error=true` 只停止尚未开始的后续项,不会撤销已完成项;`stop_on_error=false` 则继续并返回逐项结果。即使 schema 接受 `atomic=true`,它也只是请求 Bridge 提供事务语义;当前 Unity 对象修改、方法调用、创建/销毁和场景副作用通常无法可靠回滚,客户端必须按“不保证原子”处理。 + +因此危险批处理应先查询并展示计划,执行后逐项读取验证。请求超时也不代表尚未执行,禁止自动重试包含调用、创建或销毁的批次。 + +建议 Agent 的调用流程: + +1. `tools/list`,只选择当前返回的工具。 +2. 先用查询工具缩小范围,显示对象名称、类型、场景和稳定标识。 +3. 写操作前重新读取目标并展示差异。 +4. 对销毁、移除组件、加载场景、任意反射调用、文件写入等操作取得用户明确批准。 +5. 写后读取验证;失败时不要盲目重试非幂等操作。 + +## 5. 安全设置 + +### 5.1 localhost 是强制边界 + +Bridge 应只绑定 `127.0.0.1`(必要时同时绑定 `::1`),不能绑定 `0.0.0.0`、局域网地址或公网地址。回环限制并不等于鉴权:本机其他进程、浏览器扩展、恶意模组和同一用户会话仍可能访问端口,因此必须同时使用 token。 + +如确需跨机器控制,应在 Bridge 保持回环监听,并使用具有身份认证与加密的隧道把远端端口映射到本机;不要直接开放游戏端口。远端 MCP 客户端会显著放大 token 泄露、重放和延迟超时风险。 + +### 5.2 token + +- 每次安装或敏感会话生成至少 128 bit 随机 token;不要使用游戏名、端口或短密码。 +- 推荐使用 `Authorization: Bearer `;实际头名必须和 MCP UI/Bridge 配置一致。 +- token 校验应覆盖 RPC 端点;健康端点也可配置为要求 token。 +- token 不应出现在 URL、命令行参数、stdout、普通日志、崩溃报告或版本库中。 +- 怀疑泄露时立即停止 MCP Server、轮换 token,并重新连接客户端。 + +### 5.3 只读模式 + +只读模式开启时,只允许纯查询和序列化。以下行为即使表面上是“读取”也应拒绝: + +- 属性 getter、`ToString()` 或方法调用可能执行用户代码并产生副作用; +- 自动加载资源、场景或程序集; +- 触发惰性集合枚举、网络请求、保存、截图或文件导出; +- 通过反射取得可变对象后进行间接写入。 + +因此只读执行器应使用显式 allowlist,而不是仅按工具名称中是否有 `get/list` 判断。 + +### 5.4 危险操作 + +建议使用两级开关: + +- **写入允许**:可设置经过 schema 校验的简单字段/属性和 Transform 等可恢复状态。 +- **危险操作允许**:方法调用、对象/组件销毁、场景加载卸载、任意类型实例化、静态成员写入、文件访问、执行代码或影响全局游戏状态。 + +危险开关默认关闭,并在每次启动时恢复关闭。UI 应醒目显示当前状态。客户端允许调用不代表用户已同意具体操作;Agent 仍应逐次解释目标、影响和恢复方法,并请求确认。 + +## 6. 运行时限制 + +### IL2CPP + +- IL2CPP 已裁剪或未生成包装的类型、泛型实例、方法和元数据可能无法发现或调用。 +- 某些反射 API 与 Mono 表现不同;参数封送、枚举、值类型、委托、重载选择和异常信息可能不完整。 +- Native 对象已销毁但托管包装仍存在时,会出现“假非空”对象;执行前后都要校验 Unity 对象存活状态。 +- 不要承诺“任意内容都能改”。原生插件、GPU/Shader 内部状态、反作弊保护、未暴露或被裁剪代码可能不可访问。 + +### 反射 + +- 字段读取通常比属性 getter 和方法调用安全;getter/`ToString()` 也可能改变游戏状态或卡死。 +- 重载解析必须使用完整类型信息,避免仅按方法名选择。 +- 循环对象图、超大集合和递归属性会造成响应爆炸;需要深度、数量、字符串长度和响应体上限。 +- 静态状态、单例和跨场景对象影响范围更大,应按危险操作处理。 +- 反射黑名单和游戏自身的崩溃规避规则仍然适用,MCP 不应绕过它们。 + +### Unity 主线程 + +- UnityEngine API 通常不是线程安全的。网络线程只能解析、鉴权和排队;实际查询/修改必须在 Unity 主线程执行。 +- 游戏暂停、加载画面、低帧率或主线程阻塞会导致超时。客户端超时不表示操作一定未执行,尤其不能自动重试创建、销毁、调用方法等非幂等请求。 +- 每帧应限制处理数量;大批量编辑要分批,并允许取消或在步骤间验证。 +- 场景切换会使对象句柄失效;切换后重新发现对象。 + +## 7. 协议 smoke test + +最直接的只读 smoke test 是让 Trae 连接 DLL 内置 MCP Server,并依次完成 `initialize`、`tools/list`、`get_status`。测试期间保持只读模式开启、危险操作关闭。 + +### SSE + +1. 在 MCP UI 选择 **SSE** 并重启 Server。 +2. 在 Trae 选择 SSE,配置 UI 显示的 URL 和 `Authorization: Bearer ...`。 +3. 确认事件流握手返回 `200 OK`、`Content-Type: text/event-stream`,随后初始化和 `tools/list` 成功。 +4. 断开并重连一次,确认旧会话不会接收新响应。 + +### Streamable HTTP + +1. 在 MCP UI 选择 **Streamable HTTP** 并重启 Server。 +2. 在 Trae 选择 Streamable HTTP,使用同一 URL 与鉴权头。 +3. 确认 `initialize`、`tools/list` 和 `get_status` 返回合法 JSON-RPC 响应;如果服务器分配会话 ID,后续请求应沿用该会话。 +4. 发送只读 notification 或断开重连,确认客户端不会把无响应通知误判为失败,也不会重复执行请求。 + +仓库中的 `scripts/Test-McpBridge.ps1` 与 `scripts/Test-McpReadOnlyFlow.ps1` 可继续作为低层只读诊断工具,但它们是否覆盖当前传输模式取决于脚本版本。若脚本只实现普通 HTTP POST,它不能代替 SSE 握手测试。最终应以真实 MCP 客户端完成的 `initialize`、`tools/list` 和只读工具调用为准。 + +## 8. 故障排查 + +| 现象 | 检查与处理 | +|---|---| +| `ECONNREFUSED` / 无法连接 | 游戏是否运行、Bridge 是否启用;URL、端口、`/mcp` 路径是否与 UI 一致;是否被防火墙或安全软件阻止。 | +| HTTP 401/403 | token 或鉴权头不一致;token 是否含多余空格;轮换 token 后是否重启了 DLL 内置 MCP Server 并更新客户端配置。 | +| HTTP 404/405 | URL/路径错误,或客户端传输类型与 CUE UI 不一致。SSE 模式需要允许事件流 GET;Streamable HTTP 按该模式处理 MCP HTTP 请求。 | +| HTTP 413 / 响应过大 | 请求体或响应超过限制;缩小查询范围、分页、降低序列化深度,不要简单提高到无限。 | +| HTTP 429/503 / 队列已满 | Agent 并发过高或 Unity 主线程未泵;降低并发、等待游戏恢复,不要并发执行写操作。 | +| 超时但游戏随后变化 | 主线程在加载或卡顿,请先读取验证;不要自动重试非幂等调用。 | +| `Method not found` / 工具消失 | 重新调用 `tools/list`;确认当前构建、场景、运行时和安全模式支持该工具。 | +| 对象不存在 / stale handle | 对象被销毁或场景已变化;重新搜索,使用场景、层级路径和类型再次确认。 | +| Mono 可用、IL2CPP 失败 | 检查类型是否被裁剪、包装是否生成、参数是否可封送;改用较简单的字段/组件 API。 | +| 游戏卡顿 | 减少枚举范围、深度和每帧请求数;避免 getter、`ToString()`、大纹理/网格序列化。 | +| MCP 客户端报告协议解析错误 | 确认 Trae 选择的 SSE/Streamable HTTP 与 CUE UI 一致;重启 DLL 内置 MCP Server;检查响应 Content-Type、URL、token 和协议版本。 | +| 只读模式仍拒绝 getter | 这是预期的保守策略;getter 可能有副作用。只使用工具清单中明确标记为只读的能力。 | + +诊断时优先记录:CUE 版本、加载器与版本、Mono/IL2CPP、Unity 版本、活动场景、MCP 传输模式、安全模式、MCP URL(遮盖 token)、请求 `id`、错误码和时间。不要上传存档、token、完整对象转储或含隐私的游戏日志。 diff --git a/docs/MCP_SECURITY.md b/docs/MCP_SECURITY.md new file mode 100644 index 000000000..d1408ca1e --- /dev/null +++ b/docs/MCP_SECURITY.md @@ -0,0 +1,201 @@ +# MCP 安全与威胁模型 + +本文定义 CUE MCP 的安全边界、默认策略和上线检查。MCP 拥有与游戏内调试器近似的权限;启用任意反射写入或方法调用后,应按“可在当前游戏进程中执行高权限操作”的接口对待,而不是普通遥测 API。 + +## 1. 资产与信任边界 + +需要保护的资产: + +- 游戏进程的完整性、稳定性和可恢复状态; +- 存档、配置、截图及游戏可访问的本地文件; +- 用户账号、多人会话和反作弊状态; +- MCP token、对象数据、日志及可能包含隐私的运行时字符串; +- 主机 CPU、内存、磁盘和 Unity 主线程帧预算。 + +信任边界: + +1. 不受信任的自然语言、网页、游戏文本和模型输出进入 Agent。 +2. MCP 客户端通过回环网络直接连接 CUE DLL 内置的 SSE 或 Streamable HTTP Server。 +3. DLL 内置网络层完成会话管理、鉴权、协议解析、大小限制和请求排队。 +4. 网络线程把已验证请求交给 Unity 主线程。 +5. 反射/Unity API 穿过托管、IL2CPP 和原生对象边界。 +6. 截图、导出、存档或日志穿过游戏进程到文件系统。 + +本方案不依赖 Node.js、stdio 适配器或其他外部转发组件;鉴权和权限检查全部由 DLL 内置服务和游戏内 Runtime 执行器完成。 + +**不可信输入包括 Agent 自己生成的参数。** Prompt injection 可能来自对象名称、组件字符串、游戏聊天、网页或用户提供的脚本。服务端不能因为调用来自“受信任 Agent”就跳过 schema、权限和范围校验。 + +## 2. 安全目标 + +- 默认不可远程访问、默认拒绝未鉴权请求。 +- 默认只读、默认关闭危险操作,重启后不保留危险授权。 +- 所有 Unity 操作在主线程执行,并受超时、队列及每帧预算控制。 +- 工具采用最小权限和显式 allowlist;未知工具、类型、成员或参数默认拒绝。 +- 写操作可审计、可验证;高影响操作需用户逐次批准。 +- token、敏感对象数据和文件内容不进入普通日志。 +- 客户端断开或超时不会产生无限重试和重复副作用。 + +## 3. 基线安全策略(MUST) + +### 网络与鉴权 + +- DLL 内置 MCP Server **MUST** 只监听 `127.0.0.1`,可选监听 `::1`;禁止 `0.0.0.0` 和非回环网卡。 +- RPC **MUST** 要求高熵 token。token 比较应避免明显的时序泄漏,并在失败时返回统一错误。 +- **MUST** 限制 HTTP 方法、`Content-Type`、`Accept`、请求体字节数、JSON 深度、字符串长度和响应体大小。 +- SSE 会话 **MUST** 有连接数、空闲时间、写入队列和生命周期上限;断开后必须释放会话及网络资源。 +- Streamable HTTP 可采用无状态模式;此时不得伪造或宣称存在 `Mcp-Session-Id`。若未来启用有状态会话,则会话标识和协议版本头必须按协议校验,并拒绝伪造、过期或跨连接复用。 +- UI 切换 SSE/Streamable HTTP 后必须重启 MCP Server,使旧监听和旧会话失效;不能让两种模式意外共享未验证的会话状态。 +- **MUST NOT** 接受 URL 查询参数中的 token;**MUST NOT** 在日志打印鉴权头。 +- 浏览器可访问的 HTTP 实现 **MUST NOT** 开放宽泛 CORS;对意外的 `Origin` 头应默认拒绝,避免恶意网页利用本机端口。 +- 健康端点不得泄露工具、对象、token 或详细异常;可配置为也要求 token。 + +### 权限 + +- 启动默认 `readOnly=true`、`allowDangerous=false`。 +- 只读模式必须由服务端强制执行,不能只靠 UI 隐藏按钮或依赖客户端描述。 +- 每个工具必须带服务端权限分类;未知分类按危险操作拒绝。 +- 危险操作开关必须在 UI 中明确可见,并在每次进程启动时复位为关闭。 +- 反射成员、可实例化类型、可读写路径和文件导出目录必须支持 denylist/allowlist;禁止绕过 CUE 已有反射黑名单。 + +### 执行与资源控制 + +- 网络线程不得直接访问 UnityEngine 对象。 +- 请求队列必须有上限;满时快速失败,不可无限缓存。 +- 每帧执行请求数和单请求工作量必须有上限。 +- 枚举、对象图序列化和递归必须限制深度、节点数、集合项数及字符串长度。 +- 异常必须转换为结构化错误;默认不向客户端暴露完整本地路径、栈、token 或私有字段值。 +- 非幂等操作应支持调用 ID/去重窗口,或明确告诉客户端不可在超时后自动重试。 + +## 4. 操作分级 + +| 级别 | 示例 | 默认 | 用户确认 | +|---|---|---|---| +| R0 诊断 | ping、版本、Bridge 状态、安全模式 | 允许 | 不需要 | +| R1 只读 | 场景/对象/组件列表,安全字段读取 | 只读模式允许 | 批量/敏感数据可要求 | +| W1 可恢复写入 | Transform、启停对象、简单数值字段 | 默认拒绝 | 每个任务明确确认并写后验证 | +| D1 破坏性 | 销毁对象、移除组件、卸载/加载场景、静态状态写入 | 危险开关关闭 | 必须逐次确认 | +| D2 代码/外部副作用 | 任意方法调用、类型实例化、文件读写、网络、进程/原生接口 | 应默认永久禁用或严格 allowlist | 双重确认;能不用则不用 | + +名称不是分类依据。例如 `get_Current()` 是方法,可能有副作用;`saveScreenshot` 会写文件;`set_active(false)` 可能中断关键系统。分类必须绑定到实际执行器和成员策略。 + +### 4.1 当前运行时权限矩阵 + +| Runtime 操作 | `MCP_Read_Only=true` | 只读关闭、危险关闭 | 只读关闭、危险开启 | +|---|---:|---:|---:| +| `status`、`list_scenes`、`search`、`snapshot`、`get_member` | 允许 | 允许 | 允许 | +| `set_member`、`set_transform`、`set_enabled` | 拒绝 | 允许 | 允许 | +| `invoke`、`create`、`destroy` | 拒绝 | 拒绝 | 允许 | +| `batch` | 每个子命令分别检查 | 每个子命令分别检查 | 每个子命令分别检查 | + +权限必须在游戏内 Runtime 执行器中强制,而不是依赖 MCP tool annotation、Trae/其他客户端的审批设置或 UI 是否显示某个按钮。通用入口 `game.execute` 与批处理也必须先解析实际内部命令,再应用相同策略。 + +### 4.2 批处理非原子边界 + +批处理减少网络往返,但不是安全事务。当前应假设: + +- 子操作按顺序执行,已成功的前序修改不会因后续失败而回滚; +- `stop_on_error=true` 只阻止尚未执行的后续项; +- `atomic=true` 只是能力请求,Bridge 可拒绝,客户端不能据此承诺回滚; +- Unity 方法、对象创建/销毁、场景行为和原生副作用通常不可逆; +- 客户端超时时操作可能已经在主线程执行,因此危险批次不得自动重试。 + +服务端必须对每个子操作单独鉴权、分类和限流,拒绝递归批处理。Agent 应在执行前展示逐项计划,执行后逐项读取验证,并明确标注部分成功状态。 + +## 5. 主要威胁与缓解 + +| 威胁 | 典型场景 | 影响 | 必需缓解 | +|---|---|---|---| +| 未授权本机访问 | 恶意进程扫描回环端口 | 任意修改、数据泄露 | token、回环绑定、短暴露周期、轮换 | +| 局域网/公网暴露 | 绑定 `0.0.0.0` 或端口转发 | 远程接管游戏 | 拒绝非回环绑定;仅使用认证加密隧道 | +| 浏览器对 localhost 的攻击 | 恶意网页 POST 到已知端口 | CSRF 式调用 | token 自定义头、拒绝 CORS/Origin、JSON Content-Type | +| Prompt injection | 游戏聊天/对象名诱导 Agent 调危险工具 | 越权或破坏状态 | 把运行时文本视为数据;服务端权限;用户确认 | +| token 泄露 | 配置提交、日志、截图或命令行 | 会话接管 | `.gitignore`、日志脱敏、header 传递、快速轮换 | +| 参数混淆/对象替换 | 场景切换后旧 ID 指向无效对象 | 修改错误目标 | 句柄带会话/场景世代;写前重读名称、类型、路径 | +| 反射副作用 | getter、`ToString()`、方法执行游戏逻辑 | 保存损坏、网络行为、崩溃 | allowlist;只读模式拒绝隐式代码执行 | +| 拒绝服务 | 深对象图、大集合、昂贵 getter、请求洪泛 | 掉帧、OOM、卡死 | 大小/深度/队列/每帧限制,取消与熔断 | +| 超时重放 | 客户端没收到响应后重试销毁/创建 | 重复副作用 | 幂等键/去重;写后读取;禁止自动重试 | +| 路径穿越 | 导出工具接收 `../` 或绝对路径 | 覆盖/泄露文件 | 固定导出根目录、规范化后验证、拒绝链接和绝对路径 | +| 类型混淆 | JSON 数字/枚举/重载错误转换 | 错误调用或内存问题 | 严格 schema、范围校验、完整签名和显式转换 | +| IL2CPP/原生边界失效 | 已销毁对象包装仍非 null | 崩溃或未定义行为 | Unity 存活检查、异常隔离、保守 API 集 | +| 信息泄露 | 错误返回栈、本地路径或私有状态 | 隐私与后续攻击 | 面向客户端的精简错误;详细日志本地且脱敏 | + +## 6. 危险操作确认建议 + +仅有一个全局复选框不足以表达用户意图。推荐流程: + +1. Agent 提交只读 `plan/preview`,列出工具、目标、旧值、新值和预计影响。 +2. UI 为该计划生成短期、单次确认 nonce;nonce 绑定会话、工具、参数摘要和过期时间。 +3. 实际危险调用必须携带 nonce;参数变化、过期或重复使用均拒绝。 +4. 执行后返回每个目标的结果,并通过只读查询验证。 +5. 对可恢复字段保留会话内 undo 记录;不要声称可以撤销方法调用或场景副作用。 + +如果当前实现尚无 nonce,至少要求 MCP UI 中危险开关 + 客户端逐次人工审批,并将危险模式的开启时间保持尽可能短。 + +## 7. 日志与审计 + +建议审计字段:时间、会话 ID、请求 ID、客户端标识、工具名、权限级别、目标的非敏感标识、结果码、耗时和是否被策略拒绝。 + +不得记录:完整 token/鉴权头、任意文件内容、完整对象转储、账号凭据、聊天隐私、未脱敏路径或超长参数。参数审计应采用允许字段摘要;token 最多显示不可逆指纹或末尾极少字符,且后者也非必需。 + +日志应有轮换、大小和保留期限。UI 中“复制诊断信息”必须自动遮盖 token。 + +## 8. 发布前安全测试 + +### 自动化协议测试 + +- SSE 与 Streamable HTTP 分别测试无 token、错误 token、正确 token; +- SSE 测试事件流握手、endpoint/message 事件、心跳、客户端断开、无效会话和并发连接上限; +- Streamable HTTP 测试 `GET`/`POST`/通知、`Accept`、`Content-Type` 和协议版本;无状态模式验证不强制 session header,有状态模式再测试会话 ID 及无效/过期会话; +- 畸形 JSON、重复字段、过深 JSON、超大请求、超大响应、队列满和超时; +- JSON-RPC 缺少 `jsonrpc`/`id`/`method`,未知方法,notification; +- 响应 ID 与请求一致,错误结构稳定,网络日志不泄露 token; +- 在 MCP UI 切换传输模式并重启后,旧连接与旧会话不可继续使用。 + +### 权限测试 + +- 只读模式逐个拒绝所有 W1/D1/D2 工具; +- getter、`ToString()`、静态成员、索引器、事件和委托不可通过只读路径旁路; +- 危险关闭时销毁、移除组件、场景操作、方法调用和文件访问均失败; +- 关闭/重启 Bridge 后危险授权失效; +- `tools/list` 不发布当前模式下不可安全调用的能力,或工具调用时可靠拒绝。 + +### 网络测试 + +```powershell +Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq 17891 +``` + +确认 `LocalAddress` 仅为 `127.0.0.1` 或 `::1`。还应从同局域网另一台机器确认不能连接。不要仅依赖 Windows 防火墙弥补错误绑定。 + +### 稳定性测试 + +- 加载/卸载场景时并发查询; +- 对象在排队后、执行前被销毁; +- 游戏暂停、低帧率和长时间主线程卡顿; +- Mono 与 IL2CPP 分别测试值类型、枚举、数组、泛型和重载; +- 10k+ 对象场景和大型集合的分页/截断; +- 客户端超时、断开、重连后不重复执行危险请求。 + +## 9. 安全部署检查表 + +- [ ] DLL 内置 MCP Server 仅监听回环地址。 +- [ ] RPC token 已生成,长度足够,未提交版本库。 +- [ ] 只读默认开启,危险默认关闭,重启后复位。 +- [ ] MCP 客户端对写入/危险工具启用人工审批。 +- [ ] 请求、响应、JSON 深度、队列和每帧预算均有限制。 +- [ ] 反射和文件能力使用 allowlist,沿用 CUE 黑名单。 +- [ ] SSE/Streamable HTTP 网络日志已脱敏,不记录鉴权头、token 或完整敏感载荷。 +- [ ] SSE 与 Streamable HTTP 均完成 `initialize`、`tools/list` 和只读调用 smoke test。 +- [ ] Mono 和目标 IL2CPP 游戏均完成负面权限及稳定性测试。 +- [ ] 已准备 token 轮换、停止 DLL 内置 MCP Server、终止活动会话和恢复存档的方法。 + +## 10. 事件响应 + +发现异常调用、token 泄露或游戏状态被意外修改时: + +1. 立即在 MCP UI 停止 DLL 内置 MCP Server,断开全部 SSE/Streamable HTTP 会话。 +2. 保存脱敏后的请求 ID、时间和错误日志,不保存 token。 +3. 轮换 token,并检查端口是否曾绑定到非回环地址。 +4. 从可信存档/配置恢复;不要假定反射修改可以自动撤销。 +5. 检查 MCP 客户端会话中的 prompt injection 和自动审批规则。 +6. 在确认根因和策略修复前,仅以只读模式重新启用。 diff --git a/src/Config/ConfigManager.cs b/src/Config/ConfigManager.cs index 947f66f1f..e04c92a89 100644 --- a/src/Config/ConfigManager.cs +++ b/src/Config/ConfigManager.cs @@ -1,4 +1,5 @@ -using UnityExplorer.UI; +using UnityExplorer.MCP.Transport; +using UnityExplorer.UI; using UnityExplorer.UI.Panels; namespace UnityExplorer.Config @@ -35,6 +36,23 @@ public static class ConfigManager public static ConfigElement Arrow_Size; public static ConfigElement Freecam_Camera_Target_Selection; + // MCP server settings + public static ConfigElement MCP_Enabled; + public static ConfigElement MCP_Transport_Mode; + public static ConfigElement MCP_Bind_Address; + public static ConfigElement MCP_Port; + public static ConfigElement MCP_Rpc_Path; + public static ConfigElement MCP_Health_Path; + public static ConfigElement MCP_Auth_Token; + public static ConfigElement MCP_Require_Token_For_Health; + public static ConfigElement MCP_Read_Only; + public static ConfigElement MCP_Allow_Dangerous_Operations; + public static ConfigElement MCP_Request_Logging; + public static ConfigElement MCP_Request_Timeout_Milliseconds; + public static ConfigElement MCP_Max_Request_Body_Bytes; + public static ConfigElement MCP_Max_Pending_Requests; + public static ConfigElement MCP_Max_Requests_Per_Frame; + public static ConfigElement Pause; public static ConfigElement Frameskip; public static ConfigElement Screenshot; @@ -204,6 +222,66 @@ private static void CreateConfigElements() "Enables certain advanced settings on the Freecam panel, in case the user can't get the freecam to work properly (requires game reset).", false); + MCP_Enabled = new("MCP Enabled", + "Start the local MCP server after CinematicUnityExplorer finishes initializing.", + false); + + MCP_Transport_Mode = new("MCP Transport Mode", + "MCP protocol transport hosted directly by the CinematicUnityExplorer DLL. Supported values are SSE and StreamableHTTP. Restart MCP after changing this value.", + McpTransportMode.SSE); + + MCP_Bind_Address = new("MCP Bind Address", + "Address exposed by the MCP transport. The built-in transport currently supports loopback only.", + "127.0.0.1"); + + MCP_Port = new("MCP Port", + "TCP port used by the local MCP HTTP bridge. Restart MCP after changing this value.", + 17891); + + MCP_Rpc_Path = new("MCP RPC Path", + "HTTP path used for JSON-RPC requests. Restart MCP after changing this value.", + "/mcp"); + + MCP_Health_Path = new("MCP Health Path", + "HTTP path used for MCP health checks. Restart MCP after changing this value.", + "/health"); + + MCP_Auth_Token = new("MCP Auth Token", + "Bearer token required by the MCP HTTP bridge. A secure token is generated automatically when MCP starts if this value is empty.", + ""); + + MCP_Require_Token_For_Health = new("MCP Require Token For Health", + "Require the configured MCP bearer token for health-check requests.", + false); + + MCP_Read_Only = new("MCP Read Only", + "Block mutation tools and permit inspection-only MCP operations.", + true); + + MCP_Allow_Dangerous_Operations = new("MCP Allow Dangerous Operations", + "Allow high-risk tools such as object destruction, arbitrary invocation, and scene changes.", + false); + + MCP_Request_Logging = new("MCP Request Logging", + "Retain a small in-memory log of MCP requests for the MCP UI.", + false); + + MCP_Request_Timeout_Milliseconds = new("MCP Request Timeout Milliseconds", + "Maximum time an HTTP request waits for Unity main-thread execution.", + 30000); + + MCP_Max_Request_Body_Bytes = new("MCP Max Request Body Bytes", + "Maximum accepted JSON-RPC request body size.", + 1024 * 1024); + + MCP_Max_Pending_Requests = new("MCP Max Pending Requests", + "Maximum number of requests waiting for Unity main-thread execution.", + 128); + + MCP_Max_Requests_Per_Frame = new("MCP Max Requests Per Frame", + "Maximum number of queued MCP requests executed during one Unity Update.", + 16); + Pause = new("Pause", "Toggle the pause of the game.", KeyCode.PageUp); diff --git a/src/ExplorerBehaviour.cs b/src/ExplorerBehaviour.cs index c45cda977..a897c7563 100644 --- a/src/ExplorerBehaviour.cs +++ b/src/ExplorerBehaviour.cs @@ -1,4 +1,5 @@ using UnityExplorer.Config; +using UnityExplorer.MCP; using UnityExplorer.UI; using UnityExplorer.UI.Panels; using UnityExplorer.UI.Widgets; @@ -38,6 +39,7 @@ internal static void Setup() internal void Update() { ExplorerCore.Update(); + McpManager.PumpMainThread(); } // For editor, to clean up objects @@ -53,6 +55,8 @@ internal void OnApplicationQuit() { if (quitting) return; quitting = true; + McpManager.Shutdown(); + if (UIManager.UIRoot) TryDestroy(UIManager.UIRoot.transform.root.gameObject); diff --git a/src/ExplorerCore.cs b/src/ExplorerCore.cs index 735f6408c..4195bf56a 100644 --- a/src/ExplorerCore.cs +++ b/src/ExplorerCore.cs @@ -9,6 +9,7 @@ global using UniverseLib.Utility; using UnityExplorer.CatmullRom; using UnityExplorer.Config; +using UnityExplorer.MCP; using UnityExplorer.ObjectExplorer; using UnityExplorer.Runtime; using UnityExplorer.UI; @@ -67,6 +68,7 @@ public static void Init(IExplorerLoader loader) static void LateInit() { SceneHandler.Init(); + McpManager.Initialize(); Log($"Creating UI..."); diff --git a/src/MCP/McpManager.cs b/src/MCP/McpManager.cs new file mode 100644 index 000000000..aaa945e09 --- /dev/null +++ b/src/MCP/McpManager.cs @@ -0,0 +1,817 @@ +using System.Security.Cryptography; +using UnityExplorer.Config; +using UnityExplorer.MCP.Runtime; +using UnityExplorer.MCP.Transport; + +namespace UnityExplorer.MCP +{ + /// + /// Coordinates MCP configuration, transport lifetime and Unity main-thread dispatch. + /// Runtime/transport implementations plug in through SetTransportFactory or AttachTransport. + /// + public static class McpManager + { + public enum LifecycleState + { + Uninitialized, + Stopped, + Starting, + Running, + Stopping, + Faulted, + Shutdown + } + + private static readonly object SyncRoot = new(); + private static readonly McpRequestRouter RequestRouter = new(); + private static readonly Queue RecentRequestEntries = new(); + private const int MaximumRecentRequestEntries = 100; + + private static Func transportFactory = + (options, requestDispatcher) => new McpHttpBridge(options, requestDispatcher); + private static IMcpRequestDispatcher dispatcher = RequestRouter; + private static IMcpTransport transport; + private static McpGameCapabilityExecutor gameExecutor; + private static McpNativeProtocolHandler nativeProtocolHandler; + private static LifecycleState state = LifecycleState.Uninitialized; + private static Exception lastError; + private static bool initialized; + private static bool shuttingDown; + private static bool configCallbacksRegistered; + private static bool restartRequired; + + /// Raised after lifecycle state, error, or pending-restart status changes. + public static event Action StateChanged; + + /// Raised when an MCP configuration value changes. + public static event Action ConfigurationChanged; + + /// + /// Shared router for runtime modules. Register handlers here before or after Initialize. + /// Handlers are executed only from PumpMainThread. + /// + public static McpRequestRouter Router => RequestRouter; + + public static McpGameCapabilityExecutor GameExecutor + { + get { lock (SyncRoot) return gameExecutor; } + } + + public static bool IsInitialized + { + get { lock (SyncRoot) return initialized; } + } + + public static bool IsRunning + { + get + { + lock (SyncRoot) + return transport != null && transport.IsRunning; + } + } + + public static LifecycleState State + { + get { lock (SyncRoot) return state; } + } + + public static Exception LastError + { + get { lock (SyncRoot) return lastError; } + } + + public static string LastErrorMessage + { + get + { + lock (SyncRoot) + return lastError?.Message ?? string.Empty; + } + } + + public static int PendingRequestCount + { + get + { + lock (SyncRoot) + return transport?.PendingRequestCount ?? 0; + } + } + + public static bool RestartRequired + { + get { lock (SyncRoot) return restartRequired; } + } + + public static string RpcEndpoint + => $"http://127.0.0.1:{ConfigManager.MCP_Port.Value}{NormalizeDisplayPath(ConfigManager.MCP_Rpc_Path.Value)}"; + + public static string HealthEndpoint + => $"http://127.0.0.1:{ConfigManager.MCP_Port.Value}{NormalizeDisplayPath(ConfigManager.MCP_Health_Path.Value)}"; + + /// The configured transport mode in UI/config-friendly form. + public static string TransportMode + { + get => ToConfiguredTransportMode(ConfigManager.MCP_Transport_Mode.Value); + set => ConfigManager.MCP_Transport_Mode.Value = ParseTransportMode(value); + } + + /// + /// The mode used by the currently-created transport. This can differ from + /// while a configuration change is awaiting restart. + /// + public static string CurrentTransportMode + { + get + { + lock (SyncRoot) + { + if (transport?.Options != null) + return ToConfiguredTransportMode(transport.Options.TransportMode); + } + + return TransportMode; + } + } + + /// The MCP endpoint clients should use for the selected transport. + public static string TransportEndpoint => RpcEndpoint; + + /// Compact mode/endpoint status intended for the MCP settings UI. + public static string TransportStatus + => $"{CurrentTransportMode}: {TransportEndpoint}"; + + // Stable settings/control API consumed by the MCP UI and external integrations. + public static bool Enabled + { + get => ConfigManager.MCP_Enabled.Value; + set => ConfigManager.MCP_Enabled.Value = value; + } + + public static string BindAddress + { + get => ConfigManager.MCP_Bind_Address.Value; + set + { + string address = string.IsNullOrEmpty(value) ? "127.0.0.1" : value.Trim(); + if (address.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + address = "127.0.0.1"; + if (address != "127.0.0.1") + throw new ArgumentException("The built-in MCP transport currently supports loopback (127.0.0.1) only.", nameof(value)); + ConfigManager.MCP_Bind_Address.Value = address; + } + } + + public static int Port + { + get => ConfigManager.MCP_Port.Value; + set + { + if (value < 1 || value > 65535) + throw new ArgumentOutOfRangeException(nameof(value), "MCP port must be between 1 and 65535."); + ConfigManager.MCP_Port.Value = value; + } + } + + public static string AuthenticationToken + { + get => ConfigManager.MCP_Auth_Token.Value; + set => ConfigManager.MCP_Auth_Token.Value = value ?? string.Empty; + } + + public static bool ReadOnly + { + get => ConfigManager.MCP_Read_Only.Value; + set => ConfigManager.MCP_Read_Only.Value = value; + } + + public static bool AllowDangerousOperations + { + get => ConfigManager.MCP_Allow_Dangerous_Operations.Value; + set => ConfigManager.MCP_Allow_Dangerous_Operations.Value = value; + } + + public static bool RequestLoggingEnabled + { + get => ConfigManager.MCP_Request_Logging.Value; + set => ConfigManager.MCP_Request_Logging.Value = value; + } + + public static string StatusText + { + get + { + string result = State.ToString(); + if (RestartRequired) + result += " (restart required)"; + if (State == LifecycleState.Faulted && !string.IsNullOrEmpty(LastErrorMessage)) + result += ": " + LastErrorMessage; + return result; + } + } + + public static string RecentRequests + { + get + { + lock (SyncRoot) + return string.Join(Environment.NewLine, RecentRequestEntries.ToArray()); + } + } + + public static string GenerateAuthenticationToken() + { + byte[] bytes = new byte[32]; + RandomNumberGenerator generator = RandomNumberGenerator.Create(); + generator.GetBytes(bytes); + + string token = Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + AuthenticationToken = token; + return token; + } + + public static void SaveSettings() + => ConfigManager.Handler.SaveConfig(); + + /// Allows runtime handlers to add useful entries to the UI request log. + public static void RecordRequest(string entry) + { + if (!ConfigManager.MCP_Request_Logging.Value || string.IsNullOrEmpty(entry)) + return; + + lock (SyncRoot) + { + RecentRequestEntries.Enqueue(entry); + while (RecentRequestEntries.Count > MaximumRecentRequestEntries) + RecentRequestEntries.Dequeue(); + } + } + /// + /// Installs the transport constructor. This is the preferred integration API for the + /// Transport submodule because it lets the manager rebuild the transport after settings change. + /// + public static void SetTransportFactory( + Func factory) + { + if (factory == null) + throw new ArgumentNullException(nameof(factory)); + + lock (SyncRoot) + { + ThrowIfShutdown(); + transportFactory = factory; + } + + if (IsInitialized) + RecreateTransport(startIfEnabled: ConfigManager.MCP_Enabled.Value); + } + + /// + /// Overrides the request dispatcher passed to the transport factory. By default the shared + /// Router is used. Changing it recreates an initialized transport. + /// + public static void SetDispatcher(IMcpRequestDispatcher requestDispatcher) + { + if (requestDispatcher == null) + throw new ArgumentNullException(nameof(requestDispatcher)); + + lock (SyncRoot) + { + ThrowIfShutdown(); + dispatcher = requestDispatcher; + } + + if (IsInitialized && transportFactory != null) + RecreateTransport(startIfEnabled: ConfigManager.MCP_Enabled.Value); + } + + /// + /// Attaches an already-created transport. Prefer SetTransportFactory when settings should + /// be editable at runtime. Ownership transfers to McpManager and the transport is disposed + /// during replacement or Shutdown. + /// + public static void AttachTransport(IMcpTransport newTransport, bool startIfEnabled = true) + { + if (newTransport == null) + throw new ArgumentNullException(nameof(newTransport)); + + IMcpTransport oldTransport; + lock (SyncRoot) + { + ThrowIfShutdown(); + oldTransport = transport; + transport = newTransport; + lastError = null; + restartRequired = false; + state = newTransport.IsRunning ? LifecycleState.Running : LifecycleState.Stopped; + } + + if (!ReferenceEquals(oldTransport, newTransport)) + StopAndDispose(oldTransport); + + NotifyStateChanged(); + + if (initialized && startIfEnabled && ConfigManager.MCP_Enabled.Value && !newTransport.IsRunning) + Start(); + } + + /// Builds a fresh options object from the persisted MCP configuration. + public static McpHttpBridgeOptions CreateOptions() + { + return new McpHttpBridgeOptions + { + Port = ConfigManager.MCP_Port.Value, + TransportMode = ToHttpTransportMode(ConfigManager.MCP_Transport_Mode.Value), + RpcPath = ConfigManager.MCP_Rpc_Path.Value, + HealthPath = ConfigManager.MCP_Health_Path.Value, + Token = ConfigManager.MCP_Auth_Token.Value, + RequireTokenForHealth = ConfigManager.MCP_Require_Token_For_Health.Value, + RequestTimeoutMilliseconds = ConfigManager.MCP_Request_Timeout_Milliseconds.Value, + MaxRequestBodyBytes = ConfigManager.MCP_Max_Request_Body_Bytes.Value, + MaxPendingRequests = ConfigManager.MCP_Max_Pending_Requests.Value, + MaxRequestsPerPump = ConfigManager.MCP_Max_Requests_Per_Frame.Value + }; + } + + /// Called once from ExplorerCore.LateInit. + public static void Initialize() + { + lock (SyncRoot) + { + if (initialized || shuttingDown) + return; + + initialized = true; + state = LifecycleState.Stopped; + RegisterConfigCallbacks(); + } + + // Dangerous authorization is intentionally session-scoped and never survives a restart. + if (ConfigManager.MCP_Allow_Dangerous_Operations.Value) + ConfigManager.MCP_Allow_Dangerous_Operations.Value = false; + + EnsureRuntimeRegistered(); + + if (transportFactory != null) + RecreateTransport(startIfEnabled: ConfigManager.MCP_Enabled.Value); + else if (ConfigManager.MCP_Enabled.Value) + SetFault(new InvalidOperationException( + "MCP is enabled, but no transport factory or transport has been registered.")); + else + NotifyStateChanged(); + } + + public static bool Start() + { + IMcpTransport current; + lock (SyncRoot) + { + if (!initialized || shuttingDown) + return false; + if (transport != null && transport.IsRunning) + return true; + + // Never expose the control bridge without authentication, even on loopback. + // Generate a token lazily so existing installations start safely. + if (string.IsNullOrEmpty(ConfigManager.MCP_Auth_Token.Value)) + ConfigManager.MCP_Auth_Token.Value = GenerateAuthenticationToken(); + + current = transport; + + // A stopped transport may have been created before the user edited its settings + // (or before a token was generated). Synchronize every startup option before + // Start() captures its immutable listener snapshot. + SynchronizeStoppedTransportOptions(current); + + state = LifecycleState.Starting; + lastError = null; + } + + NotifyStateChanged(); + + if (current == null) + { + if (transportFactory != null) + { + RecreateTransport(startIfEnabled: false); + lock (SyncRoot) + current = transport; + } + + if (current == null) + { + SetFault(new InvalidOperationException( + "No MCP transport is registered. Install a transport factory before starting MCP.")); + return false; + } + } + + try + { + current.Start(); + lock (SyncRoot) + { + state = current.IsRunning ? LifecycleState.Running : LifecycleState.Stopped; + restartRequired = false; + } + NotifyStateChanged(); + return current.IsRunning; + } + catch (Exception ex) + { + SetFault(ex); + ExplorerCore.LogError($"Failed to start MCP: {ex}"); + return false; + } + } + + public static void Stop() + { + IMcpTransport current; + lock (SyncRoot) + { + if (shuttingDown) + return; + current = transport; + state = LifecycleState.Stopping; + } + + NotifyStateChanged(); + + try + { + current?.Stop(); + lock (SyncRoot) + state = LifecycleState.Stopped; + } + catch (Exception ex) + { + SetFault(ex); + ExplorerCore.LogError($"Failed to stop MCP: {ex}"); + return; + } + + NotifyStateChanged(); + } + + /// Applies all current configuration by rebuilding and optionally starting MCP. + public static bool Restart() + { + if (!IsInitialized) + return false; + + RecreateTransport(startIfEnabled: ConfigManager.MCP_Enabled.Value); + return !ConfigManager.MCP_Enabled.Value || IsRunning; + } + + /// Runs queued MCP work on Unity's main thread. Called from Update. + public static int PumpMainThread() + { + IMcpTransport current; + lock (SyncRoot) + { + if (!initialized || shuttingDown || transport == null || !transport.IsRunning) + return 0; + current = transport; + } + + try + { + return current.PumpMainThread(ConfigManager.MCP_Max_Requests_Per_Frame.Value); + } + catch (Exception ex) + { + SetFault(ex); + ExplorerCore.LogError($"MCP main-thread pump failed: {ex}"); + return 0; + } + } + + /// Stops and disposes all MCP resources. Safe to call more than once. + public static void Shutdown() + { + IMcpTransport current; + lock (SyncRoot) + { + if (shuttingDown) + return; + + shuttingDown = true; + current = transport; + transport = null; + state = LifecycleState.Shutdown; + } + + StopAndDispose(current); + + lock (SyncRoot) + { + gameExecutor?.Unregister(RequestRouter); + gameExecutor = null; + } + + NotifyStateChanged(); + } + + private static void SynchronizeStoppedTransportOptions(IMcpTransport value) + { + if (value == null || value.IsRunning || value.Options == null) + return; + + McpHttpBridgeOptions configured = CreateOptions(); + McpHttpBridgeOptions target = value.Options; + target.Port = configured.Port; + target.TransportMode = configured.TransportMode; + target.RpcPath = configured.RpcPath; + target.HealthPath = configured.HealthPath; + target.Token = configured.Token; + target.RequireTokenForHealth = configured.RequireTokenForHealth; + target.RequestTimeoutMilliseconds = configured.RequestTimeoutMilliseconds; + target.MaxRequestBodyBytes = configured.MaxRequestBodyBytes; + target.MaxPendingRequests = configured.MaxPendingRequests; + target.MaxRequestsPerPump = configured.MaxRequestsPerPump; + } + private static void RecreateTransport(bool startIfEnabled) + { + Func factory; + IMcpRequestDispatcher currentDispatcher; + IMcpTransport oldTransport; + + lock (SyncRoot) + { + if (shuttingDown) + return; + factory = transportFactory; + currentDispatcher = new LoggingDispatcher(dispatcher); + oldTransport = transport; + transport = null; + state = LifecycleState.Stopped; + } + + StopAndDispose(oldTransport); + + if (factory == null) + { + NotifyStateChanged(); + return; + } + + try + { + IMcpTransport newTransport = factory(CreateOptions(), currentDispatcher); + if (newTransport == null) + throw new InvalidOperationException("The MCP transport factory returned null."); + + lock (SyncRoot) + { + transport = newTransport; + lastError = null; + restartRequired = false; + state = newTransport.IsRunning ? LifecycleState.Running : LifecycleState.Stopped; + } + NotifyStateChanged(); + + if (initialized && startIfEnabled && !newTransport.IsRunning) + Start(); + } + catch (Exception ex) + { + SetFault(ex); + ExplorerCore.LogError($"Failed to create MCP transport: {ex}"); + } + } + + private static void RegisterConfigCallbacks() + { + if (configCallbacksRegistered) + return; + + configCallbacksRegistered = true; + ConfigManager.MCP_Enabled.OnValueChanged += OnEnabledChanged; + ConfigManager.MCP_Bind_Address.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Port.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Transport_Mode.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Rpc_Path.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Health_Path.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Auth_Token.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Require_Token_For_Health.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Read_Only.OnValueChanged += _ => OnRuntimeSecurityChanged(); + ConfigManager.MCP_Allow_Dangerous_Operations.OnValueChanged += _ => OnRuntimeSecurityChanged(); + ConfigManager.MCP_Request_Logging.OnValueChanged += _ => OnConfigurationChanged(requiresRestart: false); + ConfigManager.MCP_Request_Timeout_Milliseconds.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Max_Request_Body_Bytes.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Max_Pending_Requests.OnValueChanged += _ => OnRestartSettingChanged(); + ConfigManager.MCP_Max_Requests_Per_Frame.OnValueChanged += _ => OnConfigurationChanged(requiresRestart: false); + } + + private static void EnsureRuntimeRegistered() + { + lock (SyncRoot) + { + if (gameExecutor == null) + { + gameExecutor = new McpGameCapabilityExecutor(); + gameExecutor.Register(RequestRouter); + } + if (nativeProtocolHandler == null) + { + nativeProtocolHandler = new McpNativeProtocolHandler(gameExecutor); + nativeProtocolHandler.Register(RequestRouter); + } + } + + ApplyRuntimeSecurityOptions(); + } + + private static void ApplyRuntimeSecurityOptions() + { + McpGameCapabilityExecutor executor; + lock (SyncRoot) + executor = gameExecutor; + if (executor == null) + return; + + bool allowDangerous = !ConfigManager.MCP_Read_Only.Value && + ConfigManager.MCP_Allow_Dangerous_Operations.Value; + executor.Options.AllowMethodInvocation = allowDangerous; + executor.Options.AllowObjectCreation = allowDangerous; + executor.Options.AllowObjectDestruction = allowDangerous; + } + + private static void OnRuntimeSecurityChanged() + { + ApplyRuntimeSecurityOptions(); + OnConfigurationChanged(requiresRestart: false); + } + private static void OnEnabledChanged(bool enabled) + { + OnConfigurationChanged(requiresRestart: false); + if (!initialized || shuttingDown) + return; + + if (enabled) + Start(); + else + Stop(); + } + + private static void OnRestartSettingChanged() + => OnConfigurationChanged(requiresRestart: true); + + private static void OnConfigurationChanged(bool requiresRestart) + { + lock (SyncRoot) + { + if (requiresRestart && transport != null && transport.IsRunning) + restartRequired = true; + } + + try + { + ConfigurationChanged?.Invoke(); + } + catch (Exception ex) + { + ExplorerCore.LogWarning($"MCP ConfigurationChanged subscriber failed: {ex}"); + } + + NotifyStateChanged(); + } + + private static void SetFault(Exception error) + { + lock (SyncRoot) + { + lastError = error; + state = LifecycleState.Faulted; + } + NotifyStateChanged(); + } + + private static void StopAndDispose(IMcpTransport value) + { + if (value == null) + return; + + try + { + if (value.IsRunning) + value.Stop(); + } + catch (Exception ex) + { + ExplorerCore.LogWarning($"Exception while stopping MCP transport: {ex}"); + } + + try + { + value.Dispose(); + } + catch (Exception ex) + { + ExplorerCore.LogWarning($"Exception while disposing MCP transport: {ex}"); + } + } + + private static void NotifyStateChanged() + { + try + { + StateChanged?.Invoke(); + } + catch (Exception ex) + { + ExplorerCore.LogWarning($"MCP StateChanged subscriber failed: {ex}"); + } + } + + private sealed class LoggingDispatcher : IMcpRequestDispatcher + { + private readonly IMcpRequestDispatcher inner; + + public LoggingDispatcher(IMcpRequestDispatcher inner) + { + this.inner = inner; + } + + public McpResponse Dispatch(McpRequest request) + { + RecordRequest($"{DateTime.UtcNow:O} {request.RemoteAddress} {request.Method}"); + return inner.Dispatch(request); + } + } + private static void ThrowIfShutdown() + { + if (shuttingDown) + throw new InvalidOperationException("MCP manager has already been shut down."); + } + + private static McpTransportMode ParseTransportMode(string value) + { + string normalized = (value ?? string.Empty) + .Trim() + .Replace(" ", string.Empty) + .Replace("-", string.Empty) + .Replace("_", string.Empty); + + if (normalized.Equals("SSE", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("LegacySSE", StringComparison.OrdinalIgnoreCase)) + return McpTransportMode.SSE; + if (normalized.Equals("StreamableHTTP", StringComparison.OrdinalIgnoreCase)) + return McpTransportMode.StreamableHTTP; + + throw new ArgumentException( + "MCP transport mode must be 'SSE' or 'Streamable HTTP'.", + nameof(value)); + } + + private static McpHttpTransportMode ToHttpTransportMode(McpTransportMode value) + { + switch (value) + { + case McpTransportMode.SSE: + return McpHttpTransportMode.LegacySse; + case McpTransportMode.StreamableHTTP: + return McpHttpTransportMode.StreamableHttp; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, "Unsupported MCP transport mode."); + } + } + + private static string ToConfiguredTransportMode(McpTransportMode value) + { + switch (value) + { + case McpTransportMode.SSE: + return "SSE"; + case McpTransportMode.StreamableHTTP: + return "Streamable HTTP"; + default: + return value.ToString(); + } + } + + private static string ToConfiguredTransportMode(McpHttpTransportMode value) + { + switch (value) + { + case McpHttpTransportMode.LegacySse: + return "SSE"; + case McpHttpTransportMode.StreamableHttp: + return "Streamable HTTP"; + default: + return value.ToString(); + } + } + private static string NormalizeDisplayPath(string value) + { + if (string.IsNullOrEmpty(value)) + return "/"; + return value[0] == '/' ? value : "/" + value; + } + } +} diff --git a/src/MCP/Runtime/McpGameCapabilityExecutor.cs b/src/MCP/Runtime/McpGameCapabilityExecutor.cs new file mode 100644 index 000000000..973efc760 --- /dev/null +++ b/src/MCP/Runtime/McpGameCapabilityExecutor.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityExplorer.MCP.Transport; +using UnityExplorer.Config; + +namespace UnityExplorer.MCP.Runtime +{ + /// Main-thread Unity game capability executor. + public sealed partial class McpGameCapabilityExecutor : IMcpRequestHandler + { + private static readonly string[] RoutedMethods = { "game.execute", "game.status", "game.list_scenes", "game.search", "game.snapshot", "game.get_member", "game.list_methods", "game.set_member", "game.set_transform", "game.invoke", "game.set_enabled", "game.create", "game.destroy", "game.batch", "get_status", "list_scenes", "search_objects", "get_object", "get_member", "list_methods", "set_member", "set_transform", "invoke_method", "set_enabled", "create_object", "destroy_object", "execute_batch" }; + private readonly McpObjectRegistry registry; + private readonly McpGameExecutorOptions options; + private readonly McpValueCodec codec; + + public McpGameCapabilityExecutor() : this(new McpObjectRegistry(), new McpGameExecutorOptions()) { } + public McpGameCapabilityExecutor(McpObjectRegistry registry, McpGameExecutorOptions options) + { + if (registry == null) throw new ArgumentNullException("registry"); + if (options == null) throw new ArgumentNullException("options"); + options.Validate(); this.registry = registry; this.options = options; codec = new McpValueCodec(registry, options); + } + public McpObjectRegistry Registry { get { return registry; } } + public McpGameExecutorOptions Options { get { return options; } } + public static IList SupportedMethods { get { return Array.AsReadOnly(RoutedMethods); } } + + public void Register(McpRequestRouter router) { if (router == null) throw new ArgumentNullException("router"); for (int i = 0; i < RoutedMethods.Length; i++) router.Register(RoutedMethods[i], this); } + public void Unregister(McpRequestRouter router) { if (router != null) for (int i = 0; i < RoutedMethods.Length; i++) router.Unregister(RoutedMethods[i]); } + + public string Execute(string json) + { + McpJsonValue command; string error; + return !McpJsonValue.TryParse(json, out command, out error) ? Failure("invalid_json", error).ToJson() : Execute(command).ToJson(); + } + public McpJsonValue Execute(McpJsonValue command) { return ExecuteInternal(command, 0); } + + public McpResponse Handle(McpRequest request) + { + if (request == null) return McpResponse.Error(-32600, "Request is required."); + try + { + McpJsonValue root = McpJsonValue.Parse(request.RawJson), parameters; + if (!root.TryGet("params", out parameters) || parameters == null || parameters.IsNull) parameters = McpJsonValue.Object(); + if (parameters.Kind != McpJsonValue.JsonKind.Object) return McpResponse.Error(-32602, "params must be a JSON object."); + parameters = NormalizeParameters(parameters); + if (request.Method != "game.execute") { parameters.ObjectValue["command"] = McpJsonValue.From(CommandForMethod(request.Method)); } + return McpResponse.SuccessJson(ExecuteInternal(parameters, 0).ToJson()); + } + catch (Exception ex) { return McpResponse.Error(-32603, McpReflection.Unwrap(ex).Message); } + } + + private McpJsonValue ExecuteInternal(McpJsonValue command, int batchDepth) + { + if (command == null || command.Kind != McpJsonValue.JsonKind.Object) return Failure("invalid_command", "Command must be a JSON object."); + command = NormalizeParameters(command); + string name = command.GetString("command", null); + if (string.IsNullOrEmpty(name)) return Failure("invalid_command", "The 'command' string is required."); + name = CommandForMethod(name.Trim().ToLowerInvariant()); + try + { + EnforcePolicy(name); + McpJsonValue result; + switch (name) + { + case "status": result = Status(); break; case "list_scenes": result = ListScenes(command); break; + case "search": result = Search(command); break; case "snapshot": result = Snapshot(command); break; + case "get_member": result = GetMember(command); break; case "list_methods": result = ListMethods(command); break; + case "set_member": result = SetMember(command); break; case "set_transform": result = SetTransform(command); break; + case "invoke": result = Invoke(command); break; + case "set_enabled": result = SetEnabled(command); break; case "create": result = Create(command); break; + case "destroy": result = Destroy(command); break; case "batch": result = Batch(command, batchDepth); break; + default: return Failure("unknown_command", "Unsupported command: " + name); + } + return Success(name, result); + } + catch (McpCommandException ex) { return Failure(ex.Code, ex.Message); } + catch (Exception ex) { Exception actual = McpReflection.Unwrap(ex); return Failure("execution_failed", actual.GetType().Name + ": " + actual.Message); } + } + + private McpJsonValue Status() + { + int removed = registry.Prune(); McpJsonValue r = McpJsonValue.Object(); + r.ObjectValue["ready"] = McpJsonValue.From(true); r.ObjectValue["mainThreadRequired"] = McpJsonValue.From(true); + r.ObjectValue["unityVersion"] = McpJsonValue.From(Application.unityVersion); r.ObjectValue["platform"] = McpJsonValue.From(Application.platform.ToString()); + r.ObjectValue["application"] = McpJsonValue.From(Application.productName); r.ObjectValue["sceneCount"] = McpJsonValue.From((double)SceneManager.sceneCount); + r.ObjectValue["registryCount"] = McpJsonValue.From((double)registry.Count); r.ObjectValue["prunedObjectIds"] = McpJsonValue.From((double)removed); +#if CPP + r.ObjectValue["runtime"] = McpJsonValue.From("IL2CPP"); +#else + r.ObjectValue["runtime"] = McpJsonValue.From("Mono"); +#endif + r.ObjectValue["capabilities"] = McpJsonValue.FromObject(new string[] { "scenes", "search", "snapshot", "fields", "properties", "transform", "methods", "enable", "create", "destroy", "batch" }); return r; + } + private McpJsonValue Batch(McpJsonValue command, int depth) + { + if (depth >= 4) throw new McpCommandException("batch_depth", "Nested batches are limited to depth 4."); + McpJsonValue atomic; + if (command.TryGet("atomic", out atomic)) + { + if (atomic.Kind != McpJsonValue.JsonKind.Boolean) throw new McpCommandException("invalid_command", "atomic must be a boolean."); + if (atomic.BooleanValue) throw new McpCommandException("atomic_not_supported", "atomic=true is not supported because the game bridge cannot roll back Unity mutations."); + } + McpJsonValue commands; + if (!command.TryGet("commands", out commands)) + { + McpJsonValue operations; + if (!command.TryGet("operations", out operations) || operations.Kind != McpJsonValue.JsonKind.Array) throw new McpCommandException("invalid_command", "commands or operations must be an array."); + commands = ConvertOperations(operations); + } + if (commands.Kind != McpJsonValue.JsonKind.Array) throw new McpCommandException("invalid_command", "commands must be an array."); + if (commands.ArrayValue.Count > options.MaximumBatchCommands) throw new McpCommandException("batch_limit", "Batch exceeds the configured command limit."); + bool stop = command.GetBoolean("stopOnError", true); McpJsonValue results = McpJsonValue.Array(); + for (int i = 0; i < commands.ArrayValue.Count; i++) + { + McpJsonValue item = ExecuteInternal(commands.ArrayValue[i], depth + 1); results.ArrayValue.Add(item); McpJsonValue ok; + if (stop && item.TryGet("ok", out ok) && ok.Kind == McpJsonValue.JsonKind.Boolean && !ok.BooleanValue) break; + } + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["results"] = results; r.ObjectValue["executed"] = McpJsonValue.From((double)results.ArrayValue.Count); r.ObjectValue["requested"] = McpJsonValue.From((double)commands.ArrayValue.Count); return r; + } + + private McpJsonValue ConvertOperations(McpJsonValue operations) + { + McpJsonValue commands = McpJsonValue.Array(); + for (int i = 0; i < operations.ArrayValue.Count; i++) + { + McpJsonValue operation = operations.ArrayValue[i]; + if (operation == null || operation.Kind != McpJsonValue.JsonKind.Object) throw new McpCommandException("invalid_command", "operations[" + i + "] must be an object."); + string method = operation.GetString("method", null); + if (string.IsNullOrEmpty(method)) throw new McpCommandException("invalid_command", "operations[" + i + "].method is required."); + string commandName = CommandForMethod(method.Trim().ToLowerInvariant()); + if (commandName == "batch" || commandName == "execute") throw new McpCommandException("invalid_command", "Nested execute_batch operations are not supported."); + McpJsonValue parameters; + if (!operation.TryGet("params", out parameters) || parameters == null || parameters.IsNull) parameters = McpJsonValue.Object(); + if (parameters.Kind != McpJsonValue.JsonKind.Object) throw new McpCommandException("invalid_command", "operations[" + i + "].params must be an object."); + McpJsonValue converted = NormalizeParameters(parameters); + converted.ObjectValue["command"] = McpJsonValue.From(commandName); + commands.ArrayValue.Add(converted); + } + return commands; + } + + private McpJsonValue ListMethods(McpJsonValue command) + { + object target; Type type; ResolveTarget(command, out target, out type); + string filter = command.GetString("filter", null); + int limit = command.GetInt32("limit", options.MaximumMembersPerObject, 1, options.MaximumSerializedItems); + bool includeNonPublic = options.IncludeNonPublicMembers && command.GetBoolean("includeNonPublic", true); + bool includeInherited = command.GetBoolean("includeInherited", true); + bool includeStatic = command.GetBoolean("includeStatic", true); + BindingFlags flags = McpReflection.Flags(includeNonPublic); + if (!includeInherited) flags |= BindingFlags.DeclaredOnly; + MethodInfo[] methods = type.GetMethods(flags); + Array.Sort(methods, delegate(MethodInfo a, MethodInfo b) { return string.CompareOrdinal(MethodSignature(a), MethodSignature(b)); }); + McpJsonValue entries = McpJsonValue.Array(); + for (int i = 0; i < methods.Length && entries.ArrayValue.Count < limit; i++) + { + MethodInfo method = methods[i]; + if (method.ContainsGenericParameters || McpReflection.IsUnsafeMember(method) || (target == null && !method.IsStatic) || (!includeStatic && method.IsStatic)) continue; + if (!string.IsNullOrEmpty(filter) && method.Name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) continue; + McpJsonValue entry = McpJsonValue.Object(); + entry.ObjectValue["name"] = McpJsonValue.From(method.Name); + entry.ObjectValue["signature"] = McpJsonValue.From(MethodSignature(method)); + entry.ObjectValue["returnType"] = McpJsonValue.From(method.ReturnType.FullName); + entry.ObjectValue["static"] = McpJsonValue.From(method.IsStatic); + entry.ObjectValue["public"] = McpJsonValue.From(method.IsPublic); + McpJsonValue parameters = McpJsonValue.Array(); + ParameterInfo[] methodParameters = method.GetParameters(); + for (int p = 0; p < methodParameters.Length; p++) + { + ParameterInfo parameter = methodParameters[p]; + McpJsonValue parameterEntry = McpJsonValue.Object(); + parameterEntry.ObjectValue["name"] = McpJsonValue.From(parameter.Name); + parameterEntry.ObjectValue["type"] = McpJsonValue.From(parameter.ParameterType.FullName); + parameterEntry.ObjectValue["optional"] = McpJsonValue.From(parameter.IsOptional); + parameterEntry.ObjectValue["out"] = McpJsonValue.From(parameter.IsOut); + parameters.ArrayValue.Add(parameterEntry); + } + entry.ObjectValue["parameters"] = parameters; + entries.ArrayValue.Add(entry); + } + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["type"] = McpJsonValue.From(type.FullName); + result.ObjectValue["methods"] = entries; + result.ObjectValue["count"] = McpJsonValue.From((double)entries.ArrayValue.Count); + result.ObjectValue["truncated"] = McpJsonValue.From(entries.ArrayValue.Count >= limit && methods.Length > entries.ArrayValue.Count); + return result; + } + private void ResolveTarget(McpJsonValue command, out object target, out Type type) + { + string id = command.GetString("objectId", null), typeName = command.GetString("type", null); + // Unity IL2CPP APIs frequently return a Component through a base wrapper such as + // UnityEngine.Behaviour. GetActualType asks UniverseLib for the native IL2CPP class, + // otherwise members declared by the generated Board wrapper are invisible here. + if (!string.IsNullOrEmpty(id)) { target = registry.Resolve(id); type = McpReflection.GetActualType(target); return; } + if (!string.IsNullOrEmpty(typeName)) { target = null; type = McpReflection.FindType(typeName); return; } + throw new McpCommandException("invalid_target", "Either objectId or type is required."); + } + + private static string CommandForMethod(string method) + { + switch (method) + { + case "get_status": return "status"; + case "list_scenes": return "list_scenes"; + case "search_objects": return "search"; + case "get_object": return "snapshot"; + case "get_member": return "get_member"; + case "list_methods": return "list_methods"; + case "set_member": return "set_member"; + case "set_transform": return "set_transform"; + case "invoke_method": return "invoke"; + case "set_enabled": return "set_enabled"; + case "create_object": return "create"; + case "destroy_object": return "destroy"; + case "execute_batch": return "batch"; + default: return method.StartsWith("game.", StringComparison.Ordinal) ? method.Substring(5) : method; + } + } + + private static McpJsonValue NormalizeParameters(McpJsonValue source) + { + McpJsonValue copy = CloneObject(source); + CopyAlias(copy, "object_id", "objectId"); CopyAlias(copy, "id", "objectId"); + CopyAlias(copy, "type_name", "type"); CopyAlias(copy, "object_type", "type"); + CopyAlias(copy, "query", "name"); CopyAlias(copy, "max_results", "limit"); CopyAlias(copy, "exact", "exactName"); + CopyAlias(copy, "name_filter", "filter"); CopyAlias(copy, "include_non_public", "includeNonPublic"); + CopyAlias(copy, "include_inherited", "includeInherited"); CopyAlias(copy, "include_static", "includeStatic"); + CopyAlias(copy, "member_name", "member"); CopyAlias(copy, "member_path", "member"); + CopyAlias(copy, "method_name", "method"); CopyAlias(copy, "arguments", "args"); + CopyAlias(copy, "stop_on_error", "stopOnError"); CopyAlias(copy, "max_depth", "depth"); + CopyAlias(copy, "max_items", "maxItems"); CopyAlias(copy, "exact_name", "exactName"); + CopyAlias(copy, "include_explorer", "includeExplorer"); CopyAlias(copy, "parent_id", "parentId"); + CopyAlias(copy, "world_position_stays", "worldPositionStays"); + CopyAlias(copy, "local_position", "localPosition"); CopyAlias(copy, "local_rotation", "localRotation"); + CopyAlias(copy, "euler_angles", "eulerAngles"); CopyAlias(copy, "local_euler_angles", "localEulerAngles"); + CopyAlias(copy, "local_scale", "localScale"); + return copy; + } + + private static void CopyAlias(McpJsonValue value, string alias, string canonical) + { + McpJsonValue item, existing; + if (value.TryGet(alias, out item) && !value.TryGet(canonical, out existing)) value.ObjectValue[canonical] = item; + } + + private static void EnforcePolicy(string command) + { + bool write = command == "set_member" || command == "set_transform" || command == "set_enabled" || command == "invoke" || command == "create" || command == "destroy"; + if (write && ConfigManager.MCP_Read_Only != null && ConfigManager.MCP_Read_Only.Value) + throw new McpCommandException("read_only", "MCP is in read-only mode; mutation command blocked: " + command); + bool dangerous = command == "invoke" || command == "create" || command == "destroy"; + if (dangerous && (ConfigManager.MCP_Allow_Dangerous_Operations == null || !ConfigManager.MCP_Allow_Dangerous_Operations.Value)) + throw new McpCommandException("dangerous_operations_disabled", "Dangerous MCP operations are disabled: " + command); + } + private static string RequiredString(McpJsonValue command, string name) + { + string value = command.GetString(name, null); if (string.IsNullOrEmpty(value)) throw new McpCommandException("invalid_command", "The '" + name + "' string is required."); return value; + } + private static bool RequiredBoolean(McpJsonValue command, string name) + { + McpJsonValue value; if (!command.TryGet(name, out value) || value.Kind != McpJsonValue.JsonKind.Boolean) throw new McpCommandException("invalid_command", "The '" + name + "' boolean is required."); return value.BooleanValue; + } + private static McpJsonValue Success(string command, McpJsonValue result) + { + McpJsonValue e = McpJsonValue.Object(); e.ObjectValue["ok"] = McpJsonValue.From(true); e.ObjectValue["command"] = McpJsonValue.From(command); e.ObjectValue["result"] = result ?? McpJsonValue.Null(); return e; + } + private static McpJsonValue Failure(string code, string message) + { + McpJsonValue error = McpJsonValue.Object(); error.ObjectValue["code"] = McpJsonValue.From(code); error.ObjectValue["message"] = McpJsonValue.From(message ?? string.Empty); + McpJsonValue e = McpJsonValue.Object(); e.ObjectValue["ok"] = McpJsonValue.From(false); e.ObjectValue["error"] = error; return e; + } + private static McpJsonValue CloneObject(McpJsonValue source) + { + McpJsonValue copy = McpJsonValue.Object(); foreach (KeyValuePair pair in source.ObjectValue) copy.ObjectValue[pair.Key] = pair.Value; return copy; + } + } +} diff --git a/src/MCP/Runtime/McpGameInvokeCommand.cs b/src/MCP/Runtime/McpGameInvokeCommand.cs new file mode 100644 index 000000000..53098f5cd --- /dev/null +++ b/src/MCP/Runtime/McpGameInvokeCommand.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace UnityExplorer.MCP.Runtime +{ + public sealed partial class McpGameCapabilityExecutor + { + private McpJsonValue Invoke(McpJsonValue command) + { + if (!options.AllowMethodInvocation) throw new McpCommandException("disabled", "Method invocation is disabled by policy."); + object target; Type type; ResolveTarget(command, out target, out type); string methodName = RequiredString(command, "method"); McpJsonValue argsNode; + if (!command.TryGet("args", out argsNode)) argsNode = McpJsonValue.Array(); if (argsNode.Kind != McpJsonValue.JsonKind.Array) throw new McpCommandException("invalid_command", "args must be an array."); + MethodInfo[] methods = type.GetMethods(McpReflection.Flags(options.IncludeNonPublicMembers)); List failures = new List(); + for (int i = 0; i < methods.Length; i++) + { + MethodInfo method = methods[i]; if (method.Name != methodName || method.ContainsGenericParameters || McpReflection.IsUnsafeMember(method) || (target == null && !method.IsStatic)) continue; + ParameterInfo[] parameters = method.GetParameters(); if (!CanAccept(parameters, argsNode.ArrayValue.Count)) continue; + try + { + object[] args = BuildArguments(parameters, argsNode.ArrayValue); object returnValue = method.Invoke(McpReflection.GetDeclaringInstance(method, target), args); McpJsonValue r = McpJsonValue.Object(); + r.ObjectValue["method"] = McpJsonValue.From(MethodSignature(method)); r.ObjectValue["returnValue"] = method.ReturnType == typeof(void) ? McpJsonValue.Null() : codec.Serialize(returnValue, 2, options.MaximumSerializedItems); + McpJsonValue outArgs = McpJsonValue.Array(); for (int p = 0; p < parameters.Length; p++) if (parameters[p].ParameterType.IsByRef || parameters[p].IsOut) outArgs.ArrayValue.Add(codec.Serialize(args[p], 1, 32)); + if (outArgs.ArrayValue.Count > 0) r.ObjectValue["outArguments"] = outArgs; return r; + } + catch (Exception ex) { failures.Add(MethodSignature(method) + ": " + McpReflection.Unwrap(ex).Message); } + } + throw new McpCommandException("method_not_found", "No compatible overload succeeded for " + type.FullName + "." + methodName + (failures.Count == 0 ? "." : ". " + string.Join(" | ", failures.ToArray()))); + } + + private object[] BuildArguments(ParameterInfo[] parameters, IList supplied) + { + object[] args = new object[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + { + Type t = parameters[i].ParameterType; if (t.IsByRef) t = t.GetElementType(); + if (i < supplied.Count) args[i] = codec.ConvertTo(supplied[i], t); else if (parameters[i].IsOptional) args[i] = parameters[i].DefaultValue; else if (parameters[i].IsOut) args[i] = t.IsValueType ? Activator.CreateInstance(t) : null; else throw new McpCommandException("argument_count", "Missing argument " + parameters[i].Name + "."); + } + return args; + } + private static bool CanAccept(ParameterInfo[] parameters, int supplied) + { + if (supplied > parameters.Length) return false; for (int i = supplied; i < parameters.Length; i++) if (!parameters[i].IsOptional && !parameters[i].IsOut) return false; return true; + } + private static string MethodSignature(MethodInfo method) + { + ParameterInfo[] parameters = method.GetParameters(); string[] names = new string[parameters.Length]; for (int i = 0; i < parameters.Length; i++) names[i] = parameters[i].ParameterType.FullName; return method.DeclaringType.FullName + "." + method.Name + "(" + string.Join(",", names) + ")"; + } + } +} diff --git a/src/MCP/Runtime/McpGameLifecycleCommands.cs b/src/MCP/Runtime/McpGameLifecycleCommands.cs new file mode 100644 index 000000000..0f1024fcb --- /dev/null +++ b/src/MCP/Runtime/McpGameLifecycleCommands.cs @@ -0,0 +1,60 @@ +using System; +using UnityEngine; + +namespace UnityExplorer.MCP.Runtime +{ + public sealed partial class McpGameCapabilityExecutor + { + private McpJsonValue Create(McpJsonValue command) + { + if (!options.AllowObjectCreation) throw new McpCommandException("disabled", "Object creation is disabled by policy."); + string name = command.GetString("name", "MCP GameObject"), primitiveName = command.GetString("primitive", null); GameObject go; + if (!string.IsNullOrEmpty(primitiveName)) + { + PrimitiveType primitive; try { primitive = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveName, true); } catch { throw new McpCommandException("invalid_primitive", "Unknown PrimitiveType: " + primitiveName); } + go = GameObject.CreatePrimitive(primitive); go.name = name; + } + else go = new GameObject(name); + try + { + string parentId = command.GetString("parentId", null); + if (!string.IsNullOrEmpty(parentId)) + { + object p = registry.Resolve(parentId); GameObject pg = p as GameObject; Component pc = p as Component; Transform parent = p as Transform; + if (parent == null && pg != null) parent = pg.transform; if (parent == null && pc != null) parent = pc.transform; if (parent == null) throw new McpCommandException("invalid_target", "parentId is not a Transform or GameObject."); + go.transform.SetParent(parent, command.GetBoolean("worldPositionStays", false)); + } + McpJsonValue components, added = McpJsonValue.Array(); + if (command.TryGet("components", out components)) + { + if (components.Kind != McpJsonValue.JsonKind.Array) throw new McpCommandException("invalid_command", "components must be an array of type names."); + for (int i = 0; i < components.ArrayValue.Count; i++) + { + if (components.ArrayValue[i].Kind != McpJsonValue.JsonKind.String) throw new McpCommandException("invalid_command", "Each component must be a type name string."); + Type componentType = McpReflection.FindType(components.ArrayValue[i].StringValue); if (!typeof(Component).IsAssignableFrom(componentType)) throw new McpCommandException("invalid_type", componentType.FullName + " is not a Component."); + #if CPP && INTEROP + Component component = go.AddComponent(Il2CppInterop.Runtime.Il2CppType.From(componentType)).TryCast(); +#elif CPP && UNHOLLOWER + Component component = go.AddComponent(UnhollowerRuntimeLib.Il2CppType.From(componentType)).Cast(); +#else + Component component = go.AddComponent(componentType); +#endif + added.ArrayValue.Add(ObjectSummary(component, go)); + } + } + McpJsonValue result = ObjectSummary(go, go); result.ObjectValue["componentsAdded"] = added; return result; + } + catch { UnityEngine.Object.Destroy(go); throw; } + } + + private McpJsonValue Destroy(McpJsonValue command) + { + if (!options.AllowObjectDestruction) throw new McpCommandException("disabled", "Object destruction is disabled by policy."); + string id = RequiredString(command, "objectId"); object target = registry.Resolve(id); UnityEngine.Object unityObject = target as UnityEngine.Object; + if (unityObject == null) throw new McpCommandException("invalid_target", "Only UnityEngine.Object instances can be destroyed."); + bool immediate = command.GetBoolean("immediate", false); string name = unityObject.name, type = unityObject.GetType().FullName; + if (immediate) UnityEngine.Object.DestroyImmediate(unityObject); else UnityEngine.Object.Destroy(unityObject); registry.Forget(id); + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["objectId"] = McpJsonValue.From(id); r.ObjectValue["name"] = McpJsonValue.From(name); r.ObjectValue["type"] = McpJsonValue.From(type); r.ObjectValue["immediate"] = McpJsonValue.From(immediate); return r; + } + } +} diff --git a/src/MCP/Runtime/McpGameMutationCommands.cs b/src/MCP/Runtime/McpGameMutationCommands.cs new file mode 100644 index 000000000..22ecb8a81 --- /dev/null +++ b/src/MCP/Runtime/McpGameMutationCommands.cs @@ -0,0 +1,51 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace UnityExplorer.MCP.Runtime +{ + public sealed partial class McpGameCapabilityExecutor + { + private McpJsonValue SetMember(McpJsonValue command) + { + object target; Type type; ResolveTarget(command, out target, out type); string path = RequiredString(command, "member"); McpJsonValue input; + if (!command.TryGet("value", out input)) throw new McpCommandException("invalid_command", "The 'value' property is required."); + object value = McpMemberPath.Write(target, type, path, input, codec, options.IncludeNonPublicMembers); + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["member"] = McpJsonValue.From(path); r.ObjectValue["value"] = codec.Serialize(value, 1, 32); return r; + } + + private McpJsonValue SetTransform(McpJsonValue command) + { + object resolved = registry.Resolve(RequiredString(command, "objectId")); GameObject go = resolved as GameObject; Component component = resolved as Component; Transform t = resolved as Transform; + if (t == null && go != null) t = go.transform; if (t == null && component != null) t = component.transform; if (t == null) throw new McpCommandException("invalid_target", "Target does not have a Transform."); + McpJsonValue v; + if (command.TryGet("parentId", out v)) + { + Transform parent = null; + if (!v.IsNull) { object p = registry.Resolve(v.StringValue); GameObject pg = p as GameObject; Component pc = p as Component; parent = p as Transform; if (parent == null && pg != null) parent = pg.transform; if (parent == null && pc != null) parent = pc.transform; if (parent == null) throw new McpCommandException("invalid_target", "parentId is not a Transform or GameObject."); } + t.SetParent(parent, command.GetBoolean("worldPositionStays", true)); + } + if (command.TryGet("position", out v)) t.position = (Vector3)codec.ConvertTo(v, typeof(Vector3)); if (command.TryGet("localPosition", out v)) t.localPosition = (Vector3)codec.ConvertTo(v, typeof(Vector3)); + if (command.TryGet("rotation", out v)) t.rotation = (Quaternion)codec.ConvertTo(v, typeof(Quaternion)); if (command.TryGet("localRotation", out v)) t.localRotation = (Quaternion)codec.ConvertTo(v, typeof(Quaternion)); + if (command.TryGet("eulerAngles", out v)) t.eulerAngles = (Vector3)codec.ConvertTo(v, typeof(Vector3)); if (command.TryGet("localEulerAngles", out v)) t.localEulerAngles = (Vector3)codec.ConvertTo(v, typeof(Vector3)); if (command.TryGet("localScale", out v)) t.localScale = (Vector3)codec.ConvertTo(v, typeof(Vector3)); + return TransformSummary(t); + } + + private McpJsonValue SetEnabled(McpJsonValue command) + { + object target = registry.Resolve(RequiredString(command, "objectId")); bool enabled = RequiredBoolean(command, "enabled"); GameObject go = target as GameObject; + if (go != null) { go.SetActive(enabled); return ObjectSummary(go, go); } + Behaviour behaviour = target as Behaviour; if (behaviour != null) { behaviour.enabled = enabled; return ObjectSummary(behaviour, behaviour.gameObject); } + MemberInfo member = McpReflection.FindWritableMember(McpReflection.GetActualType(target), "enabled", options.IncludeNonPublicMembers); + if (member == null || McpReflection.GetMemberType(member) != typeof(bool)) throw new McpCommandException("invalid_target", "Target has no writable bool enabled property."); + McpReflection.SetMemberValue(member, target, enabled); return codec.Serialize(target, 1, 32); + } + + private McpJsonValue TransformSummary(Transform t) + { + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(t)); r.ObjectValue["gameObjectId"] = McpJsonValue.From(registry.Register(t.gameObject)); + r.ObjectValue["position"] = codec.Serialize(t.position, 0, 16); r.ObjectValue["localPosition"] = codec.Serialize(t.localPosition, 0, 16); r.ObjectValue["rotation"] = codec.Serialize(t.rotation, 0, 16); r.ObjectValue["localRotation"] = codec.Serialize(t.localRotation, 0, 16); + r.ObjectValue["eulerAngles"] = codec.Serialize(t.eulerAngles, 0, 16); r.ObjectValue["localEulerAngles"] = codec.Serialize(t.localEulerAngles, 0, 16); r.ObjectValue["localScale"] = codec.Serialize(t.localScale, 0, 16); r.ObjectValue["parentId"] = McpJsonValue.From(t.parent == null ? null : registry.Register(t.parent)); return r; + } + } +} diff --git a/src/MCP/Runtime/McpGameQueryCommands.cs b/src/MCP/Runtime/McpGameQueryCommands.cs new file mode 100644 index 000000000..ed9bf70fb --- /dev/null +++ b/src/MCP/Runtime/McpGameQueryCommands.cs @@ -0,0 +1,74 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace UnityExplorer.MCP.Runtime +{ + public sealed partial class McpGameCapabilityExecutor + { + private McpJsonValue ListScenes(McpJsonValue command) + { + McpJsonValue scenes = McpJsonValue.Array(); Scene active = SceneManager.GetActiveScene(); + int maximum = command.GetInt32("limit", Math.Max(1, SceneManager.sceneCount), 1, options.MaximumSearchResults); + for (int i = 0; i < SceneManager.sceneCount && scenes.ArrayValue.Count < maximum; i++) + { + Scene scene = SceneManager.GetSceneAt(i); McpJsonValue item = McpJsonValue.Object(); + item.ObjectValue["handle"] = McpJsonValue.From((double)scene.handle); item.ObjectValue["name"] = McpJsonValue.From(scene.name); + item.ObjectValue["path"] = McpJsonValue.From(scene.path); item.ObjectValue["buildIndex"] = McpJsonValue.From((double)scene.buildIndex); + item.ObjectValue["loaded"] = McpJsonValue.From(scene.isLoaded); item.ObjectValue["valid"] = McpJsonValue.From(scene.IsValid()); item.ObjectValue["active"] = McpJsonValue.From(scene == active); + try { item.ObjectValue["rootCount"] = McpJsonValue.From((double)RuntimeHelper.GetRootGameObjects(scene).Count()); } catch { item.ObjectValue["rootCount"] = McpJsonValue.Null(); } + scenes.ArrayValue.Add(item); + } + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["scenes"] = scenes; r.ObjectValue["count"] = McpJsonValue.From((double)scenes.ArrayValue.Count); return r; + } + + private McpJsonValue Search(McpJsonValue command) + { + Type type = McpReflection.FindType(command.GetString("type", "UnityEngine.GameObject")); + if (!typeof(UnityEngine.Object).IsAssignableFrom(type)) throw new McpCommandException("invalid_type", "Search type must derive from UnityEngine.Object."); + int limit = command.GetInt32("limit", 50, 1, options.MaximumSearchResults); string name = command.GetString("name", null), sceneName = command.GetString("scene", null), path = command.GetString("path", null); + bool exact = command.GetBoolean("exactName", false), includeExplorer = command.GetBoolean("includeExplorer", false); + UnityEngine.Object[] objects = RuntimeHelper.FindObjectsOfTypeAll(type); McpJsonValue matches = McpJsonValue.Array(); + for (int i = 0; i < objects.Length && matches.ArrayValue.Count < limit; i++) + { + UnityEngine.Object obj = objects[i]; if (obj == null || !Matches(obj.name, name, exact)) continue; + GameObject go = obj as GameObject; Component component = obj as Component; if (go == null && component != null) go = component.gameObject; + if (go != null) + { + if (!includeExplorer && go.transform.root != null && go.transform.root.name == "UniverseLibCanvas") continue; + if (!string.IsNullOrEmpty(sceneName) && !string.Equals(go.scene.name, sceneName, StringComparison.OrdinalIgnoreCase) && go.scene.handle.ToString() != sceneName) continue; + string objectPath = McpReflection.GetGameObjectPath(go); if (!string.IsNullOrEmpty(path) && objectPath.IndexOf(path, StringComparison.OrdinalIgnoreCase) < 0) continue; + } + matches.ArrayValue.Add(ObjectSummary(obj, go)); + } + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["objects"] = matches; r.ObjectValue["count"] = McpJsonValue.From((double)matches.ArrayValue.Count); r.ObjectValue["scanned"] = McpJsonValue.From((double)objects.Length); r.ObjectValue["limited"] = McpJsonValue.From(matches.ArrayValue.Count >= limit); return r; + } + + private McpJsonValue Snapshot(McpJsonValue command) + { + object target = registry.Resolve(RequiredString(command, "objectId")); + int depth = command.GetInt32("depth", 2, 0, options.MaximumSnapshotDepth), maxItems = command.GetInt32("maxItems", options.MaximumSerializedItems, 1, options.MaximumSerializedItems); + return codec.Serialize(target, depth, maxItems); + } + + private McpJsonValue GetMember(McpJsonValue command) + { + object target; Type type; ResolveTarget(command, out target, out type); + object value = McpMemberPath.Read(target, type, RequiredString(command, "member"), options.IncludeNonPublicMembers); + return codec.Serialize(value, command.GetInt32("depth", 2, 0, options.MaximumSnapshotDepth), options.MaximumSerializedItems); + } + + private McpJsonValue ObjectSummary(UnityEngine.Object obj, GameObject go) + { + McpJsonValue r = McpJsonValue.Object(); r.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(obj)); r.ObjectValue["instanceId"] = McpJsonValue.From((double)obj.GetInstanceID()); r.ObjectValue["name"] = McpJsonValue.From(obj.name); r.ObjectValue["type"] = McpJsonValue.From(McpReflection.GetActualType(obj).FullName); + if (go != null) { r.ObjectValue["gameObjectId"] = McpJsonValue.From(registry.Register(go)); r.ObjectValue["path"] = McpJsonValue.From(McpReflection.GetGameObjectPath(go)); r.ObjectValue["scene"] = McpJsonValue.From(go.scene.name); r.ObjectValue["activeSelf"] = McpJsonValue.From(go.activeSelf); r.ObjectValue["activeInHierarchy"] = McpJsonValue.From(go.activeInHierarchy); } + return r; + } + + private static bool Matches(string candidate, string filter, bool exact) + { + if (string.IsNullOrEmpty(filter)) return true; candidate = candidate ?? string.Empty; + return exact ? string.Equals(candidate, filter, StringComparison.OrdinalIgnoreCase) : candidate.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + } + } +} diff --git a/src/MCP/Runtime/McpJsonValue.cs b/src/MCP/Runtime/McpJsonValue.cs new file mode 100644 index 000000000..e8342d06d --- /dev/null +++ b/src/MCP/Runtime/McpJsonValue.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace UnityExplorer.MCP.Runtime +{ + /// Dependency-free JSON value used by the game capability executor. + public sealed class McpJsonValue + { + public enum JsonKind { Null, Boolean, Number, String, Array, Object } + + private readonly object value; + + private McpJsonValue(JsonKind kind, object value) + { + Kind = kind; + this.value = value; + } + + public JsonKind Kind { get; private set; } + public bool IsNull { get { return Kind == JsonKind.Null; } } + public bool BooleanValue { get { return (bool)value; } } + public double NumberValue { get { return (double)value; } } + public string StringValue { get { return (string)value; } } + public IList ArrayValue { get { return (IList)value; } } + public IDictionary ObjectValue { get { return (IDictionary)value; } } + + public McpJsonValue this[string key] + { + get + { + McpJsonValue result; + return Kind == JsonKind.Object && ObjectValue.TryGetValue(key, out result) ? result : Null(); + } + } + + public static McpJsonValue Null() { return new McpJsonValue(JsonKind.Null, null); } + public static McpJsonValue From(bool value) { return new McpJsonValue(JsonKind.Boolean, value); } + public static McpJsonValue From(double value) { return new McpJsonValue(JsonKind.Number, value); } + public static McpJsonValue From(string value) { return value == null ? Null() : new McpJsonValue(JsonKind.String, value); } + public static McpJsonValue Array() { return new McpJsonValue(JsonKind.Array, new List()); } + public static McpJsonValue Object() { return new McpJsonValue(JsonKind.Object, new Dictionary(StringComparer.Ordinal)); } + + public static McpJsonValue FromObject(object input) + { + if (input == null) return Null(); + McpJsonValue json = input as McpJsonValue; + if (json != null) return json; + if (input is string) return From((string)input); + if (input is char) return From(input.ToString()); + if (input is bool) return From((bool)input); + if (input is Enum) return From(input.ToString()); + if (IsNumber(input)) return From(Convert.ToDouble(input, CultureInfo.InvariantCulture)); + + IDictionary dictionary = input as IDictionary; + if (dictionary != null) + { + McpJsonValue obj = Object(); + foreach (DictionaryEntry entry in dictionary) + obj.ObjectValue[Convert.ToString(entry.Key, CultureInfo.InvariantCulture)] = FromObject(entry.Value); + return obj; + } + + IEnumerable sequence = input as IEnumerable; + if (sequence != null) + { + McpJsonValue array = Array(); + foreach (object item in sequence) + array.ArrayValue.Add(FromObject(item)); + return array; + } + + return From(Convert.ToString(input, CultureInfo.InvariantCulture)); + } + + public bool TryGet(string key, out McpJsonValue result) + { + if (Kind == JsonKind.Object) + return ObjectValue.TryGetValue(key, out result); + result = null; + return false; + } + + public string GetString(string key, string defaultValue) + { + McpJsonValue item; + return TryGet(key, out item) && item.Kind == JsonKind.String ? item.StringValue : defaultValue; + } + + public bool GetBoolean(string key, bool defaultValue) + { + McpJsonValue item; + return TryGet(key, out item) && item.Kind == JsonKind.Boolean ? item.BooleanValue : defaultValue; + } + + public int GetInt32(string key, int defaultValue, int minimum, int maximum) + { + McpJsonValue item; + if (!TryGet(key, out item) || item.Kind != JsonKind.Number) + return defaultValue; + double number = item.NumberValue; + if (double.IsNaN(number) || double.IsInfinity(number) || number < minimum || number > maximum) + return defaultValue; + return (int)number; + } + + public string ToJson() + { + StringBuilder builder = new StringBuilder(); + Write(builder, this, 0); + return builder.ToString(); + } + + public override string ToString() { return ToJson(); } + + public static McpJsonValue Parse(string json) + { + if (json == null) throw new ArgumentNullException("json"); + Parser parser = new Parser(json); + McpJsonValue result = parser.ReadValue(0); + parser.SkipWhiteSpace(); + if (!parser.AtEnd) throw new FormatException("Unexpected content after JSON value."); + return result; + } + + public static bool TryParse(string json, out McpJsonValue value, out string error) + { + try + { + value = Parse(json); + error = null; + return true; + } + catch (Exception ex) + { + value = null; + error = ex.Message; + return false; + } + } + + private static bool IsNumber(object input) + { + TypeCode code = Type.GetTypeCode(input.GetType()); + return code >= TypeCode.SByte && code <= TypeCode.Decimal; + } + + private static void Write(StringBuilder builder, McpJsonValue node, int depth) + { + if (depth > 128) throw new InvalidOperationException("JSON nesting is too deep."); + switch (node.Kind) + { + case JsonKind.Null: builder.Append("null"); break; + case JsonKind.Boolean: builder.Append(node.BooleanValue ? "true" : "false"); break; + case JsonKind.Number: + if (double.IsNaN(node.NumberValue) || double.IsInfinity(node.NumberValue)) builder.Append("null"); + else builder.Append(node.NumberValue.ToString("R", CultureInfo.InvariantCulture)); + break; + case JsonKind.String: WriteString(builder, node.StringValue); break; + case JsonKind.Array: + builder.Append('['); + for (int i = 0; i < node.ArrayValue.Count; i++) + { + if (i != 0) builder.Append(','); + Write(builder, node.ArrayValue[i] ?? Null(), depth + 1); + } + builder.Append(']'); + break; + case JsonKind.Object: + builder.Append('{'); + bool first = true; + foreach (KeyValuePair pair in node.ObjectValue) + { + if (!first) builder.Append(','); + first = false; + WriteString(builder, pair.Key); + builder.Append(':'); + Write(builder, pair.Value ?? Null(), depth + 1); + } + builder.Append('}'); + break; + } + } + + private static void WriteString(StringBuilder builder, string text) + { + builder.Append('"'); + if (text != null) + { + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + switch (c) + { + case '"': builder.Append("\\\""); break; + case '\\': builder.Append("\\\\"); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (c < 32) builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + else builder.Append(c); + break; + } + } + } + builder.Append('"'); + } + + private sealed class Parser + { + private const int MaximumDepth = 128; + private readonly string json; + private int index; + + internal Parser(string json) { this.json = json; } + internal bool AtEnd { get { return index >= json.Length; } } + + internal void SkipWhiteSpace() + { + while (!AtEnd && (json[index] == ' ' || json[index] == '\t' || json[index] == '\r' || json[index] == '\n')) index++; + } + + internal McpJsonValue ReadValue(int depth) + { + if (depth > MaximumDepth) throw Error("JSON nesting is too deep."); + SkipWhiteSpace(); + if (AtEnd) throw Error("Expected a JSON value."); + char c = json[index]; + if (c == '"') return From(ReadString()); + if (c == '{') return ReadObject(depth + 1); + if (c == '[') return ReadArray(depth + 1); + if (c == 't') { ReadLiteral("true"); return From(true); } + if (c == 'f') { ReadLiteral("false"); return From(false); } + if (c == 'n') { ReadLiteral("null"); return Null(); } + if (c == '-' || (c >= '0' && c <= '9')) return From(ReadNumber()); + throw Error("Invalid JSON value."); + } + + private McpJsonValue ReadObject(int depth) + { + index++; + McpJsonValue result = Object(); + SkipWhiteSpace(); + if (!AtEnd && json[index] == '}') { index++; return result; } + while (true) + { + SkipWhiteSpace(); + if (AtEnd || json[index] != '"') throw Error("Expected object property name."); + string name = ReadString(); + SkipWhiteSpace(); + Require(':'); + result.ObjectValue[name] = ReadValue(depth); + SkipWhiteSpace(); + if (!AtEnd && json[index] == '}') { index++; return result; } + Require(','); + } + } + + private McpJsonValue ReadArray(int depth) + { + index++; + McpJsonValue result = Array(); + SkipWhiteSpace(); + if (!AtEnd && json[index] == ']') { index++; return result; } + while (true) + { + result.ArrayValue.Add(ReadValue(depth)); + SkipWhiteSpace(); + if (!AtEnd && json[index] == ']') { index++; return result; } + Require(','); + } + } + + private string ReadString() + { + Require('"'); + StringBuilder builder = new StringBuilder(); + while (!AtEnd) + { + char c = json[index++]; + if (c == '"') return builder.ToString(); + if (c < 32) throw Error("Control character in JSON string."); + if (c != '\\') { builder.Append(c); continue; } + if (AtEnd) throw Error("Unterminated JSON escape."); + char escape = json[index++]; + switch (escape) + { + case '"': builder.Append('"'); break; + case '\\': builder.Append('\\'); break; + case '/': builder.Append('/'); break; + case 'b': builder.Append('\b'); break; + case 'f': builder.Append('\f'); break; + case 'n': builder.Append('\n'); break; + case 'r': builder.Append('\r'); break; + case 't': builder.Append('\t'); break; + case 'u': builder.Append((char)ReadHex4()); break; + default: throw Error("Invalid JSON escape."); + } + } + throw Error("Unterminated JSON string."); + } + + private int ReadHex4() + { + if (index + 4 > json.Length) throw Error("Invalid Unicode escape."); + int code = 0; + for (int i = 0; i < 4; i++) + { + char c = json[index++]; + int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; + if (digit < 0) throw Error("Invalid Unicode escape."); + code = (code << 4) | digit; + } + return code; + } + + private double ReadNumber() + { + int start = index; + if (json[index] == '-') index++; + if (AtEnd) throw Error("Invalid JSON number."); + if (json[index] == '0') index++; + else + { + if (json[index] < '1' || json[index] > '9') throw Error("Invalid JSON number."); + while (!AtEnd && json[index] >= '0' && json[index] <= '9') index++; + } + if (!AtEnd && json[index] == '.') + { + index++; + int fraction = index; + while (!AtEnd && json[index] >= '0' && json[index] <= '9') index++; + if (fraction == index) throw Error("Invalid JSON number."); + } + if (!AtEnd && (json[index] == 'e' || json[index] == 'E')) + { + index++; + if (!AtEnd && (json[index] == '+' || json[index] == '-')) index++; + int exponent = index; + while (!AtEnd && json[index] >= '0' && json[index] <= '9') index++; + if (exponent == index) throw Error("Invalid JSON number."); + } + double result; + if (!double.TryParse(json.Substring(start, index - start), NumberStyles.Float, CultureInfo.InvariantCulture, out result) || double.IsInfinity(result)) + throw Error("JSON number is out of range."); + return result; + } + + private void ReadLiteral(string literal) + { + if (index + literal.Length > json.Length || string.CompareOrdinal(json, index, literal, 0, literal.Length) != 0) + throw Error("Invalid JSON literal."); + index += literal.Length; + } + + private void Require(char expected) + { + SkipWhiteSpace(); + if (AtEnd || json[index] != expected) throw Error("Expected '" + expected + "'."); + index++; + } + + private FormatException Error(string message) { return new FormatException(message + " At character " + index + "."); } + } + } +} diff --git a/src/MCP/Runtime/McpMemberPath.cs b/src/MCP/Runtime/McpMemberPath.cs new file mode 100644 index 000000000..77b6503ba --- /dev/null +++ b/src/MCP/Runtime/McpMemberPath.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +namespace UnityExplorer.MCP.Runtime +{ + internal static class McpMemberPath + { + private enum TokenKind { Member, Index } + + private sealed class Token + { + internal TokenKind Kind; + internal string Name; + internal int Index; + } + + private sealed class AccessFrame + { + internal object Owner; + internal Type OwnerType; + internal Token Token; + internal Type ValueType; + } + + internal static object Read(object target, Type targetType, string path, bool includeNonPublic) + { + List tokens = Parse(path); + object current = target; + Type currentType = targetType; + for (int i = 0; i < tokens.Count; i++) + { + Type declaredType; + current = ReadToken(current, currentType, tokens[i], includeNonPublic, out declaredType); + currentType = current == null ? declaredType : McpReflection.GetActualType(current); + } + return current; + } + + internal static object Write(object target, Type targetType, string path, McpJsonValue input, McpValueCodec codec, bool includeNonPublic) + { + List tokens = Parse(path); + List frames = new List(); + object current = target; + Type currentType = targetType; + + for (int i = 0; i < tokens.Count - 1; i++) + { + Type childType; + object child = ReadToken(current, currentType, tokens[i], includeNonPublic, out childType); + if (child == null) + throw new McpCommandException("null_member", "Member path reached null before " + Describe(tokens[i + 1]) + "."); + AccessFrame frame = new AccessFrame(); + frame.Owner = current; + frame.OwnerType = currentType; + frame.Token = tokens[i]; + frame.ValueType = childType; + frames.Add(frame); + current = child; + currentType = McpReflection.GetActualType(child); + } + + Token leaf = tokens[tokens.Count - 1]; + Type leafType = GetWritableTokenType(current, currentType, leaf, includeNonPublic); + object converted = codec.ConvertTo(input, leafType); + WriteToken(current, currentType, leaf, converted, includeNonPublic); + + // Reflection mutates a boxed struct, not the struct stored in its parent. Propagate + // changed value types back through fields/properties/arrays/lists until a reference + // boundary is reached. + object updatedChild = current; + for (int i = frames.Count - 1; i >= 0; i--) + { + AccessFrame frame = frames[i]; + if (!frame.ValueType.IsValueType) break; + WriteToken(frame.Owner, frame.OwnerType, frame.Token, updatedChild, includeNonPublic); + updatedChild = frame.Owner; + } + + return Read(target, targetType, path, includeNonPublic); + } + + private static object ReadToken(object owner, Type ownerType, Token token, bool includeNonPublic, out Type valueType) + { + if (token.Kind == TokenKind.Member) + { + MemberInfo member = McpReflection.FindReadableMember(ownerType, token.Name, includeNonPublic); + if (member == null || McpReflection.IsUnsafeMember(member)) + throw MemberNotFound(ownerType, token.Name, false); + valueType = McpReflection.GetMemberType(member); + return McpReflection.GetMemberValue(member, owner); + } + + if (owner == null) throw new McpCommandException("null_member", "Cannot index a null value."); + Array array = owner as Array; + if (array != null) + { + ValidateIndex(token.Index, array.Length); + valueType = ownerType.GetElementType() ?? array.GetType().GetElementType() ?? typeof(object); + return array.GetValue(token.Index); + } + IList list = owner as IList; + if (list != null) + { + ValidateIndex(token.Index, list.Count); + valueType = GetListElementType(ownerType, list, token.Index); + return list[token.Index]; + } + throw new McpCommandException("not_indexable", "Value of type " + ownerType.FullName + " is not an array or IList."); + } + + private static Type GetWritableTokenType(object owner, Type ownerType, Token token, bool includeNonPublic) + { + if (token.Kind == TokenKind.Member) + { + MemberInfo member = McpReflection.FindWritableMember(ownerType, token.Name, includeNonPublic); + if (member == null || McpReflection.IsUnsafeMember(member)) + throw MemberNotFound(ownerType, token.Name, true); + return McpReflection.GetMemberType(member); + } + if (owner == null) throw new McpCommandException("null_member", "Cannot index a null value."); + Array array = owner as Array; + if (array != null) + { + ValidateIndex(token.Index, array.Length); + return ownerType.GetElementType() ?? array.GetType().GetElementType() ?? typeof(object); + } + IList list = owner as IList; + if (list != null) + { + ValidateIndex(token.Index, list.Count); + if (list.IsReadOnly) throw new McpCommandException("read_only_collection", "The IList is read-only."); + return GetListElementType(ownerType, list, token.Index); + } + throw new McpCommandException("not_indexable", "Value of type " + ownerType.FullName + " is not an array or IList."); + } + + private static void WriteToken(object owner, Type ownerType, Token token, object value, bool includeNonPublic) + { + if (token.Kind == TokenKind.Member) + { + MemberInfo member = McpReflection.FindWritableMember(ownerType, token.Name, includeNonPublic); + if (member == null || McpReflection.IsUnsafeMember(member)) + throw MemberNotFound(ownerType, token.Name, true); + McpReflection.SetMemberValue(member, owner, value); + return; + } + Array array = owner as Array; + if (array != null) { ValidateIndex(token.Index, array.Length); array.SetValue(value, token.Index); return; } + IList list = owner as IList; + if (list != null) + { + ValidateIndex(token.Index, list.Count); + if (list.IsReadOnly) throw new McpCommandException("read_only_collection", "The IList is read-only."); + list[token.Index] = value; + return; + } + throw new McpCommandException("not_indexable", "Value of type " + ownerType.FullName + " is not an array or IList."); + } + + private static List Parse(string path) + { + if (string.IsNullOrEmpty(path)) throw new McpCommandException("invalid_member_path", "Member path is required."); + List tokens = new List(); + int position = 0; + while (position < path.Length) + { + int start = position; + while (position < path.Length && path[position] != '.' && path[position] != '[') position++; + if (position > start) + { + Token member = new Token(); member.Kind = TokenKind.Member; member.Name = path.Substring(start, position - start); tokens.Add(member); + } + else if (position >= path.Length || path[position] != '[') + throw InvalidPath(path, position); + + while (position < path.Length && path[position] == '[') + { + int close = path.IndexOf(']', position + 1); + if (close < 0) throw InvalidPath(path, position); + int index; + if (!int.TryParse(path.Substring(position + 1, close - position - 1), out index) || index < 0) + throw new McpCommandException("invalid_member_path", "Collection index must be a non-negative integer in path: " + path); + Token indexed = new Token(); indexed.Kind = TokenKind.Index; indexed.Index = index; tokens.Add(indexed); + position = close + 1; + } + + if (position < path.Length) + { + if (path[position] != '.' || position + 1 >= path.Length) throw InvalidPath(path, position); + position++; + } + } + if (tokens.Count == 0) throw InvalidPath(path, 0); + return tokens; + } + + private static Type GetListElementType(Type type, IList list, int index) + { + if (type.IsGenericType) + { + Type[] arguments = type.GetGenericArguments(); + if (arguments.Length == 1) return arguments[0]; + } + Type[] interfaces = type.GetInterfaces(); + for (int i = 0; i < interfaces.Length; i++) + if (interfaces[i].IsGenericType && interfaces[i].GetGenericTypeDefinition() == typeof(IList<>)) + return interfaces[i].GetGenericArguments()[0]; + object existing = index >= 0 && index < list.Count ? list[index] : null; + return existing == null ? typeof(object) : existing.GetType(); + } + + private static void ValidateIndex(int index, int count) + { + if (index < 0 || index >= count) + throw new McpCommandException("index_out_of_range", "Collection index " + index + " is outside the valid range 0.." + (count - 1) + "."); + } + + private static McpCommandException InvalidPath(string path, int position) + { + return new McpCommandException("invalid_member_path", "Invalid member path near position " + position + ": " + path); + } + + private static McpCommandException MemberNotFound(Type ownerType, string name, bool writable) + { + string operation = writable ? "Writable field or property" : "Readable member"; + return new McpCommandException( + "member_not_found", + operation + " was not found on resolved runtime type " + + (ownerType == null ? "" : ownerType.FullName) + ": " + name); + } + + private static string Describe(Token token) + { + return token.Kind == TokenKind.Member ? token.Name : "[" + token.Index + "]"; + } + } +} diff --git a/src/MCP/Runtime/McpNativeProtocolHandler.cs b/src/MCP/Runtime/McpNativeProtocolHandler.cs new file mode 100644 index 000000000..39da3a219 --- /dev/null +++ b/src/MCP/Runtime/McpNativeProtocolHandler.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using UnityExplorer.MCP.Transport; + +namespace UnityExplorer.MCP.Runtime +{ + /// + /// Implements the MCP protocol surface directly inside the UnityExplorer DLL. + /// It adapts standard MCP methods to + /// without requiring the legacy Node.js sidecar. + /// + public sealed class McpNativeProtocolHandler : IMcpRequestHandler + { + private const string LatestProtocol = "2026-07-28"; + private const string DefaultProtocol = "2025-11-25"; + private static readonly string[] SupportedProtocols = { LatestProtocol, DefaultProtocol, "2025-06-18", "2025-03-26", "2024-11-05" }; + private static readonly string[] ProtocolMethods = + { + "initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "server/discover" + }; + + private readonly McpGameCapabilityExecutor executor; + private readonly McpJsonValue tools; + private readonly Dictionary requiredArguments; + + public McpNativeProtocolHandler(McpGameCapabilityExecutor executor) + { + if (executor == null) throw new ArgumentNullException("executor"); + this.executor = executor; + tools = BuildTools(); + requiredArguments = BuildRequiredArguments(); + } + + public void Register(McpRequestRouter router) + { + if (router == null) throw new ArgumentNullException("router"); + for (int i = 0; i < ProtocolMethods.Length; i++) router.Register(ProtocolMethods[i], this); + } + + public void Unregister(McpRequestRouter router) + { + if (router == null) return; + for (int i = 0; i < ProtocolMethods.Length; i++) router.Unregister(ProtocolMethods[i]); + } + + public McpResponse Handle(McpRequest request) + { + if (request == null) return McpResponse.Error(-32600, "Request is required."); + try + { + McpJsonValue root = McpJsonValue.Parse(request.RawJson); + McpJsonValue parameters; + if (!root.TryGet("params", out parameters) || parameters == null || parameters.IsNull) + parameters = McpJsonValue.Object(); + if (parameters.Kind != McpJsonValue.JsonKind.Object) + return McpResponse.Error(-32602, "params must be a JSON object."); + + switch (request.Method) + { + case "initialize": return McpResponse.SuccessJson(Initialize(parameters).ToJson()); + case "notifications/initialized": return McpResponse.Success(); + case "ping": return McpResponse.SuccessJson(IsModern(parameters) ? "{\"resultType\":\"complete\"}" : "{}"); + case "tools/list": return McpResponse.SuccessJson(ToolsList(parameters).ToJson()); + case "tools/call": return CallTool(parameters); + case "server/discover": return McpResponse.SuccessJson(Discover().ToJson()); + default: return McpResponse.Error(-32601, "Method not found: " + request.Method); + } + } + catch (FormatException ex) + { + return McpResponse.Error(-32602, ex.Message); + } + catch (Exception ex) + { + return McpResponse.Error(-32603, McpReflection.Unwrap(ex).Message); + } + } + + private McpJsonValue Initialize(McpJsonValue parameters) + { + string requested = parameters.GetString("protocolVersion", DefaultProtocol); + string negotiated = DefaultProtocol; + for (int i = 0; i < SupportedProtocols.Length; i++) + { + if (string.Equals(requested, SupportedProtocols[i], StringComparison.Ordinal)) + { + negotiated = requested; + break; + } + } + + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["protocolVersion"] = McpJsonValue.From(negotiated); + result.ObjectValue["capabilities"] = Capabilities(); + result.ObjectValue["serverInfo"] = ServerInfo(); + result.ObjectValue["instructions"] = McpJsonValue.From(Instructions()); + return result; + } + + private McpJsonValue ToolsList(McpJsonValue parameters) + { + McpJsonValue result = McpJsonValue.Object(); + if (IsModern(parameters)) result.ObjectValue["resultType"] = McpJsonValue.From("complete"); + result.ObjectValue["tools"] = tools; + if (IsModern(parameters)) + { + result.ObjectValue["ttlMs"] = McpJsonValue.From(60000d); + result.ObjectValue["cacheScope"] = McpJsonValue.From("public"); + } + return result; + } + + private McpResponse CallTool(McpJsonValue parameters) + { + string name = parameters.GetString("name", null); + if (string.IsNullOrEmpty(name)) + return McpResponse.Error(-32602, "tools/call requires params.name and optional params.arguments."); + + string[] required; + if (!requiredArguments.TryGetValue(name, out required)) + return McpResponse.Error(-32602, "Unknown tool: " + name); + + McpJsonValue arguments; + if (!parameters.TryGet("arguments", out arguments) || arguments == null || arguments.IsNull) + arguments = McpJsonValue.Object(); + if (arguments.Kind != McpJsonValue.JsonKind.Object) + return McpResponse.Error(-32602, "tools/call params.arguments must be a JSON object."); + + for (int i = 0; i < required.Length; i++) + { + McpJsonValue ignored; + if (!arguments.TryGet(required[i], out ignored)) + return McpResponse.Error(-32602, "Missing required argument: " + required[i]); + } + + McpJsonValue command = CopyObject(arguments); + command.ObjectValue["command"] = McpJsonValue.From(name); + McpJsonValue execution = executor.Execute(command); + return McpResponse.SuccessJson(BuildToolResult(execution, IsModern(parameters)).ToJson()); + } + + private static McpJsonValue BuildToolResult(McpJsonValue execution, bool modern) + { + bool failed = false; + McpJsonValue ok; + if (execution != null && execution.TryGet("ok", out ok) && ok.Kind == McpJsonValue.JsonKind.Boolean) + failed = !ok.BooleanValue; + + McpJsonValue structured = McpJsonValue.Object(); + structured.ObjectValue["data"] = execution ?? McpJsonValue.Null(); + + McpJsonValue text = McpJsonValue.Object(); + text.ObjectValue["type"] = McpJsonValue.From("text"); + text.ObjectValue["text"] = McpJsonValue.From(structured.ToJson()); + McpJsonValue content = McpJsonValue.Array(); + content.ArrayValue.Add(text); + + McpJsonValue result = McpJsonValue.Object(); + if (modern) result.ObjectValue["resultType"] = McpJsonValue.From("complete"); + result.ObjectValue["content"] = content; + result.ObjectValue["structuredContent"] = structured; + result.ObjectValue["isError"] = McpJsonValue.From(failed); + return result; + } + + private static McpJsonValue Discover() + { + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["resultType"] = McpJsonValue.From("complete"); + McpJsonValue versions = McpJsonValue.Array(); + for (int i = 0; i < SupportedProtocols.Length; i++) versions.ArrayValue.Add(McpJsonValue.From(SupportedProtocols[i])); + result.ObjectValue["supportedVersions"] = versions; + result.ObjectValue["capabilities"] = Capabilities(); + McpJsonValue meta = McpJsonValue.Object(); + meta.ObjectValue["io.modelcontextprotocol/serverInfo"] = ServerInfo(); + result.ObjectValue["_meta"] = meta; + result.ObjectValue["instructions"] = McpJsonValue.From(Instructions()); + result.ObjectValue["ttlMs"] = McpJsonValue.From(60000d); + result.ObjectValue["cacheScope"] = McpJsonValue.From("public"); + return result; + } + + private static bool IsModern(McpJsonValue parameters) + { + McpJsonValue meta, protocol; + return parameters != null && parameters.TryGet("_meta", out meta) && meta.Kind == McpJsonValue.JsonKind.Object && + meta.TryGet("io.modelcontextprotocol/protocolVersion", out protocol) && protocol.Kind == McpJsonValue.JsonKind.String && + string.Equals(protocol.StringValue, LatestProtocol, StringComparison.Ordinal); + } + + private static McpJsonValue Capabilities() + { + McpJsonValue toolsCapability = McpJsonValue.Object(); + toolsCapability.ObjectValue["listChanged"] = McpJsonValue.From(false); + McpJsonValue capabilities = McpJsonValue.Object(); + capabilities.ObjectValue["tools"] = toolsCapability; + return capabilities; + } + + private static McpJsonValue ServerInfo() + { + McpJsonValue info = McpJsonValue.Object(); + info.ObjectValue["name"] = McpJsonValue.From("cinematic-unity-explorer"); + info.ObjectValue["title"] = McpJsonValue.From("Cinematic Unity Explorer MCP"); + info.ObjectValue["version"] = McpJsonValue.From("0.1.0"); + return info; + } + + private static string Instructions() + { + return "Use search_objects to obtain stable object handles, inspect with get_object, then mutate with set_member or invoke_method. Prefer execute_batch for related operations."; + } + + private static Dictionary BuildRequiredArguments() + { + Dictionary map = new Dictionary(StringComparer.Ordinal); + map["get_status"] = new string[0]; + map["list_scenes"] = new string[0]; + map["search_objects"] = new string[0]; + map["get_object"] = new[] { "object_id" }; + map["get_member"] = new[] { "object_id", "member_path" }; + map["list_methods"] = new[] { "object_id" }; + map["set_member"] = new[] { "object_id", "member_path", "value" }; + map["set_transform"] = new[] { "object_id" }; + map["set_enabled"] = new[] { "object_id", "enabled" }; + map["create_object"] = new string[0]; + map["destroy_object"] = new[] { "object_id" }; + map["invoke_method"] = new[] { "object_id", "method" }; + map["execute_batch"] = new[] { "operations" }; + return map; + } + + private static McpJsonValue BuildTools() + { + McpJsonValue result = McpJsonValue.Array(); + AddTool(result, "get_status", "Get CUE bridge status", "Check whether the game-side bridge is alive and return game, Unity, scene, and capability metadata.", Schema(), true, false, true); + AddTool(result, "list_scenes", "List Unity scenes", "List loaded Unity scenes.", Schema(Prop("include_unloaded", Boolean()), Prop("include_special", Boolean())), true, false, true); + AddTool(result, "search_objects", "Search game objects", "Search Unity objects and return stable handles for later calls.", Schema(Prop("query", String()), Prop("scene", String()), Prop("type", String()), Prop("kind", String()), Prop("include_inactive", Boolean()), Prop("exact", Boolean()), Prop("limit", Integer(1, 1000))), true, false, true); + AddTool(result, "get_object", "Inspect an object", "Read an object summary, hierarchy, components, and members.", SchemaRequired(new[] { "object_id" }, Prop("object_id", Id()), Prop("depth", Integer(0, 5)), Prop("include_members", Boolean()), Prop("include_methods", Boolean()), Prop("member_filter", String()), Prop("max_collection_items", Integer(0, 1000))), true, false, true); + AddTool(result, "get_member", "Read an object member", "Read one field, property, or nested member path.", SchemaRequired(new[] { "object_id", "member_path" }, Prop("object_id", Id()), Prop("member_path", String()), Prop("depth", Integer(0, 5)), Prop("max_items", Integer(1, 1000))), true, false, true); + AddTool(result, "list_methods", "List callable methods", "List callable instance/static methods and signatures.", SchemaRequired(new[] { "object_id" }, Prop("object_id", Id()), Prop("name_filter", String()), Prop("include_non_public", Boolean()), Prop("include_inherited", Boolean()), Prop("include_static", Boolean()), Prop("limit", Integer(1, 1000))), true, false, true); + AddTool(result, "set_member", "Set an object member", "Set a field/property or nested member path.", SchemaRequired(new[] { "object_id", "member_path", "value" }, Prop("object_id", Id()), Prop("member_path", String()), Prop("value", McpJsonValue.Object()), Prop("value_type", String())), false, true, false); + AddTool(result, "set_transform", "Set object transform", "Set world/local Transform values or change an object's parent.", SchemaRequired(new[] { "object_id" }, Prop("object_id", Id()), Prop("position", Vector3()), Prop("local_position", Vector3()), Prop("rotation", Quaternion()), Prop("local_rotation", Quaternion()), Prop("euler_angles", Vector3()), Prop("local_euler_angles", Vector3()), Prop("local_scale", Vector3()), Prop("parent_id", Id()), Prop("world_position_stays", Boolean())), false, true, true); + AddTool(result, "set_enabled", "Set object enabled state", "Enable or disable a GameObject, Behaviour, or writable enabled member.", SchemaRequired(new[] { "object_id", "enabled" }, Prop("object_id", Id()), Prop("enabled", Boolean())), false, true, true); + AddTool(result, "create_object", "Create a game object", "Create an empty or primitive GameObject and optionally add Components.", Schema(Prop("name", String()), Prop("primitive", String()), Prop("parent_id", Id()), Prop("world_position_stays", Boolean()), Prop("components", ArrayOf(String()))), false, false, false); + AddTool(result, "destroy_object", "Destroy a game object", "Destroy a UnityEngine.Object by stable handle.", SchemaRequired(new[] { "object_id" }, Prop("object_id", Id()), Prop("immediate", Boolean())), false, true, false); + AddTool(result, "invoke_method", "Invoke an object method", "Invoke an instance or static method with JSON arguments converted to CLR values.", SchemaRequired(new[] { "object_id", "method" }, Prop("object_id", Id()), Prop("method", String()), Prop("arguments", ArrayOf(McpJsonValue.Object())), Prop("generic_type_arguments", ArrayOf(String())), Prop("overload", String())), false, true, false); + AddTool(result, "execute_batch", "Execute a bridge batch", "Execute multiple non-batch bridge operations in order.", SchemaRequired(new[] { "operations" }, Prop("operations", ArrayOf(McpJsonValue.Object())), Prop("atomic", Boolean()), Prop("stop_on_error", Boolean())), false, true, false); + return result; + } + + private static void AddTool(McpJsonValue array, string name, string title, string description, McpJsonValue schema, bool readOnly, bool destructive, bool idempotent) + { + McpJsonValue tool = McpJsonValue.Object(); + tool.ObjectValue["name"] = McpJsonValue.From(name); + tool.ObjectValue["title"] = McpJsonValue.From(title); + tool.ObjectValue["description"] = McpJsonValue.From(description); + tool.ObjectValue["inputSchema"] = schema; + McpJsonValue annotations = McpJsonValue.Object(); + annotations.ObjectValue["readOnlyHint"] = McpJsonValue.From(readOnly); + annotations.ObjectValue["destructiveHint"] = McpJsonValue.From(destructive); + annotations.ObjectValue["idempotentHint"] = McpJsonValue.From(idempotent); + annotations.ObjectValue["openWorldHint"] = McpJsonValue.From(false); + tool.ObjectValue["annotations"] = annotations; + array.ArrayValue.Add(tool); + } + + private static KeyValuePair Prop(string name, McpJsonValue schema) { return new KeyValuePair(name, schema); } + private static McpJsonValue Schema(params KeyValuePair[] properties) { return SchemaRequired(null, properties); } + private static McpJsonValue SchemaRequired(string[] required, params KeyValuePair[] properties) + { + McpJsonValue schema = McpJsonValue.Object(); + schema.ObjectValue["type"] = McpJsonValue.From("object"); + McpJsonValue props = McpJsonValue.Object(); + for (int i = 0; i < properties.Length; i++) props.ObjectValue[properties[i].Key] = properties[i].Value; + schema.ObjectValue["properties"] = props; + if (required != null && required.Length > 0) + { + McpJsonValue items = McpJsonValue.Array(); + for (int i = 0; i < required.Length; i++) items.ArrayValue.Add(McpJsonValue.From(required[i])); + schema.ObjectValue["required"] = items; + } + schema.ObjectValue["additionalProperties"] = McpJsonValue.From(false); + return schema; + } + private static McpJsonValue Type(string name) { McpJsonValue v = McpJsonValue.Object(); v.ObjectValue["type"] = McpJsonValue.From(name); return v; } + private static McpJsonValue String() { return Type("string"); } + private static McpJsonValue Boolean() { return Type("boolean"); } + private static McpJsonValue Id() { McpJsonValue v = String(); v.ObjectValue["minLength"] = McpJsonValue.From(1d); return v; } + private static McpJsonValue Integer(int minimum, int maximum) { McpJsonValue v = Type("integer"); v.ObjectValue["minimum"] = McpJsonValue.From((double)minimum); v.ObjectValue["maximum"] = McpJsonValue.From((double)maximum); return v; } + private static McpJsonValue ArrayOf(McpJsonValue item) { McpJsonValue v = Type("array"); v.ObjectValue["items"] = item; return v; } + private static McpJsonValue Vector3() { return CoordinateObject(new[] { "x", "y", "z" }); } + private static McpJsonValue Quaternion() { return CoordinateObject(new[] { "x", "y", "z", "w" }); } + private static McpJsonValue CoordinateObject(string[] names) + { + KeyValuePair[] props = new KeyValuePair[names.Length]; + for (int i = 0; i < names.Length; i++) props[i] = Prop(names[i], Type("number")); + return SchemaRequired(names, props); + } + private static McpJsonValue CopyObject(McpJsonValue source) + { + McpJsonValue copy = McpJsonValue.Object(); + foreach (KeyValuePair pair in source.ObjectValue) copy.ObjectValue[pair.Key] = pair.Value; + return copy; + } + } +} diff --git a/src/MCP/Runtime/McpObjectRegistry.cs b/src/MCP/Runtime/McpObjectRegistry.cs new file mode 100644 index 000000000..309a17ebb --- /dev/null +++ b/src/MCP/Runtime/McpObjectRegistry.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace UnityExplorer.MCP.Runtime +{ + /// Stable session-local IDs. Unity objects are keyed by native instance ID for IL2CPP wrapper stability. + public sealed class McpObjectRegistry + { + private readonly object sync = new object(); + private readonly Dictionary byId = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary managedIds = new Dictionary(ReferenceComparer.Instance); + private readonly Dictionary unityIds = new Dictionary(); + private long nextId = 1; + + public int Count { get { lock (sync) return byId.Count; } } + + public string Register(object value) + { + if (value == null || IsDestroyed(value)) return null; + UnityEngine.Object unityObject = value as UnityEngine.Object; + lock (sync) + { + if (!ReferenceEquals(unityObject, null)) + { + int instanceId = unityObject.GetInstanceID(); string existing; object previous; + if (unityIds.TryGetValue(instanceId, out existing) && byId.TryGetValue(existing, out previous)) + { + if (!IsDestroyed(previous)) { byId[existing] = value; return existing; } + RemoveInternal(existing, previous); + } + string id = "u" + instanceId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "-" + NextSuffix(); + unityIds[instanceId] = id; byId[id] = value; return id; + } + + string managed; + if (managedIds.TryGetValue(value, out managed) && byId.ContainsKey(managed)) return managed; + managed = "m" + NextSuffix(); managedIds[value] = managed; byId[managed] = value; return managed; + } + } + + public bool TryResolve(string id, out object value) + { + value = null; if (string.IsNullOrEmpty(id)) return false; + lock (sync) + { + if (!byId.TryGetValue(id, out value)) return false; + if (value == null || IsDestroyed(value)) { RemoveInternal(id, value); value = null; return false; } + return true; + } + } + + public object Resolve(string id) + { + object value; if (!TryResolve(id, out value)) throw new McpCommandException("object_not_found", "Object ID is unknown or the Unity object was destroyed: " + id); return value; + } + + public bool Forget(string id) + { + if (string.IsNullOrEmpty(id)) return false; + lock (sync) { object value; return byId.TryGetValue(id, out value) && RemoveInternal(id, value); } + } + + public int Prune() + { + lock (sync) + { + List stale = new List(); + foreach (KeyValuePair pair in byId) if (pair.Value == null || IsDestroyed(pair.Value)) stale.Add(pair.Key); + for (int i = 0; i < stale.Count; i++) RemoveInternal(stale[i], null); return stale.Count; + } + } + + private string NextSuffix() { return (nextId++).ToString("x", System.Globalization.CultureInfo.InvariantCulture); } + + private bool RemoveInternal(string id, object knownTarget) + { + object target; + if (!byId.TryGetValue(id, out target)) return false; + if (knownTarget != null) target = knownTarget; byId.Remove(id); + UnityEngine.Object unityObject = target as UnityEngine.Object; + if (!ReferenceEquals(unityObject, null)) + { + int instanceId = unityObject.GetInstanceID(); string mapped; + if (unityIds.TryGetValue(instanceId, out mapped) && mapped == id) unityIds.Remove(instanceId); + } + else if (target != null) + { + string mapped; if (managedIds.TryGetValue(target, out mapped) && mapped == id) managedIds.Remove(target); + } + else + { + int key = 0; bool found = false; + foreach (KeyValuePair pair in unityIds) if (pair.Value == id) { key = pair.Key; found = true; break; } + if (found) unityIds.Remove(key); + } + return true; + } + + internal static bool IsDestroyed(object value) + { + UnityEngine.Object unityObject = value as UnityEngine.Object; + return value != null && !ReferenceEquals(unityObject, null) && !unityObject; + } + + private sealed class ReferenceComparer : IEqualityComparer + { + internal static readonly ReferenceComparer Instance = new ReferenceComparer(); + public new bool Equals(object x, object y) { return ReferenceEquals(x, y); } + public int GetHashCode(object obj) { return RuntimeHelpers.GetHashCode(obj); } + } + } + + public sealed class McpGameExecutorOptions + { + public McpGameExecutorOptions() + { + MaximumSearchResults = 200; MaximumSnapshotDepth = 3; MaximumSerializedItems = 256; MaximumMembersPerObject = 128; MaximumBatchCommands = 64; + IncludeNonPublicMembers = true; AllowMethodInvocation = true; AllowObjectCreation = true; AllowObjectDestruction = true; + } + public int MaximumSearchResults { get; set; } + public int MaximumSnapshotDepth { get; set; } + public int MaximumSerializedItems { get; set; } + public int MaximumMembersPerObject { get; set; } + public int MaximumBatchCommands { get; set; } + public bool IncludeNonPublicMembers { get; set; } + public bool AllowMethodInvocation { get; set; } + public bool AllowObjectCreation { get; set; } + public bool AllowObjectDestruction { get; set; } + internal void Validate() + { + if (MaximumSearchResults < 1) throw new ArgumentOutOfRangeException("MaximumSearchResults"); + if (MaximumSnapshotDepth < 0 || MaximumSnapshotDepth > 16) throw new ArgumentOutOfRangeException("MaximumSnapshotDepth"); + if (MaximumSerializedItems < 1) throw new ArgumentOutOfRangeException("MaximumSerializedItems"); + if (MaximumMembersPerObject < 1) throw new ArgumentOutOfRangeException("MaximumMembersPerObject"); + if (MaximumBatchCommands < 1) throw new ArgumentOutOfRangeException("MaximumBatchCommands"); + } + } + + public sealed class McpCommandException : Exception + { + public McpCommandException(string code, string message) : base(message) { Code = code; } + public McpCommandException(string code, string message, Exception inner) : base(message, inner) { Code = code; } + public string Code { get; private set; } + } +} diff --git a/src/MCP/Runtime/McpValueCodec.cs b/src/MCP/Runtime/McpValueCodec.cs new file mode 100644 index 000000000..9afa4dcc6 --- /dev/null +++ b/src/MCP/Runtime/McpValueCodec.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using UnityEngine; + +namespace UnityExplorer.MCP.Runtime +{ + internal sealed class McpValueCodec + { + private readonly McpObjectRegistry registry; + private readonly McpGameExecutorOptions options; + + internal McpValueCodec(McpObjectRegistry registry, McpGameExecutorOptions options) + { + this.registry = registry; + this.options = options; + } + + internal McpJsonValue Serialize(object value, int requestedDepth, int requestedItems) + { + int depth = Clamp(requestedDepth, 0, options.MaximumSnapshotDepth); + int items = Clamp(requestedItems, 1, options.MaximumSerializedItems); + SerializationState state = new SerializationState(items); + return SerializeValue(value, depth, state); + } + + internal object ConvertTo(McpJsonValue json, Type targetType) + { + if (targetType == null) throw new ArgumentNullException("targetType"); + Type nullable = Nullable.GetUnderlyingType(targetType); + if (json == null || json.IsNull) + { + if (!targetType.IsValueType || nullable != null) return null; + throw new McpCommandException("conversion_failed", "null cannot be assigned to " + targetType.FullName + "."); + } + if (nullable != null) return ConvertTo(json, nullable); + + if (typeof(UnityEngine.Object).IsAssignableFrom(targetType)) + { + string id = json.Kind == McpJsonValue.JsonKind.String ? json.StringValue : json.GetString("objectId", null); + object resolved = registry.Resolve(id); + if (!targetType.IsInstanceOfType(resolved)) + throw new McpCommandException("conversion_failed", "Object " + id + " is not a " + targetType.FullName + "."); + return resolved; + } + + if (targetType == typeof(string)) return json.Kind == McpJsonValue.JsonKind.String ? json.StringValue : json.ToJson(); + if (targetType == typeof(bool)) return RequireBoolean(json, targetType); + if (targetType == typeof(char)) + { + string text = RequireString(json, targetType); + if (text.Length != 1) throw Conversion(targetType); + return text[0]; + } + if (targetType.IsEnum) + { + if (json.Kind == McpJsonValue.JsonKind.String) return Enum.Parse(targetType, json.StringValue, true); + return Enum.ToObject(targetType, Convert.ChangeType(RequireNumber(json, targetType), Enum.GetUnderlyingType(targetType), CultureInfo.InvariantCulture)); + } + if (IsNumeric(targetType)) return Convert.ChangeType(RequireNumber(json, targetType), targetType, CultureInfo.InvariantCulture); + if (targetType == typeof(Vector2)) return new Vector2(Float(json, "x"), Float(json, "y")); + if (targetType == typeof(Vector3)) return new Vector3(Float(json, "x"), Float(json, "y"), Float(json, "z")); + if (targetType == typeof(Vector4)) return new Vector4(Float(json, "x"), Float(json, "y"), Float(json, "z"), Float(json, "w")); + if (targetType == typeof(Quaternion)) return new Quaternion(Float(json, "x"), Float(json, "y"), Float(json, "z"), Float(json, "w")); + if (targetType == typeof(Color)) return new Color(Float(json, "r"), Float(json, "g"), Float(json, "b"), Float(json, "a", 1f)); + if (targetType == typeof(Rect)) return new Rect(Float(json, "x"), Float(json, "y"), Float(json, "width"), Float(json, "height")); + if (targetType == typeof(LayerMask)) return (LayerMask)(int)RequireNumber(json, targetType); + if (targetType == typeof(Type)) return McpReflection.FindType(RequireString(json, targetType)); + + if (targetType.IsArray) + { + RequireArray(json, targetType); + Type elementType = targetType.GetElementType(); + Array array = Array.CreateInstance(elementType, json.ArrayValue.Count); + for (int i = 0; i < json.ArrayValue.Count; i++) array.SetValue(ConvertTo(json.ArrayValue[i], elementType), i); + return array; + } + + if (targetType.IsGenericType && typeof(IList).IsAssignableFrom(targetType)) + { + RequireArray(json, targetType); + Type elementType = targetType.GetGenericArguments()[0]; + IList list = (IList)Activator.CreateInstance(targetType); + for (int i = 0; i < json.ArrayValue.Count; i++) list.Add(ConvertTo(json.ArrayValue[i], elementType)); + return list; + } + + if (targetType == typeof(object)) return ConvertUntyped(json); + if (json.Kind != McpJsonValue.JsonKind.Object) throw Conversion(targetType); + + object instance; + try { instance = Activator.CreateInstance(targetType); } + catch (Exception ex) { throw new McpCommandException("conversion_failed", "Cannot construct " + targetType.FullName + ".", ex); } + foreach (KeyValuePair pair in json.ObjectValue) + { + MemberInfo member = McpReflection.FindWritableMember(targetType, pair.Key, options.IncludeNonPublicMembers); + if (member == null) continue; + McpReflection.SetMemberValue(member, instance, ConvertTo(pair.Value, McpReflection.GetMemberType(member))); + } + return instance; + } + + private McpJsonValue SerializeValue(object value, int depth, SerializationState state) + { + if (value == null || McpObjectRegistry.IsDestroyed(value)) return McpJsonValue.Null(); + Type type = McpReflection.GetActualType(value); + if (value is string || value is char) return McpJsonValue.From(value.ToString()); + if (value is bool) return McpJsonValue.From((bool)value); + if (value is Enum) return McpJsonValue.From(value.ToString()); + if (IsNumeric(type)) return McpJsonValue.From(Convert.ToDouble(value, CultureInfo.InvariantCulture)); + if (value is Type) return McpJsonValue.From(((Type)value).AssemblyQualifiedName); + + if (value is Vector2) { Vector2 v = (Vector2)value; return ObjectOf("x", v.x, "y", v.y); } + if (value is Vector3) { Vector3 v = (Vector3)value; return ObjectOf("x", v.x, "y", v.y, "z", v.z); } + if (value is Vector4) { Vector4 v = (Vector4)value; return ObjectOf("x", v.x, "y", v.y, "z", v.z, "w", v.w); } + if (value is Quaternion) { Quaternion v = (Quaternion)value; return ObjectOf("x", v.x, "y", v.y, "z", v.z, "w", v.w); } + if (value is Color) { Color v = (Color)value; return ObjectOf("r", v.r, "g", v.g, "b", v.b, "a", v.a); } + if (value is Rect) { Rect v = (Rect)value; return ObjectOf("x", v.x, "y", v.y, "width", v.width, "height", v.height); } + if (value is LayerMask) return McpJsonValue.From((double)((LayerMask)value).value); + + UnityEngine.Object unityObject = value as UnityEngine.Object; + if (!ReferenceEquals(unityObject, null)) + { + McpJsonValue reference = ObjectReference(unityObject); + if (depth <= 0) return reference; + GameObject go = unityObject as GameObject; + Component component = unityObject as Component; + if (go != null) AddGameObjectSummary(reference, go); + else if (component != null) reference.ObjectValue["gameObjectId"] = McpJsonValue.From(registry.Register(component.gameObject)); + AddMembers(reference, value, type, depth - 1, state); + return reference; + } + + if (depth <= 0 || state.Remaining <= 0) + { + McpJsonValue reference = McpJsonValue.Object(); + reference.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(value)); + reference.ObjectValue["type"] = McpJsonValue.From(type.FullName); + return reference; + } + + IEnumerable enumerable = value as IEnumerable; + if (enumerable != null) + { + McpJsonValue array = McpJsonValue.Array(); + try + { + foreach (object item in enumerable) + { + if (state.Remaining-- <= 0) { array.ArrayValue.Add(Truncated()); break; } + array.ArrayValue.Add(SerializeValue(item, depth - 1, state)); + } + } + catch (Exception ex) { array.ArrayValue.Add(ErrorValue(ex)); } + return array; + } + + if (!type.IsValueType && !state.Visited.Add(value)) + { + McpJsonValue cycle = McpJsonValue.Object(); + cycle.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(value)); + cycle.ObjectValue["cycle"] = McpJsonValue.From(true); + return cycle; + } + + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(value)); + result.ObjectValue["type"] = McpJsonValue.From(type.FullName); + AddMembers(result, value, type, depth - 1, state); + return result; + } + + private void AddMembers(McpJsonValue target, object value, Type type, int depth, SerializationState state) + { + McpJsonValue members = McpJsonValue.Object(); + int count = 0; + foreach (MemberInfo member in McpReflection.GetReadableMembers(type, options.IncludeNonPublicMembers)) + { + if (count++ >= options.MaximumMembersPerObject || state.Remaining-- <= 0) { members.ObjectValue["$truncated"] = McpJsonValue.From(true); break; } + try { members.ObjectValue[member.Name] = SerializeValue(McpReflection.GetMemberValue(member, value), depth, state); } + catch (Exception ex) { members.ObjectValue[member.Name] = ErrorValue(ex); } + } + target.ObjectValue["members"] = members; + } + + private McpJsonValue ObjectReference(UnityEngine.Object value) + { + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["objectId"] = McpJsonValue.From(registry.Register(value)); + result.ObjectValue["instanceId"] = McpJsonValue.From((double)value.GetInstanceID()); + result.ObjectValue["name"] = McpJsonValue.From(value.name); + result.ObjectValue["type"] = McpJsonValue.From(McpReflection.GetActualType(value).FullName); + return result; + } + + private void AddGameObjectSummary(McpJsonValue result, GameObject go) + { + result.ObjectValue["activeSelf"] = McpJsonValue.From(go.activeSelf); + result.ObjectValue["activeInHierarchy"] = McpJsonValue.From(go.activeInHierarchy); + result.ObjectValue["scene"] = McpJsonValue.From(go.scene.name); + result.ObjectValue["path"] = McpJsonValue.From(McpReflection.GetGameObjectPath(go)); + result.ObjectValue["transformId"] = McpJsonValue.From(registry.Register(go.transform)); + } + + private static object ConvertUntyped(McpJsonValue json) + { + switch (json.Kind) + { + case McpJsonValue.JsonKind.Null: return null; + case McpJsonValue.JsonKind.Boolean: return json.BooleanValue; + case McpJsonValue.JsonKind.Number: return json.NumberValue; + case McpJsonValue.JsonKind.String: return json.StringValue; + case McpJsonValue.JsonKind.Array: + List list = new List(); + for (int i = 0; i < json.ArrayValue.Count; i++) list.Add(ConvertUntyped(json.ArrayValue[i])); + return list; + default: + Dictionary dictionary = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair pair in json.ObjectValue) dictionary[pair.Key] = ConvertUntyped(pair.Value); + return dictionary; + } + } + + private static McpJsonValue ObjectOf(params object[] pairs) + { + McpJsonValue result = McpJsonValue.Object(); + for (int i = 0; i + 1 < pairs.Length; i += 2) result.ObjectValue[(string)pairs[i]] = McpJsonValue.FromObject(pairs[i + 1]); + return result; + } + + private static McpJsonValue ErrorValue(Exception ex) + { + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["error"] = McpJsonValue.From(McpReflection.Unwrap(ex).Message); + return result; + } + + private static McpJsonValue Truncated() + { + McpJsonValue result = McpJsonValue.Object(); + result.ObjectValue["truncated"] = McpJsonValue.From(true); + return result; + } + + private static bool IsNumeric(Type type) + { + TypeCode code = Type.GetTypeCode(type); + return code >= TypeCode.SByte && code <= TypeCode.Decimal; + } + + private static double RequireNumber(McpJsonValue value, Type target) + { + if (value.Kind != McpJsonValue.JsonKind.Number) throw Conversion(target); + return value.NumberValue; + } + + private static bool RequireBoolean(McpJsonValue value, Type target) + { + if (value.Kind != McpJsonValue.JsonKind.Boolean) throw Conversion(target); + return value.BooleanValue; + } + + private static string RequireString(McpJsonValue value, Type target) + { + if (value.Kind != McpJsonValue.JsonKind.String) throw Conversion(target); + return value.StringValue; + } + + private static void RequireArray(McpJsonValue value, Type target) + { + if (value.Kind != McpJsonValue.JsonKind.Array) throw Conversion(target); + } + + private static float Float(McpJsonValue value, string key) { return Float(value, key, 0f); } + private static float Float(McpJsonValue value, string key, float defaultValue) + { + if (value.Kind != McpJsonValue.JsonKind.Object) throw new McpCommandException("conversion_failed", "Expected an object containing '" + key + "'."); + McpJsonValue item; + return value.TryGet(key, out item) && item.Kind == McpJsonValue.JsonKind.Number ? (float)item.NumberValue : defaultValue; + } + + private static McpCommandException Conversion(Type target) { return new McpCommandException("conversion_failed", "JSON value cannot be converted to " + target.FullName + "."); } + private static int Clamp(int value, int minimum, int maximum) { return value < minimum ? minimum : value > maximum ? maximum : value; } + + private sealed class SerializationState + { + internal SerializationState(int remaining) { Remaining = remaining; Visited = new HashSet(ReferenceEqualityComparer.Instance); } + internal int Remaining; + internal readonly HashSet Visited; + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + internal static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); + public new bool Equals(object x, object y) { return ReferenceEquals(x, y); } + public int GetHashCode(object obj) { return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); } + } + } + + internal static class McpReflection + { + internal static Type GetActualType(object value) + { + if (value == null) return null; + try { return value.GetActualType() ?? value.GetType(); } + catch { return value.GetType(); } + } + + internal static BindingFlags Flags(bool nonPublic) + { + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; + if (nonPublic) flags |= BindingFlags.NonPublic; + return flags; + } + + internal static Type FindType(string name) + { + if (string.IsNullOrEmpty(name)) throw new McpCommandException("type_not_found", "Type name is required."); + try + { + Type reflected = ReflectionUtility.GetTypeByName(name); + if (reflected != null) return reflected; + } + catch { } + Type type = Type.GetType(name, false, true); + if (type != null) return type; + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length; i++) + { + try + { + type = assemblies[i].GetType(name, false, true); + if (type != null) return type; + Type[] types = assemblies[i].GetTypes(); + for (int j = 0; j < types.Length; j++) + if (string.Equals(types[j].Name, name, StringComparison.OrdinalIgnoreCase)) return types[j]; + } + catch (ReflectionTypeLoadException ex) + { + Type[] types = ex.Types; + for (int j = 0; types != null && j < types.Length; j++) + if (types[j] != null && (string.Equals(types[j].FullName, name, StringComparison.OrdinalIgnoreCase) || string.Equals(types[j].Name, name, StringComparison.OrdinalIgnoreCase))) return types[j]; + } + catch { } + } + throw new McpCommandException("type_not_found", "Type was not found: " + name); + } + + internal static IEnumerable GetReadableMembers(Type type, bool nonPublic) + { + List result = new List(); + FieldInfo[] fields = type.GetFields(Flags(nonPublic)); + for (int i = 0; i < fields.Length; i++) + if (!fields[i].IsLiteral && !fields[i].IsStatic && !IsUnsafeMember(fields[i])) result.Add(fields[i]); + PropertyInfo[] properties = type.GetProperties(Flags(nonPublic)); + for (int i = 0; i < properties.Length; i++) + { + PropertyInfo property = properties[i]; + if (property.GetIndexParameters().Length == 0 && property.GetGetMethod(nonPublic) != null && !property.GetGetMethod(nonPublic).IsStatic && !IsUnsafeMember(property)) result.Add(property); + } + result.Sort(delegate(MemberInfo a, MemberInfo b) { return string.CompareOrdinal(a.Name, b.Name); }); + return result; + } + + internal static MemberInfo FindReadableMember(Type type, string name, bool nonPublic) + { + FieldInfo field = FindField(type, name, nonPublic); + if (field != null) return field; + PropertyInfo property = FindProperty(type, name, nonPublic); + return property != null && property.GetIndexParameters().Length == 0 && property.GetGetMethod(nonPublic) != null ? property : null; + } + + internal static MemberInfo FindWritableMember(Type type, string name, bool nonPublic) + { + FieldInfo field = FindField(type, name, nonPublic); + if (field != null && !field.IsInitOnly && !field.IsLiteral) return field; + PropertyInfo property = FindProperty(type, name, nonPublic); + return property != null && property.GetIndexParameters().Length == 0 && property.GetSetMethod(nonPublic) != null ? property : null; + } + + private static FieldInfo FindField(Type type, string name, bool nonPublic) + { + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + if (nonPublic) flags |= BindingFlags.NonPublic; + for (Type current = type; current != null; current = current.BaseType) + { + FieldInfo field = current.GetField(name, flags); + if (field != null) return field; + } + return null; + } + + private static PropertyInfo FindProperty(Type type, string name, bool nonPublic) + { + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + if (nonPublic) flags |= BindingFlags.NonPublic; + for (Type current = type; current != null; current = current.BaseType) + { + PropertyInfo property = current.GetProperty(name, flags); + if (property != null) return property; + } + return null; + } + + internal static Type GetMemberType(MemberInfo member) + { + FieldInfo field = member as FieldInfo; + if (field != null) return field.FieldType; + return ((PropertyInfo)member).PropertyType; + } + + internal static object GetMemberValue(MemberInfo member, object target) + { + FieldInfo field = member as FieldInfo; + object instance = GetDeclaringInstance(member, target); + return field != null ? field.GetValue(instance) : ((PropertyInfo)member).GetValue(instance, null); + } + + internal static void SetMemberValue(MemberInfo member, object target, object value) + { + FieldInfo field = member as FieldInfo; + object instance = GetDeclaringInstance(member, target); + if (field != null) field.SetValue(instance, value); + else ((PropertyInfo)member).SetValue(instance, value, null); + } + + internal static object GetDeclaringInstance(MemberInfo member, object target) + { + if (target == null || member == null) return target; + try { return target.TryCast(member.DeclaringType) ?? target; } + catch { return target; } + } + + internal static string GetGameObjectPath(GameObject go) + { + if (go == null) return null; + string path = go.name; + Transform parent = go.transform.parent; + int guard = 0; + while (parent != null && guard++ < 256) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + return path; + } + + internal static bool IsUnsafeMember(MemberInfo member) + { + if (member == null) return true; + if (member.Name.StartsWith("NativeFieldInfoPtr_", StringComparison.Ordinal) || member.Name.StartsWith("NativeMethodInfoPtr_", StringComparison.Ordinal)) return true; + try { return UnityExplorer.Runtime.UERuntimeHelper.IsBlacklisted(member); } catch { return false; } + } + internal static Exception Unwrap(Exception ex) + { + while (ex is TargetInvocationException && ex.InnerException != null) ex = ex.InnerException; + return ex; + } + } +} diff --git a/src/MCP/Transport/IMcpTransport.cs b/src/MCP/Transport/IMcpTransport.cs new file mode 100644 index 000000000..a6ca45117 --- /dev/null +++ b/src/MCP/Transport/IMcpTransport.cs @@ -0,0 +1,105 @@ +using System; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// Receives validated JSON-RPC requests. Dispatch is always performed by + /// , never by an HTTP worker thread. + /// + public interface IMcpRequestDispatcher + { + McpResponse Dispatch(McpRequest request); + } + + public interface IMcpRequestHandler + { + McpResponse Handle(McpRequest request); + } + + public interface IMcpTransport : IDisposable + { + bool IsRunning { get; } + int PendingRequestCount { get; } + McpHttpBridgeOptions Options { get; } + + void Start(); + void Stop(); + + /// + /// Executes queued requests on the calling thread. The owner must call + /// this method from Unity's Update loop (or another known main-thread hook). + /// + int PumpMainThread(); + + int PumpMainThread(int maximumRequests); + } + + /// + /// Mutable startup settings. Change these only while the bridge is stopped. + /// + public sealed class McpHttpBridgeOptions + { + public McpHttpBridgeOptions() + { + Port = 17891; + TransportMode = McpHttpTransportMode.LegacySse; + RpcPath = "/mcp"; + HealthPath = "/health"; + Token = string.Empty; + RequireTokenForHealth = false; + RequestTimeoutMilliseconds = 30000; + MaxRequestBodyBytes = 1024 * 1024; + MaxPendingRequests = 128; + MaxRequestsPerPump = 16; + } + + public int Port { get; set; } + public McpHttpTransportMode TransportMode { get; set; } + public string RpcPath { get; set; } + public string HealthPath { get; set; } + public string Token { get; set; } + public bool RequireTokenForHealth { get; set; } + public int RequestTimeoutMilliseconds { get; set; } + public int MaxRequestBodyBytes { get; set; } + public int MaxPendingRequests { get; set; } + public int MaxRequestsPerPump { get; set; } + + internal void Validate() + { + if (Port < 1 || Port > 65535) + throw new ArgumentOutOfRangeException("Port", "Port must be between 1 and 65535."); + if (!Enum.IsDefined(typeof(McpHttpTransportMode), TransportMode)) + throw new ArgumentOutOfRangeException("TransportMode"); + if (RequestTimeoutMilliseconds < 1) + throw new ArgumentOutOfRangeException("RequestTimeoutMilliseconds"); + if (MaxRequestBodyBytes < 1) + throw new ArgumentOutOfRangeException("MaxRequestBodyBytes"); + if (MaxPendingRequests < 1) + throw new ArgumentOutOfRangeException("MaxPendingRequests"); + if (MaxRequestsPerPump < 1) + throw new ArgumentOutOfRangeException("MaxRequestsPerPump"); + + RpcPath = NormalizePath(RpcPath, "RpcPath"); + HealthPath = NormalizePath(HealthPath, "HealthPath"); + Token = Token ?? string.Empty; + + if (string.Equals(RpcPath, HealthPath, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("RpcPath and HealthPath must be different."); + } + + private static string NormalizePath(string value, string parameterName) + { + if (string.IsNullOrEmpty(value)) + throw new ArgumentException("Path must not be empty.", parameterName); + + string path = value.Trim(); + if (path.Length == 0) + throw new ArgumentException("Path must not be empty.", parameterName); + if (path[0] != '/') + path = "/" + path; + while (path.Length > 1 && path[path.Length - 1] == '/') + path = path.Substring(0, path.Length - 1); + return path; + } + } +} diff --git a/src/MCP/Transport/JsonWire.cs b/src/MCP/Transport/JsonWire.cs new file mode 100644 index 000000000..a9a0b37be --- /dev/null +++ b/src/MCP/Transport/JsonWire.cs @@ -0,0 +1,377 @@ +using System; +using System.Text; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// Small dependency-free JSON scanner. It deliberately exposes raw JSON values + /// so the transport works on net35 without selecting a serializer for executors. + /// + internal static class JsonWire + { + private const int MaximumDepth = 128; + + internal static bool TryParseRequest(string json, string remoteAddress, out McpRequest request, out string error) + { + request = null; + error = null; + if (json == null) + { + error = "Request body is missing."; + return false; + } + + try + { + int index = 0; + SkipWhiteSpace(json, ref index); + if (index >= json.Length || json[index] != '{') + throw new FormatException("The JSON-RPC request must be an object."); + index++; + + string version = null; + string method = null; + string idJson = null; + bool hasMethod = false; + bool hasId = false; + + SkipWhiteSpace(json, ref index); + if (index < json.Length && json[index] == '}') + index++; + else + { + while (true) + { + SkipWhiteSpace(json, ref index); + string name = ReadString(json, ref index); + SkipWhiteSpace(json, ref index); + Require(json, ref index, ':'); + SkipWhiteSpace(json, ref index); + + int valueStart = index; + string stringValue = null; + if (index < json.Length && json[index] == '"') + { + stringValue = ReadString(json, ref index); + } + else + { + SkipValue(json, ref index, 0); + } + int valueEnd = index; + + if (name == "jsonrpc") + version = stringValue; + else if (name == "method") + { + method = stringValue; + hasMethod = true; + } + else if (name == "id") + { + idJson = json.Substring(valueStart, valueEnd - valueStart); + hasId = true; + } + + SkipWhiteSpace(json, ref index); + if (index >= json.Length) + throw new FormatException("Unterminated JSON object."); + if (json[index] == '}') + { + index++; + break; + } + Require(json, ref index, ','); + } + } + + SkipWhiteSpace(json, ref index); + if (index != json.Length) + throw new FormatException("Unexpected content after the JSON object."); + if (version != "2.0") + throw new FormatException("jsonrpc must be the string \"2.0\"."); + if (!hasMethod || string.IsNullOrEmpty(method)) + throw new FormatException("method must be a non-empty string."); + if (hasId && !IsValidId(idJson)) + throw new FormatException("id must be a string, number, or null."); + + request = new McpRequest(json, hasId ? idJson : null, method, remoteAddress); + return true; + } + catch (FormatException ex) + { + error = ex.Message; + return false; + } + } + + internal static void ValidateSingleValue(string json, string parameterName) + { + if (json == null) + throw new ArgumentNullException(parameterName); + try + { + int index = 0; + SkipWhiteSpace(json, ref index); + SkipValue(json, ref index, 0); + SkipWhiteSpace(json, ref index); + if (index != json.Length) + throw new FormatException("Unexpected trailing JSON content."); + } + catch (FormatException ex) + { + throw new ArgumentException("Value is not valid JSON: " + ex.Message, parameterName); + } + } + + internal static string Quote(string value) + { + if (value == null) + return "null"; + + StringBuilder builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + switch (c) + { + case '"': builder.Append("\\\""); break; + case '\\': builder.Append("\\\\"); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (c < 32) + { + builder.Append("\\u"); + builder.Append(((int)c).ToString("x4", System.Globalization.CultureInfo.InvariantCulture)); + } + else + builder.Append(c); + break; + } + } + builder.Append('"'); + return builder.ToString(); + } + + private static bool IsValidId(string idJson) + { + if (idJson == "null") + return true; + if (idJson.Length > 0 && idJson[0] == '"') + return true; + int index = 0; + try + { + SkipNumber(idJson, ref index); + return index == idJson.Length; + } + catch (FormatException) + { + return false; + } + } + + private static void SkipValue(string json, ref int index, int depth) + { + if (depth > MaximumDepth) + throw new FormatException("JSON nesting is too deep."); + if (index >= json.Length) + throw new FormatException("Expected a JSON value."); + + char c = json[index]; + if (c == '"') + { + ReadString(json, ref index); + return; + } + if (c == '{') + { + index++; + SkipWhiteSpace(json, ref index); + if (index < json.Length && json[index] == '}') + { + index++; + return; + } + while (true) + { + SkipWhiteSpace(json, ref index); + ReadString(json, ref index); + SkipWhiteSpace(json, ref index); + Require(json, ref index, ':'); + SkipWhiteSpace(json, ref index); + SkipValue(json, ref index, depth + 1); + SkipWhiteSpace(json, ref index); + if (index >= json.Length) + throw new FormatException("Unterminated JSON object."); + if (json[index] == '}') + { + index++; + return; + } + Require(json, ref index, ','); + } + } + if (c == '[') + { + index++; + SkipWhiteSpace(json, ref index); + if (index < json.Length && json[index] == ']') + { + index++; + return; + } + while (true) + { + SkipWhiteSpace(json, ref index); + SkipValue(json, ref index, depth + 1); + SkipWhiteSpace(json, ref index); + if (index >= json.Length) + throw new FormatException("Unterminated JSON array."); + if (json[index] == ']') + { + index++; + return; + } + Require(json, ref index, ','); + } + } + if (c == '-' || (c >= '0' && c <= '9')) + { + SkipNumber(json, ref index); + return; + } + if (StartsWith(json, index, "true")) { index += 4; return; } + if (StartsWith(json, index, "false")) { index += 5; return; } + if (StartsWith(json, index, "null")) { index += 4; return; } + throw new FormatException("Invalid JSON value."); + } + + private static string ReadString(string json, ref int index) + { + Require(json, ref index, '"'); + StringBuilder builder = null; + int segmentStart = index; + while (index < json.Length) + { + char c = json[index++]; + if (c == '"') + { + if (builder == null) + return json.Substring(segmentStart, index - segmentStart - 1); + builder.Append(json, segmentStart, index - segmentStart - 1); + return builder.ToString(); + } + if (c < 32) + throw new FormatException("Control character in JSON string."); + if (c != '\\') + continue; + + if (builder == null) + builder = new StringBuilder(); + builder.Append(json, segmentStart, index - segmentStart - 1); + if (index >= json.Length) + throw new FormatException("Unterminated JSON escape."); + char escape = json[index++]; + switch (escape) + { + case '"': builder.Append('"'); break; + case '\\': builder.Append('\\'); break; + case '/': builder.Append('/'); break; + case 'b': builder.Append('\b'); break; + case 'f': builder.Append('\f'); break; + case 'n': builder.Append('\n'); break; + case 'r': builder.Append('\r'); break; + case 't': builder.Append('\t'); break; + case 'u': + if (index + 4 > json.Length) + throw new FormatException("Invalid Unicode escape."); + int code = 0; + for (int i = 0; i < 4; i++) + { + int hex = HexValue(json[index++]); + if (hex < 0) + throw new FormatException("Invalid Unicode escape."); + code = (code << 4) | hex; + } + builder.Append((char)code); + break; + default: + throw new FormatException("Invalid JSON escape."); + } + segmentStart = index; + } + throw new FormatException("Unterminated JSON string."); + } + + private static void SkipNumber(string json, ref int index) + { + int start = index; + if (index < json.Length && json[index] == '-') index++; + if (index >= json.Length) throw new FormatException("Invalid JSON number."); + if (json[index] == '0') + index++; + else + { + if (json[index] < '1' || json[index] > '9') throw new FormatException("Invalid JSON number."); + while (index < json.Length && json[index] >= '0' && json[index] <= '9') index++; + } + if (index < json.Length && json[index] == '.') + { + index++; + int fractionStart = index; + while (index < json.Length && json[index] >= '0' && json[index] <= '9') index++; + if (index == fractionStart) throw new FormatException("Invalid JSON number."); + } + if (index < json.Length && (json[index] == 'e' || json[index] == 'E')) + { + index++; + if (index < json.Length && (json[index] == '+' || json[index] == '-')) index++; + int exponentStart = index; + while (index < json.Length && json[index] >= '0' && json[index] <= '9') index++; + if (index == exponentStart) throw new FormatException("Invalid JSON number."); + } + if (index == start) throw new FormatException("Invalid JSON number."); + } + + private static void SkipWhiteSpace(string json, ref int index) + { + while (index < json.Length) + { + char c = json[index]; + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') + return; + index++; + } + } + + private static void Require(string json, ref int index, char expected) + { + if (index >= json.Length || json[index] != expected) + throw new FormatException("Expected '" + expected + "'."); + index++; + } + + private static bool StartsWith(string value, int index, string expected) + { + if (index + expected.Length > value.Length) + return false; + for (int i = 0; i < expected.Length; i++) + if (value[index + i] != expected[i]) return false; + return true; + } + + private static int HexValue(char c) + { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + } +} diff --git a/src/MCP/Transport/McpHttpBridge.cs b/src/MCP/Transport/McpHttpBridge.cs new file mode 100644 index 000000000..6714d0e69 --- /dev/null +++ b/src/MCP/Transport/McpHttpBridge.cs @@ -0,0 +1,934 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// Loopback-only HTTP/JSON bridge implemented on TcpListener for compatibility + /// with old Unity Mono profiles and newer CoreCLR/IL2CPP configurations. + /// Network workers only validate and enqueue; executor code is exclusively + /// invoked by PumpMainThread. + /// + public sealed class McpHttpBridge : IMcpTransport + { + private const int MaximumHeaderBytes = 32768; + private readonly object lifecycleSync = new object(); + private readonly object queueSync = new object(); + private readonly object sessionSync = new object(); + private readonly Queue pendingRequests = new Queue(); + private readonly Dictionary legacySessions = + new Dictionary(StringComparer.Ordinal); + private readonly IMcpRequestDispatcher dispatcher; + private readonly int mainThreadId; + + private TcpListener listener; + private Thread listenerThread; + private volatile bool running; + private bool disposed; + private DateTime startedUtc; + + // Validated startup snapshot. Options remain editable for the next Start(). + private string rpcPath; + private string healthPath; + private string token; + private bool requireTokenForHealth; + private int requestTimeoutMilliseconds; + private int maxRequestBodyBytes; + private int maxPendingRequests; + private int maxRequestsPerPump; + private McpHttpTransportMode configuredTransportMode = McpHttpTransportMode.LegacySse; + private McpHttpTransportMode activeTransportMode; + + public McpHttpBridge(IMcpRequestDispatcher dispatcher) + : this(new McpHttpBridgeOptions(), dispatcher) + { + } + + /// + /// Construct this object on Unity's main thread. PumpMainThread rejects calls + /// from any other thread, preventing accidental Unity API access by workers. + /// + public McpHttpBridge(McpHttpBridgeOptions options, IMcpRequestDispatcher dispatcher) + { + if (options == null) + throw new ArgumentNullException("options"); + if (dispatcher == null) + throw new ArgumentNullException("dispatcher"); + + Options = options; + configuredTransportMode = options.TransportMode; + this.dispatcher = dispatcher; + mainThreadId = Thread.CurrentThread.ManagedThreadId; + } + public McpHttpBridge(McpHttpBridgeOptions options, IMcpRequestDispatcher dispatcher, + McpHttpTransportMode transportMode) + : this(options, dispatcher) + { + TransportMode = transportMode; + } + + public McpHttpBridgeOptions Options { get; private set; } + public bool IsRunning { get { return running; } } + public int MainThreadId { get { return mainThreadId; } } + + /// + /// Selects legacy HTTP+SSE or the current Streamable HTTP transport. + /// The value is captured by Start() and may only be changed while stopped. + /// LegacySse is the default to preserve compatibility with SSE-only MCP clients. + /// + public McpHttpTransportMode TransportMode + { + get + { + lock (lifecycleSync) + return configuredTransportMode; + } + set + { + if (!Enum.IsDefined(typeof(McpHttpTransportMode), value)) + throw new ArgumentOutOfRangeException("value"); + lock (lifecycleSync) + { + ThrowIfDisposed(); + if (running) + throw new InvalidOperationException("TransportMode may only be changed while the bridge is stopped."); + configuredTransportMode = value; + Options.TransportMode = value; + } + } + } + + public McpHttpTransportMode ActiveTransportMode + { + get + { + lock (lifecycleSync) + return running ? activeTransportMode : configuredTransportMode; + } + } + + public int PendingRequestCount + { + get + { + lock (queueSync) + return pendingRequests.Count; + } + } + + public void Start() + { + lock (lifecycleSync) + { + ThrowIfDisposed(); + if (running) + return; + + Options.Validate(); + CaptureOptions(); + + TcpListener newListener = new TcpListener(IPAddress.Loopback, Options.Port); + try + { + newListener.Start(); + } + catch + { + try { newListener.Stop(); } catch { } + throw; + } + + listener = newListener; + startedUtc = DateTime.UtcNow; + running = true; + listenerThread = new Thread(ListenLoop); + listenerThread.IsBackground = true; + listenerThread.Name = "CUE MCP HTTP Listener"; + listenerThread.Start(); + } + } + + public void Stop() + { + Thread threadToJoin; + lock (lifecycleSync) + { + if (!running && listener == null) + return; + + running = false; + TcpListener oldListener = listener; + listener = null; + threadToJoin = listenerThread; + listenerThread = null; + if (oldListener != null) + { + try { oldListener.Stop(); } catch { } + } + } + + CloseAllLegacySessions(); + CancelQueuedRequests(McpResponse.Error(-32000, "MCP bridge stopped.")); + if (threadToJoin != null && threadToJoin != Thread.CurrentThread) + { + try { threadToJoin.Join(1000); } catch { } + } + } + + public int PumpMainThread() + { + return PumpMainThread(maxRequestsPerPump > 0 ? maxRequestsPerPump : Options.MaxRequestsPerPump); + } + + public int PumpMainThread(int maximumRequests) + { + ThrowIfDisposed(); + if (Thread.CurrentThread.ManagedThreadId != mainThreadId) + throw new InvalidOperationException("MCP requests may only be pumped on the thread that created the bridge."); + if (maximumRequests < 1) + throw new ArgumentOutOfRangeException("maximumRequests"); + + int executed = 0; + while (executed < maximumRequests) + { + PendingRequest pending; + lock (queueSync) + { + if (pendingRequests.Count == 0) + break; + pending = pendingRequests.Dequeue(); + } + + if (!pending.TryBeginExecution()) + continue; + + McpResponse response; + try + { + response = dispatcher.Dispatch(pending.Request); + if (response == null) + response = McpResponse.Error(-32603, "Dispatcher returned no response."); + } + catch (Exception ex) + { + response = McpResponse.Error(-32603, "Executor error.", + "{\"type\":" + JsonWire.Quote(ex.GetType().FullName) + + ",\"message\":" + JsonWire.Quote(ex.Message) + "}"); + } + + pending.Complete(response); + executed++; + } + return executed; + } + + public void Dispose() + { + lock (lifecycleSync) + { + if (disposed) + return; + } + Stop(); + lock (lifecycleSync) + disposed = true; + } + + private void ListenLoop() + { + while (running) + { + try + { + TcpListener current = listener; + if (current == null) + break; + TcpClient client = current.AcceptTcpClient(); + ThreadPool.QueueUserWorkItem(HandleClient, client); + } + catch (SocketException) + { + if (running) + Thread.Sleep(25); + } + catch (ObjectDisposedException) + { + break; + } + catch + { + if (!running) + break; + Thread.Sleep(25); + } + } + } + + private void HandleClient(object state) + { + TcpClient client = state as TcpClient; + if (client == null) + return; + + try + { + client.NoDelay = true; + client.ReceiveTimeout = requestTimeoutMilliseconds; + client.SendTimeout = requestTimeoutMilliseconds; + IPEndPoint remote = client.Client.RemoteEndPoint as IPEndPoint; + using (client) + using (NetworkStream stream = client.GetStream()) + { + if (remote == null || !IPAddress.IsLoopback(remote.Address)) + { + WriteSimpleError(stream, 403, "Loopback clients only."); + return; + } + + HttpRequestData request; + try + { + request = ReadHttpRequest(stream, maxRequestBodyBytes); + } + catch (RequestTooLargeException) + { + WriteSimpleError(stream, 413, "Request is too large."); + return; + } + catch (HttpParseException ex) + { + WriteSimpleError(stream, ex.StatusCode, ex.Message); + return; + } + + if (string.Equals(request.Path, healthPath, StringComparison.OrdinalIgnoreCase)) + { + HandleHealth(stream, request); + return; + } + if (string.Equals(request.Path, rpcPath, StringComparison.OrdinalIgnoreCase)) + { + HandleRpc(stream, request, remote.ToString()); + return; + } + WriteSimpleError(stream, 404, "Endpoint not found."); + } + } + catch + { + try { client.Close(); } catch { } + } + } + + private void HandleHealth(Stream stream, HttpRequestData request) + { + if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + WriteSimpleError(stream, 405, "GET required.", "Allow: GET\r\n"); + return; + } + if (requireTokenForHealth && !IsAuthorized(request)) + { + WriteSimpleError(stream, 401, "Unauthorized.", "WWW-Authenticate: Bearer\r\n"); + return; + } + + double uptime = Math.Max(0, (DateTime.UtcNow - startedUtc).TotalSeconds); + string json = "{\"status\":\"ok\",\"running\":" + (running ? "true" : "false") + + ",\"pendingRequests\":" + PendingRequestCount.ToString(CultureInfo.InvariantCulture) + + ",\"uptimeSeconds\":" + ((long)uptime).ToString(CultureInfo.InvariantCulture) + "}"; + WriteJson(stream, 200, json, null); + } + + private void HandleRpc(Stream stream, HttpRequestData httpRequest, string remoteAddress) + { + if (!IsAuthorized(httpRequest)) + { + WriteSimpleError(stream, 401, "Unauthorized.", "WWW-Authenticate: Bearer\r\n"); + return; + } + if (!IsAllowedOrigin(httpRequest)) + { + WriteSimpleError(stream, 403, "Origin is not allowed."); + return; + } + if (!running) + { + WriteSimpleError(stream, 503, "MCP bridge is stopping."); + return; + } + + if (activeTransportMode == McpHttpTransportMode.LegacySse) + HandleLegacySse(stream, httpRequest, remoteAddress); + else + HandleStreamableHttp(stream, httpRequest, remoteAddress); + } + + private void HandleLegacySse(Stream stream, HttpRequestData httpRequest, string remoteAddress) + { + if (string.Equals(httpRequest.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + if (!Accepts(httpRequest, "text/event-stream")) + { + WriteSimpleError(stream, 406, "Accept must allow text/event-stream."); + return; + } + + string sessionId = Guid.NewGuid().ToString("N"); + string postEndpoint = rpcPath + "?sessionId=" + sessionId; + McpLegacySseSession session = new McpLegacySseSession(sessionId, postEndpoint); + RegisterLegacySession(session); + try + { + session.Run(stream, delegate { return running; }); + } + finally + { + UnregisterLegacySession(session); + session.Dispose(); + } + return; + } + + if (!string.Equals(httpRequest.Method, "POST", StringComparison.OrdinalIgnoreCase)) + { + WriteSimpleError(stream, 405, "GET or POST required.", "Allow: GET, POST\r\n"); + return; + } + + if (!HasJsonContentType(httpRequest)) + { + WriteSimpleError(stream, 415, "Content-Type must be application/json."); + return; + } + + string sessionIdValue; + if (!httpRequest.Query.TryGetValue("sessionId", out sessionIdValue) || string.IsNullOrEmpty(sessionIdValue)) + { + WriteSimpleError(stream, 400, "Legacy SSE POST requires a sessionId query parameter."); + return; + } + + McpLegacySseSession sessionForPost; + if (!TryGetLegacySession(sessionIdValue, out sessionForPost)) + { + WriteSimpleError(stream, 404, "SSE session was not found or has closed."); + return; + } + + McpRequest request; + if (!TryReadMcpRequest(stream, httpRequest, remoteAddress, out request)) + return; + + PendingRequest pending = new PendingRequest(request, delegate(McpResponse response) + { + if (!request.IsNotification) + sessionForPost.TryEnqueueMessage(response.ToJson(request.IdJson)); + }); + + if (!TryEnqueue(pending)) + { + WriteJson(stream, 503, + McpResponse.Error(-32001, "MCP request queue is full or stopped.").ToJson(request.IdJson), null); + return; + } + + // Legacy SSE acknowledges the POST immediately. JSON-RPC responses are + // delivered as `message` events on the associated GET stream. + WriteResponse(stream, 202, null, null, null); + } + + private void HandleStreamableHttp(Stream stream, HttpRequestData httpRequest, string remoteAddress) + { + if (string.Equals(httpRequest.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + // This bridge has no unsolicited server messages. Streamable HTTP + // explicitly permits a server to reject a standalone SSE GET. + WriteSimpleError(stream, 405, "Standalone SSE streams are not supported in Streamable HTTP mode.", + "Allow: POST\r\n"); + return; + } + if (string.Equals(httpRequest.Method, "DELETE", StringComparison.OrdinalIgnoreCase)) + { + // The implementation is stateless and therefore has no session to end. + WriteSimpleError(stream, 405, "This Streamable HTTP endpoint is stateless.", "Allow: POST\r\n"); + return; + } + if (!string.Equals(httpRequest.Method, "POST", StringComparison.OrdinalIgnoreCase)) + { + WriteSimpleError(stream, 405, "POST required.", "Allow: POST\r\n"); + return; + } + if (!HasJsonContentType(httpRequest)) + { + WriteSimpleError(stream, 415, "Content-Type must be application/json."); + return; + } + if (!AcceptsJsonResponse(httpRequest)) + { + WriteSimpleError(stream, 406, "Accept must allow application/json."); + return; + } + + McpRequest request; + if (!TryReadMcpRequest(stream, httpRequest, remoteAddress, out request)) + return; + + PendingRequest pending = new PendingRequest(request, null); + if (!TryEnqueue(pending)) + { + WriteJson(stream, 503, + McpResponse.Error(-32001, "MCP request queue is full or stopped.").ToJson(request.IdJson), null); + return; + } + + if (request.IsNotification) + { + // Notifications have no JSON-RPC response. The queued work still runs + // on Unity's main thread after this HTTP acknowledgement is sent. + WriteResponse(stream, 202, null, null, null); + return; + } + + if (!pending.Wait(requestTimeoutMilliseconds)) + { + pending.TryCancelQueued(); + WriteJson(stream, 504, + McpResponse.Error(-32002, "Timed out waiting for Unity's main thread.").ToJson(request.IdJson), null); + return; + } + + McpResponse response = pending.Response ?? McpResponse.Error(-32603, "Missing executor response."); + WriteJson(stream, 200, response.ToJson(request.IdJson), null); + } + + private bool TryReadMcpRequest(Stream stream, HttpRequestData httpRequest, string remoteAddress, + out McpRequest request) + { + request = null; + string body; + try + { + body = new UTF8Encoding(false, true).GetString(httpRequest.Body); + } + catch (DecoderFallbackException) + { + WriteSimpleError(stream, 400, "Request body is not valid UTF-8."); + return false; + } + + string parseError; + if (!JsonWire.TryParseRequest(body, remoteAddress, out request, out parseError)) + { + WriteJson(stream, 400, McpResponse.Error(-32700, parseError).ToJson(null), null); + return false; + } + return true; + } + + private void RegisterLegacySession(McpLegacySseSession session) + { + lock (sessionSync) + legacySessions.Add(session.Id, session); + } + + private bool TryGetLegacySession(string id, out McpLegacySseSession session) + { + lock (sessionSync) + return legacySessions.TryGetValue(id, out session); + } + + private void UnregisterLegacySession(McpLegacySseSession session) + { + lock (sessionSync) + { + McpLegacySseSession current; + if (legacySessions.TryGetValue(session.Id, out current) && object.ReferenceEquals(current, session)) + legacySessions.Remove(session.Id); + } + } + + private void CloseAllLegacySessions() + { + McpLegacySseSession[] sessions; + lock (sessionSync) + { + sessions = new McpLegacySseSession[legacySessions.Count]; + legacySessions.Values.CopyTo(sessions, 0); + legacySessions.Clear(); + } + for (int i = 0; i < sessions.Length; i++) + sessions[i].Close(); + } + + private static bool HasJsonContentType(HttpRequestData request) + { + string value; + if (!request.Headers.TryGetValue("Content-Type", out value)) + return false; + int semicolon = value.IndexOf(';'); + if (semicolon >= 0) + value = value.Substring(0, semicolon); + return string.Equals(value.Trim(), "application/json", StringComparison.OrdinalIgnoreCase); + } + + private static bool AcceptsJsonResponse(HttpRequestData request) + { + string accept; + if (!request.Headers.TryGetValue("Accept", out accept) || string.IsNullOrEmpty(accept)) + return true; + return HeaderContainsMediaType(accept, "application/json") || HeaderContainsMediaType(accept, "*/*"); + } + + private static bool Accepts(HttpRequestData request, string mediaType) + { + string accept; + if (!request.Headers.TryGetValue("Accept", out accept) || string.IsNullOrEmpty(accept)) + return false; + return HeaderContainsMediaType(accept, mediaType) || HeaderContainsMediaType(accept, "*/*"); + } + + private static bool HeaderContainsMediaType(string header, string mediaType) + { + string[] values = header.Split(new char[] { ',' }); + for (int i = 0; i < values.Length; i++) + { + string value = values[i].Trim(); + int semicolon = value.IndexOf(';'); + if (semicolon >= 0) + value = value.Substring(0, semicolon).Trim(); + if (string.Equals(value, mediaType, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static bool IsAllowedOrigin(HttpRequestData request) + { + string origin; + if (!request.Headers.TryGetValue("Origin", out origin) || string.IsNullOrEmpty(origin)) + return true; + + Uri uri; + if (!Uri.TryCreate(origin, UriKind.Absolute, out uri)) + return false; + if (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + IPAddress address; + return IPAddress.TryParse(uri.Host, out address) && IPAddress.IsLoopback(address); + } + private bool TryEnqueue(PendingRequest pending) + { + lock (queueSync) + { + if (!running || pendingRequests.Count >= maxPendingRequests) + return false; + pendingRequests.Enqueue(pending); + return true; + } + } + + private void CancelQueuedRequests(McpResponse response) + { + lock (queueSync) + { + while (pendingRequests.Count > 0) + { + PendingRequest pending = pendingRequests.Dequeue(); + if (pending.TryBeginExecution()) + pending.Complete(response); + } + } + } + + private bool IsAuthorized(HttpRequestData request) + { + if (token.Length == 0) + return true; + + string supplied; + request.Headers.TryGetValue("X-MCP-Token", out supplied); + if (string.IsNullOrEmpty(supplied)) + { + string authorization; + request.Headers.TryGetValue("Authorization", out authorization); + const string prefix = "Bearer "; + if (!string.IsNullOrEmpty(authorization) && authorization.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + supplied = authorization.Substring(prefix.Length).Trim(); + } + return FixedTimeEquals(token, supplied ?? string.Empty); + } + + private static bool FixedTimeEquals(string expected, string supplied) + { + int maximum = Math.Max(expected.Length, supplied.Length); + int difference = expected.Length ^ supplied.Length; + for (int i = 0; i < maximum; i++) + { + char left = i < expected.Length ? expected[i] : (char)0; + char right = i < supplied.Length ? supplied[i] : (char)0; + difference |= left ^ right; + } + return difference == 0; + } + + private static HttpRequestData ReadHttpRequest(Stream stream, int maximumBodyBytes) + { + MemoryStream received = new MemoryStream(); + byte[] one = new byte[1]; + int headerEnd = -1; + while (received.Length < MaximumHeaderBytes) + { + int read = stream.Read(one, 0, 1); + if (read == 0) + throw new HttpParseException(400, "Connection closed before HTTP headers completed."); + received.WriteByte(one[0]); + byte[] bytes = received.GetBuffer(); + int length = (int)received.Length; + if (length >= 4 && bytes[length - 4] == 13 && bytes[length - 3] == 10 && + bytes[length - 2] == 13 && bytes[length - 1] == 10) + { + headerEnd = length; + break; + } + } + if (headerEnd < 0) + throw new RequestTooLargeException(); + + string headerText = Encoding.ASCII.GetString(received.GetBuffer(), 0, headerEnd - 4); + string[] lines = headerText.Split(new string[] { "\r\n" }, StringSplitOptions.None); + if (lines.Length == 0) + throw new HttpParseException(400, "Missing HTTP request line."); + string[] requestLine = lines[0].Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (requestLine.Length != 3 || !requestLine[2].StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase)) + throw new HttpParseException(400, "Invalid HTTP request line."); + + Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + int colon = lines[i].IndexOf(':'); + if (colon <= 0) + throw new HttpParseException(400, "Invalid HTTP header."); + string name = lines[i].Substring(0, colon).Trim(); + string value = lines[i].Substring(colon + 1).Trim(); + if (name.Length == 0) + throw new HttpParseException(400, "Invalid HTTP header name."); + headers[name] = value; + } + + string transferEncoding; + if (headers.TryGetValue("Transfer-Encoding", out transferEncoding) && + !string.IsNullOrEmpty(transferEncoding) && + !string.Equals(transferEncoding, "identity", StringComparison.OrdinalIgnoreCase)) + throw new HttpParseException(501, "Chunked request bodies are not supported."); + + int contentLength = 0; + string lengthText; + if (headers.TryGetValue("Content-Length", out lengthText)) + { + if (!int.TryParse(lengthText, NumberStyles.None, CultureInfo.InvariantCulture, out contentLength) || contentLength < 0) + throw new HttpParseException(400, "Invalid Content-Length."); + } + else if (string.Equals(requestLine[0], "POST", StringComparison.OrdinalIgnoreCase)) + throw new HttpParseException(411, "Content-Length is required."); + + if (contentLength > maximumBodyBytes) + throw new RequestTooLargeException(); + + byte[] body = new byte[contentLength]; + int offset = 0; + while (offset < contentLength) + { + int read = stream.Read(body, offset, contentLength - offset); + if (read == 0) + throw new HttpParseException(400, "Connection closed before request body completed."); + offset += read; + } + + string target = requestLine[1]; + string path = target; + string queryText = string.Empty; + int query = path.IndexOf('?'); + if (query >= 0) + { + queryText = path.Substring(query + 1); + path = path.Substring(0, query); + } + while (path.Length > 1 && path[path.Length - 1] == '/') + path = path.Substring(0, path.Length - 1); + + return new HttpRequestData(requestLine[0], target, path, ParseQueryString(queryText), headers, body); + } + + private static Dictionary ParseQueryString(string query) + { + Dictionary result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrEmpty(query)) + return result; + + string[] pairs = query.Split(new char[] { '&' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < pairs.Length; i++) + { + int equals = pairs[i].IndexOf('='); + string name = equals < 0 ? pairs[i] : pairs[i].Substring(0, equals); + string value = equals < 0 ? string.Empty : pairs[i].Substring(equals + 1); + try + { + name = Uri.UnescapeDataString(name.Replace('+', ' ')); + value = Uri.UnescapeDataString(value.Replace('+', ' ')); + } + catch (UriFormatException) + { + continue; + } + if (name.Length > 0) + result[name] = value; + } + return result; + } + private static void WriteSimpleError(Stream stream, int statusCode, string message) + { + WriteSimpleError(stream, statusCode, message, null); + } + + private static void WriteSimpleError(Stream stream, int statusCode, string message, string extraHeaders) + { + WriteJson(stream, statusCode, "{\"error\":" + JsonWire.Quote(message) + "}", extraHeaders); + } + + private static void WriteJson(Stream stream, int statusCode, string json, string extraHeaders) + { + WriteResponse(stream, statusCode, "application/json; charset=utf-8", Encoding.UTF8.GetBytes(json), extraHeaders); + } + + private static void WriteResponse(Stream stream, int statusCode, string contentType, byte[] body, string extraHeaders) + { + if (body == null) + body = new byte[0]; + StringBuilder header = new StringBuilder(); + header.Append("HTTP/1.1 ").Append(statusCode.ToString(CultureInfo.InvariantCulture)).Append(' ') + .Append(GetReasonPhrase(statusCode)).Append("\r\n"); + header.Append("Connection: close\r\nCache-Control: no-store\r\n"); + if (!string.IsNullOrEmpty(contentType)) + header.Append("Content-Type: ").Append(contentType).Append("\r\n"); + if (!string.IsNullOrEmpty(extraHeaders)) + header.Append(extraHeaders); + header.Append("Content-Length: ").Append(body.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n\r\n"); + + byte[] headerBytes = Encoding.ASCII.GetBytes(header.ToString()); + stream.Write(headerBytes, 0, headerBytes.Length); + if (body.Length > 0) + stream.Write(body, 0, body.Length); + stream.Flush(); + } + + private static string GetReasonPhrase(int statusCode) + { + switch (statusCode) + { + case 200: return "OK"; + case 202: return "Accepted"; + case 204: return "No Content"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 403: return "Forbidden"; + case 404: return "Not Found"; + case 405: return "Method Not Allowed"; + case 406: return "Not Acceptable"; + case 411: return "Length Required"; + case 413: return "Payload Too Large"; + case 415: return "Unsupported Media Type"; + case 500: return "Internal Server Error"; + case 501: return "Not Implemented"; + case 503: return "Service Unavailable"; + case 504: return "Gateway Timeout"; + default: return "Error"; + } + } + + private void CaptureOptions() + { + rpcPath = Options.RpcPath; + healthPath = Options.HealthPath; + token = Options.Token; + requireTokenForHealth = Options.RequireTokenForHealth; + requestTimeoutMilliseconds = Options.RequestTimeoutMilliseconds; + maxRequestBodyBytes = Options.MaxRequestBodyBytes; + maxPendingRequests = Options.MaxPendingRequests; + maxRequestsPerPump = Options.MaxRequestsPerPump; + configuredTransportMode = Options.TransportMode; + activeTransportMode = configuredTransportMode; + } + + private void ThrowIfDisposed() + { + if (disposed) + throw new ObjectDisposedException("McpHttpBridge"); + } + + private sealed class HttpRequestData + { + internal HttpRequestData(string method, string target, string path, + Dictionary query, Dictionary headers, byte[] body) + { + Method = method; + Target = target; + Path = path; + Query = query; + Headers = headers; + Body = body; + } + internal string Method { get; private set; } + internal string Target { get; private set; } + internal string Path { get; private set; } + internal Dictionary Query { get; private set; } + internal Dictionary Headers { get; private set; } + internal byte[] Body { get; private set; } + } + + private sealed class PendingRequest + { + private readonly ManualResetEvent completed = new ManualResetEvent(false); + private readonly Action completionCallback; + // 0 queued, 1 executing, 2 completed, 3 canceled while queued. + private int state; + + internal PendingRequest(McpRequest request, Action completionCallback) + { + Request = request; + this.completionCallback = completionCallback; + } + internal McpRequest Request { get; private set; } + internal McpResponse Response { get; private set; } + internal bool TryBeginExecution() { return Interlocked.CompareExchange(ref state, 1, 0) == 0; } + internal bool TryCancelQueued() { return Interlocked.CompareExchange(ref state, 3, 0) == 0; } + internal void Complete(McpResponse response) + { + Response = response; + Interlocked.Exchange(ref state, 2); + completed.Set(); + if (completionCallback != null) + { + try { completionCallback(response); } + catch { } + } + } + internal bool Wait(int milliseconds) { return completed.WaitOne(milliseconds, false); } + } + + private sealed class RequestTooLargeException : Exception { } + + private sealed class HttpParseException : Exception + { + internal HttpParseException(int statusCode, string message) : base(message) { StatusCode = statusCode; } + internal int StatusCode { get; private set; } + } + } +} diff --git a/src/MCP/Transport/McpHttpTransportMode.cs b/src/MCP/Transport/McpHttpTransportMode.cs new file mode 100644 index 000000000..2079e07e6 --- /dev/null +++ b/src/MCP/Transport/McpHttpTransportMode.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// Selects the HTTP transport semantics exposed by . + /// Change the bridge mode only while it is stopped. + /// + public enum McpHttpTransportMode + { + LegacySse = 0, + StreamableHttp = 1 + } + + /// + /// Owns one legacy HTTP+SSE connection. Producers only enqueue complete JSON + /// messages; the connection worker is the sole writer to the network stream. + /// + internal sealed class McpLegacySseSession : IDisposable + { + private const int KeepAliveMilliseconds = 15000; + private readonly object sync = new object(); + private readonly Queue messages = new Queue(); + private readonly AutoResetEvent wake = new AutoResetEvent(false); + private bool closed; + + internal McpLegacySseSession(string id, string postEndpoint) + { + Id = id; + PostEndpoint = postEndpoint; + } + + internal string Id { get; private set; } + internal string PostEndpoint { get; private set; } + + internal bool TryEnqueueMessage(string json) + { + if (string.IsNullOrEmpty(json)) + return false; + + lock (sync) + { + if (closed) + return false; + messages.Enqueue(json); + } + try { wake.Set(); } + catch (ObjectDisposedException) { return false; } + return true; + } + + internal void Run(Stream stream, Func isServerRunning) + { + WriteHeaders(stream); + WriteEvent(stream, "endpoint", PostEndpoint); + + while (isServerRunning() && !IsClosed) + { + string message; + bool wroteMessage = false; + while (TryDequeue(out message)) + { + WriteEvent(stream, "message", message); + wroteMessage = true; + } + + if (!isServerRunning() || IsClosed) + break; + + if (!wroteMessage && !wake.WaitOne(KeepAliveMilliseconds, false)) + WriteComment(stream, "keepalive"); + } + } + + internal void Close() + { + bool signal = false; + lock (sync) + { + if (!closed) + { + closed = true; + signal = true; + } + } + if (signal) + { + try { wake.Set(); } + catch (ObjectDisposedException) { } + } + } + + public void Dispose() + { + Close(); + wake.Close(); + } + + private bool IsClosed + { + get + { + lock (sync) + return closed; + } + } + + private bool TryDequeue(out string message) + { + lock (sync) + { + if (messages.Count == 0) + { + message = null; + return false; + } + message = messages.Dequeue(); + return true; + } + } + + private static void WriteHeaders(Stream stream) + { + string headers = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache, no-transform\r\n" + + "Connection: keep-alive\r\n" + + "X-Accel-Buffering: no\r\n\r\n"; + byte[] bytes = Encoding.ASCII.GetBytes(headers); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + + private static void WriteEvent(Stream stream, string eventName, string data) + { + // JSON produced by this server is single-line. Splitting nevertheless keeps + // the SSE framing valid if an endpoint or future payload contains newlines. + StringBuilder frame = new StringBuilder(); + frame.Append("event: ").Append(eventName).Append('\n'); + string normalized = (data ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n'); + string[] lines = normalized.Split(new char[] { '\n' }); + for (int i = 0; i < lines.Length; i++) + frame.Append("data: ").Append(lines[i]).Append('\n'); + frame.Append('\n'); + + byte[] bytes = Encoding.UTF8.GetBytes(frame.ToString()); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + + private static void WriteComment(Stream stream, string comment) + { + byte[] bytes = Encoding.ASCII.GetBytes(": " + comment + "\n\n"); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + } +} diff --git a/src/MCP/Transport/McpMessages.cs b/src/MCP/Transport/McpMessages.cs new file mode 100644 index 000000000..2325548bc --- /dev/null +++ b/src/MCP/Transport/McpMessages.cs @@ -0,0 +1,89 @@ +using System; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// A validated JSON-RPC 2.0 request. RawJson preserves params and extension + /// fields without imposing a JSON library dependency on old Unity runtimes. + /// + public sealed class McpRequest + { + internal McpRequest(string rawJson, string idJson, string method, string remoteAddress) + { + RawJson = rawJson; + IdJson = idJson; + Method = method; + RemoteAddress = remoteAddress; + } + + public string RawJson { get; private set; } + public string IdJson { get; private set; } + public string Method { get; private set; } + public string RemoteAddress { get; private set; } + public bool IsNotification { get { return string.IsNullOrEmpty(IdJson); } } + } + + /// + /// JSON-RPC response returned by an executor. ResultJson/DataJson must each + /// be one complete JSON value, not an entire JSON-RPC envelope. + /// + public sealed class McpResponse + { + private McpResponse(bool success, string resultJson, int errorCode, string errorMessage, string dataJson) + { + IsSuccess = success; + ResultJson = resultJson; + ErrorCode = errorCode; + ErrorMessage = errorMessage; + DataJson = dataJson; + } + + public bool IsSuccess { get; private set; } + public string ResultJson { get; private set; } + public int ErrorCode { get; private set; } + public string ErrorMessage { get; private set; } + public string DataJson { get; private set; } + + public static McpResponse Success() + { + return SuccessJson("null"); + } + + public static McpResponse SuccessJson(string resultJson) + { + JsonWire.ValidateSingleValue(resultJson, "resultJson"); + return new McpResponse(true, resultJson, 0, null, null); + } + + public static McpResponse SuccessString(string value) + { + return SuccessJson(JsonWire.Quote(value)); + } + + public static McpResponse Error(int code, string message) + { + return Error(code, message, null); + } + + public static McpResponse Error(int code, string message, string dataJson) + { + if (message == null) + message = string.Empty; + if (dataJson != null) + JsonWire.ValidateSingleValue(dataJson, "dataJson"); + return new McpResponse(false, null, code, message, dataJson); + } + + internal string ToJson(string idJson) + { + string id = string.IsNullOrEmpty(idJson) ? "null" : idJson; + if (IsSuccess) + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":" + ResultJson + "}"; + + string data = DataJson == null ? string.Empty : ",\"data\":" + DataJson; + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"error\":{\"code\":" + + ErrorCode.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ",\"message\":" + JsonWire.Quote(ErrorMessage) + data + "}}"; + } + } +} diff --git a/src/MCP/Transport/McpRequestRouter.cs b/src/MCP/Transport/McpRequestRouter.cs new file mode 100644 index 000000000..8fff54502 --- /dev/null +++ b/src/MCP/Transport/McpRequestRouter.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; + +namespace UnityExplorer.MCP.Transport +{ + /// + /// Thread-safe method router intended to be populated by the MCP executor. + /// Handlers are invoked only when the transport is pumped on Unity's main thread. + /// + public sealed class McpRequestRouter : IMcpRequestDispatcher + { + private readonly object sync = new object(); + private readonly Dictionary handlers = + new Dictionary(StringComparer.Ordinal); + + public void Register(string method, IMcpRequestHandler handler) + { + if (string.IsNullOrEmpty(method)) + throw new ArgumentException("Method must not be empty.", "method"); + if (handler == null) + throw new ArgumentNullException("handler"); + + lock (sync) + handlers[method] = handler; + } + + public bool Unregister(string method) + { + if (method == null) + return false; + lock (sync) + return handlers.Remove(method); + } + + public bool Contains(string method) + { + if (method == null) + return false; + lock (sync) + return handlers.ContainsKey(method); + } + + public McpResponse Dispatch(McpRequest request) + { + if (request == null) + throw new ArgumentNullException("request"); + + IMcpRequestHandler handler; + lock (sync) + handlers.TryGetValue(request.Method, out handler); + + if (handler == null) + return McpResponse.Error(-32601, "Method not found: " + request.Method); + + return handler.Handle(request) ?? McpResponse.Error(-32603, "Handler returned no response."); + } + } + + /// Convenience adapter for registering delegates as handlers. + public sealed class DelegateMcpRequestHandler : IMcpRequestHandler + { + private readonly Func callback; + + public DelegateMcpRequestHandler(Func callback) + { + if (callback == null) + throw new ArgumentNullException("callback"); + this.callback = callback; + } + + public McpResponse Handle(McpRequest request) + { + return callback(request); + } + } +} diff --git a/src/MCP/Transport/McpTransportMode.cs b/src/MCP/Transport/McpTransportMode.cs new file mode 100644 index 000000000..54309f8bb --- /dev/null +++ b/src/MCP/Transport/McpTransportMode.cs @@ -0,0 +1,16 @@ +namespace UnityExplorer.MCP.Transport +{ + /// + /// MCP network transports hosted directly by the CinematicUnityExplorer DLL. + /// Enum names are intentionally stable because configuration backends persist + /// them as strings. + /// + public enum McpTransportMode + { + /// Legacy HTTP + Server-Sent Events MCP transport. + SSE = 0, + + /// MCP Streamable HTTP transport. + StreamableHTTP = 1 + } +} diff --git a/src/UI/Panels/McpPanel.cs b/src/UI/Panels/McpPanel.cs new file mode 100644 index 000000000..16c14148d --- /dev/null +++ b/src/UI/Panels/McpPanel.cs @@ -0,0 +1,1014 @@ +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using UniverseLib.UI; +using UniverseLib.UI.Models; + +namespace UnityExplorer.UI.Panels +{ + /// + /// Configures and controls the in-game MCP server. + /// + /// The MCP implementation may live in a separately merged module, so this panel talks to + /// its public singleton API through a small reflection bridge. See McpManagerBridge below + /// for the expected API surface and supported compatibility aliases. + /// + public class McpPanel : UEPanel + { + public override string Name => "MCP"; + + // UIManager.Panels.Mcp is expected to be added by the panel-registration integration. + // Parsing by name keeps this isolated UI file buildable while that change is pending. + public override UIManager.Panels PanelType => + (UIManager.Panels)Enum.Parse(typeof(UIManager.Panels), "Mcp", true); + + public override int MinWidth => 620; + public override int MinHeight => 360; + public override Vector2 DefaultAnchorMin => new(0.5f, 0.5f); + public override Vector2 DefaultAnchorMax => new(0.5f, 0.5f); + public override bool ShowByDefault => false; + public override bool ShouldSaveActiveState => true; + + private readonly McpManagerBridge manager = new(); + + private Toggle enabledToggle; + private Toggle readOnlyToggle; + private Toggle dangerousOperationsToggle; + private Toggle requestLoggingToggle; + private Dropdown transportModeDropdown; + private InputFieldRef bindAddressInput; + private InputFieldRef portInput; + private InputFieldRef tokenInput; + private InputFieldRef agentConfigInput; + private InputFieldRef requestLogInput; + private Text statusText; + private Text endpointText; + private ButtonRef startButton; + private ButtonRef stopButton; + private GameObject scrollContent; + + private GameObject LayoutRoot => scrollContent ?? ContentRoot; + + private bool updatingControls; + private float nextRefreshTime; + private const float RefreshInterval = 0.5f; + + public McpPanel(UIBase owner) : base(owner) { } + + public override void SetDefaultSizeAndPosition() + { + base.SetDefaultSizeAndPosition(); + Rect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 700f); + Rect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, 820f); + } + + public override void Update() + { + base.Update(); + + if (!Enabled || Time.unscaledTime < nextRefreshTime) + return; + + nextRefreshTime = Time.unscaledTime + RefreshInterval; + RefreshRuntimeState(); + } + + protected override void ConstructPanelContent() + { + UIFactory.SetLayoutGroup(ContentRoot, false, false, true, true, 0, 0, 0, 0, 0); + UIFactory.SetLayoutElement(ContentRoot, flexibleWidth: 9999, flexibleHeight: 9999); + + GameObject scrollView = UIFactory.CreateScrollView( + ContentRoot, + "McpSettingsScrollView", + out scrollContent, + out _, + new Color(0.065f, 0.065f, 0.065f)); + UIFactory.SetLayoutElement(scrollView, minHeight: 200, flexibleWidth: 9999, flexibleHeight: 9999); + UIFactory.SetLayoutGroup(scrollContent, false, false, true, true, 3, 2, 2, 2, 2); + + Text warning = UIFactory.CreateLabel( + LayoutRoot, + "McpSecurityWarning", + "MCP gives an external agent access to the running game. Keep 127.0.0.1 unless remote access is explicitly required, and always use a strong token.", + TextAnchor.MiddleLeft, + new Color(1f, 0.78f, 0.3f), + true, + 12); + UIFactory.SetLayoutElement(warning.gameObject, minHeight: 36, flexibleWidth: 9999); + + CreateToggleRow("Enabled", "Enable MCP server", out enabledToggle, OnEnabledChanged); + + bindAddressInput = CreateInputRow( + "BindAddress", + "Bind address", + "127.0.0.1", + "127.0.0.1 (local machine only; recommended)", + OnBindAddressEndEdit); + + portInput = CreateInputRow("Port", "Port", "17891", "TCP port (1-65535)", OnPortEndEdit, 120f); + portInput.Component.contentType = InputField.ContentType.IntegerNumber; + + CreateTransportModeRow(); + CreateTokenRow(); + + CreateToggleRow("ReadOnly", "Read-only mode (blocks all mutation tools)", out readOnlyToggle, OnReadOnlyChanged); + CreateToggleRow( + "DangerousOperations", + "Allow dangerous operations (destroy objects, invoke arbitrary methods, load scenes, etc.)", + out dangerousOperationsToggle, + OnDangerousOperationsChanged, + new Color(1f, 0.55f, 0.4f)); + CreateToggleRow("RequestLogging", "Log MCP requests", out requestLoggingToggle, OnRequestLoggingChanged); + + CreateStatusSection(); + CreateActionRow(); + CreateAgentToolsSection(); + CreateRequestLogSection(); + + LoadSettingsFromManager(); + RefreshRuntimeState(); + } + + private void CreateToggleRow( + string objectName, + string label, + out Toggle toggle, + Action onChanged, + Color? labelColor = null) + { + GameObject row = UIFactory.CreateToggle(LayoutRoot, objectName, out toggle, out Text text); + UIFactory.SetLayoutElement(row, minHeight: 25, flexibleWidth: 9999); + text.text = label; + if (labelColor.HasValue) + text.color = labelColor.Value; + toggle.onValueChanged.AddListener(value => + { + if (!updatingControls) + onChanged(value); + }); + } + + private InputFieldRef CreateInputRow( + string objectName, + string label, + string initialValue, + string hint, + Action onEndEdit, + float inputWidth = 300f) + { + GameObject row = UIFactory.CreateHorizontalGroup( + LayoutRoot, + objectName + "Row", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(row, minHeight: 25, flexibleWidth: 9999); + + Text labelText = UIFactory.CreateLabel(row, objectName + "Label", label, TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(labelText.gameObject, minWidth: 135, minHeight: 25); + + InputFieldRef input = UIFactory.CreateInputField(row, objectName + "Input", hint); + UIFactory.SetLayoutElement(input.GameObject, minWidth: (int)inputWidth, minHeight: 25, flexibleWidth: 9999); + input.Text = initialValue; + input.Component.GetOnEndEdit().AddListener(onEndEdit); + return input; + } + + private void CreateTransportModeRow() + { + GameObject row = UIFactory.CreateHorizontalGroup( + LayoutRoot, + "McpTransportModeRow", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(row, minHeight: 25, flexibleWidth: 9999); + + Text label = UIFactory.CreateLabel(row, "McpTransportModeLabel", "Transport", TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(label.gameObject, minWidth: 135, minHeight: 25); + + GameObject dropdownObject = UIFactory.CreateDropdown( + row, + "McpTransportModeDropdown", + out transportModeDropdown, + "SSE", + 13, + OnTransportModeChanged); + UIFactory.SetLayoutElement(dropdownObject, minWidth: 220, minHeight: 25, flexibleWidth: 9999); + transportModeDropdown.ClearOptions(); + transportModeDropdown.options.Add(new Dropdown.OptionData("SSE")); + transportModeDropdown.options.Add(new Dropdown.OptionData("Streamable HTTP")); + transportModeDropdown.RefreshShownValue(); + } + private void CreateTokenRow() + { + GameObject row = UIFactory.CreateHorizontalGroup( + LayoutRoot, + "AuthenticationTokenRow", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(row, minHeight: 25, flexibleWidth: 9999); + + Text label = UIFactory.CreateLabel(row, "AuthenticationTokenLabel", "Authentication token", TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(label.gameObject, minWidth: 135, minHeight: 25); + + tokenInput = UIFactory.CreateInputField(row, "AuthenticationTokenInput", "Generate a token before starting"); + UIFactory.SetLayoutElement(tokenInput.GameObject, minWidth: 260, minHeight: 25, flexibleWidth: 9999); + tokenInput.Component.GetOnEndEdit().AddListener(OnTokenEndEdit); + + ButtonRef generateButton = UIFactory.CreateButton(row, "GenerateTokenButton", "Generate"); + UIFactory.SetLayoutElement(generateButton.GameObject, minWidth: 82, minHeight: 25); + generateButton.OnClick += GenerateToken; + + ButtonRef copyButton = UIFactory.CreateButton(row, "CopyTokenButton", "Copy"); + UIFactory.SetLayoutElement(copyButton.GameObject, minWidth: 58, minHeight: 25); + copyButton.OnClick += CopyToken; + } + + private void CreateStatusSection() + { + GameObject row = UIFactory.CreateHorizontalGroup( + LayoutRoot, + "McpStatusRow", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(row, minHeight: 42, flexibleWidth: 9999); + + statusText = UIFactory.CreateLabel(row, "McpStatus", "Status: unavailable", TextAnchor.MiddleLeft, Color.grey, true, 12); + UIFactory.SetLayoutElement(statusText.gameObject, minWidth: 180, minHeight: 38, flexibleWidth: 1); + + endpointText = UIFactory.CreateLabel(row, "McpEndpoint", "Endpoint: -", TextAnchor.MiddleLeft, Color.grey, true, 12); + UIFactory.SetLayoutElement(endpointText.gameObject, minWidth: 260, minHeight: 38, flexibleWidth: 2); + } + + private void CreateActionRow() + { + GameObject row = UIFactory.CreateHorizontalGroup( + LayoutRoot, + "McpActions", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleCenter); + UIFactory.SetLayoutElement(row, minHeight: 27, flexibleWidth: 9999); + + startButton = UIFactory.CreateButton(row, "StartMcpButton", "Start MCP", new Color(0.2f, 0.3f, 0.2f)); + UIFactory.SetLayoutElement(startButton.GameObject, minWidth: 140, minHeight: 25, flexibleWidth: 1); + startButton.OnClick += StartServer; + + stopButton = UIFactory.CreateButton(row, "StopMcpButton", "Stop MCP", new Color(0.3f, 0.2f, 0.2f)); + UIFactory.SetLayoutElement(stopButton.GameObject, minWidth: 140, minHeight: 25, flexibleWidth: 1); + stopButton.OnClick += StopServer; + } + + private void CreateAgentToolsSection() + { + Text title = UIFactory.CreateLabel( + LayoutRoot, + "McpAgentToolsTitle", + "Agent tools / Agent 可用工具", + TextAnchor.MiddleLeft, + Color.white, + false, + 14); + UIFactory.SetLayoutElement(title.gameObject, minHeight: 24, flexibleWidth: 9999); + + Text tools = UIFactory.CreateLabel( + LayoutRoot, + "McpAgentToolsHelp", + "READ-ONLY: get_status, list_scenes, search_objects, get_object, get_member, list_methods\n" + + "WRITE (Read-only OFF): set_member, set_transform, set_enabled\n" + + "DANGEROUS (Read-only OFF + Allow dangerous operations ON): invoke_method, create_object, destroy_object\n" + + "BATCH: execute_batch (each operation is checked against the same permissions)\n" + + "Trae connection: Trae Agent --SSE / Streamable HTTP--> CUE DLL /mcp --main thread--> Unity game (no Node adapter)", + TextAnchor.UpperLeft, + Color.grey, + true, + 12); + UIFactory.SetLayoutElement(tools.gameObject, minHeight: 90, flexibleWidth: 9999); + + Text usage = UIFactory.CreateLabel( + LayoutRoot, + "McpAgentUsageHelp", + "Recommended workflow / 推荐流程: get_status -> list_scenes -> search_objects -> get_object -> modify or invoke. " + + "Object IDs are session-scoped; search again after a scene change or game restart. " + + "Add the generated URL configuration directly in Trae. The MCP server runs inside the CUE DLL; no external Node process is required.", + TextAnchor.UpperLeft, + Color.grey, + true, + 12); + UIFactory.SetLayoutElement(usage.gameObject, minHeight: 58, flexibleWidth: 9999); + + GameObject configHeader = UIFactory.CreateHorizontalGroup( + LayoutRoot, + "McpAgentConfigHeader", + false, + false, + true, + true, + 3, + default, + new Color(1f, 1f, 1f, 0f), + TextAnchor.MiddleLeft); + UIFactory.SetLayoutElement(configHeader, minHeight: 25, flexibleWidth: 9999); + + Text configLabel = UIFactory.CreateLabel( + configHeader, + "McpAgentConfigLabel", + "Trae direct DLL config (contains the authentication token / 包含敏感 Token)", + TextAnchor.MiddleLeft, + Color.grey, + true, + 12); + UIFactory.SetLayoutElement(configLabel.gameObject, minHeight: 25, flexibleWidth: 9999); + + ButtonRef copyConfigButton = UIFactory.CreateButton( + configHeader, + "CopyMcpAgentConfigButton", + "Copy config (sensitive)"); + UIFactory.SetLayoutElement(copyConfigButton.GameObject, minWidth: 170, minHeight: 25); + copyConfigButton.OnClick += CopyAgentConfig; + + agentConfigInput = UIFactory.CreateInputField( + LayoutRoot, + "McpAgentConfig", + "Generate or enter a token to create the Trae direct MCP configuration template."); + UIFactory.SetLayoutElement(agentConfigInput.GameObject, minHeight: 128, flexibleWidth: 9999); + agentConfigInput.Component.readOnly = true; + agentConfigInput.Component.lineType = InputField.LineType.MultiLineNewline; + agentConfigInput.Component.textComponent.supportRichText = false; + agentConfigInput.Component.textComponent.font = UniversalUI.ConsoleFont; + agentConfigInput.PlaceholderText.font = UniversalUI.ConsoleFont; + RefreshAgentConfigText(); + } + private void CreateRequestLogSection() + { + Text label = UIFactory.CreateLabel(LayoutRoot, "RequestLogLabel", "Recent MCP requests", TextAnchor.MiddleLeft, Color.white, false, 13); + UIFactory.SetLayoutElement(label.gameObject, minHeight: 25, flexibleWidth: 9999); + + requestLogInput = UIFactory.CreateInputField(LayoutRoot, "McpRequestLog", "Request logging is empty or disabled."); + UIFactory.SetLayoutElement(requestLogInput.GameObject, minHeight: 120, preferredHeight: 160, flexibleWidth: 9999); + requestLogInput.Component.readOnly = true; + requestLogInput.Component.lineType = InputField.LineType.MultiLineNewline; + requestLogInput.Component.textComponent.supportRichText = true; + requestLogInput.Component.textComponent.font = UniversalUI.ConsoleFont; + requestLogInput.PlaceholderText.font = UniversalUI.ConsoleFont; + } + + private void LoadSettingsFromManager() + { + updatingControls = true; + try + { + enabledToggle.isOn = manager.GetBool("Enabled", false); + bindAddressInput.Text = manager.GetString("BindAddress", "127.0.0.1"); + portInput.Text = manager.GetInt("Port", 17891).ToString(); + transportModeDropdown.value = IsStreamableHttpMode(manager.GetString("TransportMode", "SSE", "HttpTransportMode", "Transport")) ? 1 : 0; + transportModeDropdown.RefreshShownValue(); + tokenInput.Text = manager.GetString("AuthenticationToken", string.Empty, "AuthToken", "Token"); + readOnlyToggle.isOn = manager.GetBool("ReadOnly", true, "ReadOnlyMode"); + dangerousOperationsToggle.isOn = manager.GetBool("AllowDangerousOperations", false, "DangerousOperationsEnabled"); + requestLoggingToggle.isOn = manager.GetBool("RequestLoggingEnabled", true, "LogRequests"); + } + finally + { + updatingControls = false; + } + RefreshAgentConfigText(); + } + + private void OnEnabledChanged(bool value) + { + manager.Set("Enabled", value); + manager.SaveSettings(); + RefreshRuntimeState(); + } + + private void OnBindAddressEndEdit(string value) + { + string address = IsBlank(value) ? "127.0.0.1" : value.Trim(); + bindAddressInput.Text = address; + manager.Set("BindAddress", address); + manager.SaveSettings(); + RefreshAgentConfigText(); + RefreshRuntimeState(); + } + + private void OnPortEndEdit(string value) + { + if (!int.TryParse(value, out int port) || port < 1 || port > 65535) + { + port = manager.GetInt("Port", 17891); + portInput.Text = port.ToString(); + SetStatus("Invalid port. Enter a value from 1 to 65535.", new Color(1f, 0.5f, 0.35f)); + return; + } + + manager.Set("Port", port); + manager.SaveSettings(); + RefreshAgentConfigText(); + RefreshRuntimeState(); + } + + private void OnTransportModeChanged(int value) + { + if (updatingControls) + return; + + string mode = value == 1 ? "StreamableHTTP" : "SSE"; + manager.Set("TransportMode", mode, "HttpTransportMode", "Transport"); + manager.SaveSettings(); + RefreshAgentConfigText(); + RefreshRuntimeState(); + + if (manager.GetBool("IsRunning", false, "Running")) + SetStatus("Transport changed to " + GetTransportDisplayName() + ". Restart MCP to apply it.", new Color(1f, 0.75f, 0.3f)); + } + private void OnTokenEndEdit(string value) + { + manager.Set("AuthenticationToken", value ?? string.Empty, "AuthToken", "Token"); + manager.SaveSettings(); + RefreshAgentConfigText(); + } + + private void OnReadOnlyChanged(bool value) + { + manager.Set("ReadOnly", value, "ReadOnlyMode"); + manager.SaveSettings(); + } + + private void OnDangerousOperationsChanged(bool value) + { + manager.Set("AllowDangerousOperations", value, "DangerousOperationsEnabled"); + manager.SaveSettings(); + } + + private void OnRequestLoggingChanged(bool value) + { + manager.Set("RequestLoggingEnabled", value, "LogRequests"); + manager.SaveSettings(); + RefreshRuntimeState(); + } + + private void GenerateToken() + { + string token = manager.GenerateAuthenticationToken(); + if (string.IsNullOrEmpty(token)) + token = GenerateLocalToken(); + + tokenInput.Text = token; + manager.Set("AuthenticationToken", token, "AuthToken", "Token"); + manager.SaveSettings(); + RefreshAgentConfigText(); + SetStatus("A new authentication token was generated.", new Color(0.45f, 0.9f, 0.5f)); + } + + private void CopyToken() + { + GUIUtility.systemCopyBuffer = tokenInput.Text ?? string.Empty; + SetStatus("Authentication token copied. Treat clipboard contents as sensitive.", new Color(1f, 0.68f, 0.32f)); + } + + private void CopyAgentConfig() + { + if (IsBlank(tokenInput?.Text)) + { + SetStatus("Generate or enter a token before copying the Agent configuration.", new Color(1f, 0.6f, 0.3f)); + return; + } + + string config = BuildAgentConfig(); + GUIUtility.systemCopyBuffer = config; + SetStatus( + "Sensitive direct MCP config copied. It contains the MCP token; do not paste it into logs, chat, or source control.", + new Color(1f, 0.68f, 0.32f)); + } + + private void RefreshAgentConfigText() + { + if (agentConfigInput != null) + agentConfigInput.Text = BuildAgentConfig(); + } + + private string BuildAgentConfig() + { + string address = IsBlank(bindAddressInput?.Text) ? "127.0.0.1" : bindAddressInput.Text.Trim(); + string port = IsBlank(portInput?.Text) ? "17891" : portInput.Text.Trim(); + string token = IsBlank(tokenInput?.Text) ? "" : tokenInput.Text; + string endpoint = "http://" + address + ":" + port + "/mcp"; + + string traeType = IsStreamableHttpSelected() ? "streamableHttp" : "sse"; + + StringBuilder json = new StringBuilder(480); + json.AppendLine("{"); + json.AppendLine(" \"mcpServers\": {"); + json.AppendLine(" \"cue-mcp\": {"); + json.Append(" \"type\": \"").Append(traeType).AppendLine("\","); + json.Append(" \"url\": \"").Append(EscapeJson(endpoint)).AppendLine("\","); + json.AppendLine(" \"headers\": {"); + json.Append(" \"Authorization\": \"Bearer ").Append(EscapeJson(token)).AppendLine("\""); + json.AppendLine(" }"); + json.AppendLine(" }"); + json.AppendLine(" }"); + json.Append("}"); + return json.ToString(); + } + + private bool IsStreamableHttpSelected() => transportModeDropdown != null && transportModeDropdown.value == 1; + + private string GetTransportDisplayName() => IsStreamableHttpSelected() ? "Streamable HTTP" : "SSE"; + + private static bool IsStreamableHttpMode(string value) + { + if (string.IsNullOrEmpty(value)) + return false; + + string normalized = value.Replace("-", string.Empty).Replace("_", string.Empty).Replace(" ", string.Empty); + return normalized.Equals("StreamableHTTP", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("StreamableHttp", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("HTTP", StringComparison.OrdinalIgnoreCase); + } + private static string EscapeJson(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + StringBuilder escaped = new StringBuilder(value.Length + 8); + for (int i = 0; i < value.Length; i++) + { + char character = value[i]; + switch (character) + { + case '\\': escaped.Append("\\\\"); break; + case '"': escaped.Append("\\\""); break; + case '\b': escaped.Append("\\b"); break; + case '\f': escaped.Append("\\f"); break; + case '\n': escaped.Append("\\n"); break; + case '\r': escaped.Append("\\r"); break; + case '\t': escaped.Append("\\t"); break; + default: + if (character < 0x20) + escaped.Append("\\u").Append(((int)character).ToString("x4")); + else + escaped.Append(character); + break; + } + } + return escaped.ToString(); + } + + private void StartServer() + { + OnBindAddressEndEdit(bindAddressInput.Text); + OnPortEndEdit(portInput.Text); + OnTokenEndEdit(tokenInput.Text); + + if (!enabledToggle.isOn) + { + SetStatus("Enable MCP before starting the server.", new Color(1f, 0.65f, 0.3f)); + return; + } + + if (IsBlank(tokenInput.Text)) + { + SetStatus("Generate or enter an authentication token before starting MCP.", new Color(1f, 0.5f, 0.35f)); + return; + } + + if (!manager.IsAvailable) + { + SetStatus("MCP manager is not available. The server module may not be loaded.", new Color(1f, 0.5f, 0.35f)); + return; + } + + try + { + manager.SaveSettings(); + manager.Start(); + RefreshRuntimeState(); + } + catch (Exception ex) + { + SetStatus("Failed to start MCP: " + ex.Message, new Color(1f, 0.4f, 0.35f)); + ExplorerCore.LogWarning("Failed to start MCP server: " + ex); + } + } + + private void StopServer() + { + try + { + manager.Stop(); + RefreshRuntimeState(); + } + catch (Exception ex) + { + SetStatus("Failed to stop MCP: " + ex.Message, new Color(1f, 0.4f, 0.35f)); + ExplorerCore.LogWarning("Failed to stop MCP server: " + ex); + } + } + + private void RefreshRuntimeState() + { + if (statusText == null) + return; + + bool available = manager.IsAvailable; + bool running = available && manager.GetBool("IsRunning", false, "Running"); + bool restartRequired = available && manager.GetBool("RestartRequired", false); + string managerStatus = manager.GetString("StatusText", string.Empty, "Status", "State"); + string lastError = manager.GetString("LastErrorMessage", string.Empty, "ErrorMessage"); + string bindAddress = IsBlank(bindAddressInput?.Text) ? "127.0.0.1" : bindAddressInput.Text.Trim(); + string port = IsBlank(portInput?.Text) ? "17891" : portInput.Text.Trim(); + + if (!available) + SetStatus("MCP manager unavailable", new Color(1f, 0.5f, 0.35f)); + else if (!string.IsNullOrEmpty(lastError)) + SetStatus("Faulted: " + lastError, new Color(1f, 0.4f, 0.35f)); + else if (running) + { + string runningStatus = string.IsNullOrEmpty(managerStatus) ? "Running" : managerStatus; + if (restartRequired) + runningStatus += " (restart required to apply changed settings)"; + SetStatus(runningStatus, restartRequired ? new Color(1f, 0.75f, 0.3f) : new Color(0.45f, 0.95f, 0.5f)); + } + else + SetStatus(string.IsNullOrEmpty(managerStatus) ? "Stopped" : managerStatus, Color.grey); + + string reportedEndpoint = manager.GetString("RpcEndpoint", string.Empty, "Endpoint"); + endpointText.text = "Endpoint: " + (string.IsNullOrEmpty(reportedEndpoint) + ? "http://" + bindAddress + ":" + port + "/mcp" + : reportedEndpoint); + endpointText.color = bindAddress == "127.0.0.1" || bindAddress.Equals("localhost", StringComparison.OrdinalIgnoreCase) + ? new Color(0.65f, 0.85f, 1f) + : new Color(1f, 0.55f, 0.35f); + + startButton.Component.interactable = available && enabledToggle.isOn && !running; + stopButton.Component.interactable = available && running; + + if (requestLoggingToggle.isOn) + requestLogInput.Text = manager.GetRecentRequestLog(); + else + requestLogInput.Text = "Request logging is disabled."; + } + + private void SetStatus(string value, Color color) + { + if (statusText == null) + return; + + statusText.text = "Status: " + value; + statusText.color = color; + } + + private static bool IsBlank(string value) => string.IsNullOrEmpty(value) || value.Trim().Length == 0; + + private static string GenerateLocalToken() + { + byte[] bytes = new byte[32]; + RandomNumberGenerator generator = RandomNumberGenerator.Create(); + generator.GetBytes(bytes); + + StringBuilder result = new(bytes.Length * 2); + for (int i = 0; i < bytes.Length; i++) + result.Append(bytes[i].ToString("x2")); + return result.ToString(); + } + + /// + /// Late-bound adapter for the MCP module. Expected primary API: + /// UnityExplorer.MCP.McpManager.Instance, mutable settings properties, IsRunning, + /// StatusText, RecentRequests, GenerateAuthenticationToken(), SaveSettings(), + /// Start(), and Stop(). Compatibility aliases are intentionally accepted. + /// + private sealed class McpManagerBridge + { + private static readonly string[] ManagerTypeNames = + { + "UnityExplorer.MCP.McpManager", + "UnityExplorer.Mcp.McpManager", + "CinematicUnityExplorer.MCP.McpManager" + }; + + private Type managerType; + private object managerInstance; + private bool resolutionAttempted; + + public bool IsAvailable + { + get + { + Resolve(); + return managerType != null; + } + } + + public bool GetBool(string name, bool fallback, params string[] aliases) + { + object value = Get(name, aliases); + return value is bool result ? result : fallback; + } + + public int GetInt(string name, int fallback, params string[] aliases) + { + object value = Get(name, aliases); + if (value is int result) + return result; + return value != null && int.TryParse(value.ToString(), out result) ? result : fallback; + } + + public string GetString(string name, string fallback, params string[] aliases) + { + object value = Get(name, aliases); + return value?.ToString() ?? fallback; + } + + public object Get(string name, params string[] aliases) + { + Resolve(); + if (managerType == null) + return null; + + foreach (string candidate in Names(name, aliases)) + { + PropertyInfo property = managerType.GetProperty(candidate, MemberFlags); + if (property != null && property.CanRead) + return property.GetValue(IsStatic(property.GetGetMethod(true)) ? null : managerInstance, null); + + FieldInfo field = managerType.GetField(candidate, MemberFlags); + if (field != null) + return field.GetValue(field.IsStatic ? null : managerInstance); + } + + return GetConfigValue(name, aliases); + } + + public void Set(string name, object value, params string[] aliases) + { + Resolve(); + if (managerType == null) + return; + + foreach (string candidate in Names(name, aliases)) + { + PropertyInfo property = managerType.GetProperty(candidate, MemberFlags); + if (property != null && property.CanWrite) + { + property.SetValue(IsStatic(property.GetSetMethod(true)) ? null : managerInstance, ConvertValue(value, property.PropertyType), null); + return; + } + + FieldInfo field = managerType.GetField(candidate, MemberFlags); + if (field != null && !field.IsInitOnly) + { + field.SetValue(field.IsStatic ? null : managerInstance, ConvertValue(value, field.FieldType)); + return; + } + } + + SetConfigValue(name, value, aliases); + } + + public string GenerateAuthenticationToken() + { + object value = Invoke("GenerateAuthenticationToken", "GenerateToken"); + return value?.ToString(); + } + + public void SaveSettings() + { + if (!TryInvoke("SaveSettings", "SaveConfig")) + SaveConfigFallback(); + } + public void Start() => Invoke("Start", "StartServer"); + public void Stop() => Invoke("Stop", "StopServer"); + + public string GetRecentRequestLog() + { + object value = Get("RecentRequests", "RequestLog", "RecentRequestLog"); + if (value == null) + { + int pending = GetInt("PendingRequestCount", 0); + return pending > 0 + ? "No detailed request log is exposed by the MCP manager. Pending requests: " + pending + : "No MCP requests have been logged."; + } + + if (value is string text) + return string.IsNullOrEmpty(text) ? "No MCP requests have been logged." : text; + + if (value is IEnumerable entries) + { + StringBuilder builder = new(); + foreach (object entry in entries) + { + if (builder.Length > 0) + builder.AppendLine(); + builder.Append(entry); + } + return builder.Length == 0 ? "No MCP requests have been logged." : builder.ToString(); + } + + return value.ToString(); + } + + private bool TryInvoke(string name, params string[] aliases) + { + Resolve(); + if (managerType == null) + return false; + + foreach (string candidate in Names(name, aliases)) + { + MethodInfo method = managerType.GetMethod(candidate, MemberFlags, null, Type.EmptyTypes, null); + if (method == null) + continue; + + method.Invoke(method.IsStatic ? null : managerInstance, null); + return true; + } + + return false; + } + + private object GetConfigValue(string name, string[] aliases) + { + string fieldName = GetConfigFieldName(name, aliases); + if (fieldName == null) + return null; + + Type configType = FindType("UnityExplorer.Config.ConfigManager"); + FieldInfo field = configType?.GetField(fieldName, BindingFlags.Public | BindingFlags.Static); + object configElement = field?.GetValue(null); + PropertyInfo valueProperty = configElement?.GetType().GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + return valueProperty?.GetValue(configElement, null); + } + + private void SetConfigValue(string name, object value, string[] aliases) + { + string fieldName = GetConfigFieldName(name, aliases); + if (fieldName == null) + return; + + Type configType = FindType("UnityExplorer.Config.ConfigManager"); + FieldInfo field = configType?.GetField(fieldName, BindingFlags.Public | BindingFlags.Static); + object configElement = field?.GetValue(null); + PropertyInfo valueProperty = configElement?.GetType().GetProperty("Value", BindingFlags.Public | BindingFlags.Instance); + if (valueProperty != null && valueProperty.CanWrite) + valueProperty.SetValue(configElement, ConvertValue(value, valueProperty.PropertyType), null); + } + + private void SaveConfigFallback() + { + Type configType = FindType("UnityExplorer.Config.ConfigManager"); + PropertyInfo handlerProperty = configType?.GetProperty("Handler", BindingFlags.Public | BindingFlags.Static); + object handler = handlerProperty?.GetValue(null, null); + MethodInfo saveMethod = handler?.GetType().GetMethod("SaveConfig", BindingFlags.Public | BindingFlags.Instance); + saveMethod?.Invoke(handler, null); + } + + private static string GetConfigFieldName(string primary, string[] aliases) + { + foreach (string name in Names(primary, aliases)) + { + switch (name) + { + case "Enabled": return "MCP_Enabled"; + case "BindAddress": return "MCP_Bind_Address"; + case "Port": return "MCP_Port"; + case "TransportMode": + case "HttpTransportMode": + case "Transport": return "MCP_Transport_Mode"; + case "AuthenticationToken": + case "AuthToken": + case "Token": return "MCP_Auth_Token"; + case "ReadOnly": + case "ReadOnlyMode": return "MCP_Read_Only"; + case "AllowDangerousOperations": + case "DangerousOperationsEnabled": return "MCP_Allow_Dangerous_Operations"; + case "RequestLoggingEnabled": + case "LogRequests": return "MCP_Request_Logging"; + } + } + + return null; + } + + private static object ConvertValue(object value, Type targetType) + { + if (value == null || targetType == null || targetType.IsInstanceOfType(value)) + return value; + + Type nullableType = Nullable.GetUnderlyingType(targetType); + if (nullableType != null) + targetType = nullableType; + + if (targetType.IsEnum) + return Enum.Parse(targetType, value.ToString(), true); + + if (targetType == typeof(string)) + return value.ToString(); + + return Convert.ChangeType(value, targetType); + } + private static Type FindType(string fullName) + { + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length; i++) + { + Type type = assemblies[i].GetType(fullName, false); + if (type != null) + return type; + } + return null; + } + + private object Invoke(string name, params string[] aliases) + { + Resolve(); + if (managerType == null) + return null; + + foreach (string candidate in Names(name, aliases)) + { + MethodInfo method = managerType.GetMethod(candidate, MemberFlags, null, Type.EmptyTypes, null); + if (method != null) + return method.Invoke(method.IsStatic ? null : managerInstance, null); + } + + return null; + } + + private void Resolve() + { + if (resolutionAttempted && managerType != null) + return; + + resolutionAttempted = true; + Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < assemblies.Length && managerType == null; i++) + { + for (int j = 0; j < ManagerTypeNames.Length && managerType == null; j++) + managerType = assemblies[i].GetType(ManagerTypeNames[j], false); + } + + if (managerType == null) + return; + + PropertyInfo instanceProperty = managerType.GetProperty("Instance", MemberFlags); + if (instanceProperty != null && instanceProperty.CanRead) + managerInstance = instanceProperty.GetValue(null, null); + + if (managerInstance == null) + { + FieldInfo instanceField = managerType.GetField("Instance", MemberFlags); + if (instanceField != null) + managerInstance = instanceField.GetValue(null); + } + } + + private static IEnumerable Names(string primary, string[] aliases) + { + yield return primary; + if (aliases == null) + yield break; + for (int i = 0; i < aliases.Length; i++) + yield return aliases[i]; + } + + private static bool IsStatic(MethodInfo method) => method != null && method.IsStatic; + + private const BindingFlags MemberFlags = + BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; + } + } +} diff --git a/src/UI/UIManager.cs b/src/UI/UIManager.cs index cc0ddf6f0..e39b56c48 100644 --- a/src/UI/UIManager.cs +++ b/src/UI/UIManager.cs @@ -1,4 +1,4 @@ -using UnityExplorer.Config; +using UnityExplorer.Config; using UnityExplorer.CSConsole; using UnityExplorer.Inspectors; using UnityExplorer.UI.Panels; @@ -28,6 +28,7 @@ public enum Panels PostProcessingPanel, AnimatorPanel, Misc, + Mcp, } public enum VerticalAnchor @@ -102,6 +103,7 @@ internal static void InitUI() UIPanels.Add(Panels.PostProcessingPanel, new PostProcessingPanel(UiBase)); UIPanels.Add(Panels.AnimatorPanel, new AnimatorPanel(UiBase)); UIPanels.Add(Panels.Misc, new UnityExplorer.UI.Panels.Misc(UiBase)); + UIPanels.Add(Panels.Mcp, new McpPanel(UiBase)); UIPanels.Add(Panels.Options, new OptionsPanel(UiBase)); UIPanels.Add(Panels.UIInspectorResults, new MouseInspectorResultsPanel(UiBase)); From 5131e27dd05aaa50a753bd6a8490dd5edf57aeb8 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Tue, 1 Sep 2026 09:02:38 +0800 Subject: [PATCH 2/2] fix:Fix the spacing issue of the tab bar --- src/UI/UIManager.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/UI/UIManager.cs b/src/UI/UIManager.cs index e39b56c48..6cbce8781 100644 --- a/src/UI/UIManager.cs +++ b/src/UI/UIManager.cs @@ -50,7 +50,10 @@ public enum VerticalAnchor public static RectTransform NavBarRect; public static GameObject NavbarTabButtonHolder; - private static readonly Vector2 NAVBAR_DIMENSIONS = new(1610f, 35f); + // Reserve one standard tab width plus spacing for the built-in MCP panel. + // Without this, the fixed-width navbar compresses/overlaps tab content and the + // gaps between some buttons appear to disappear. + private static readonly Vector2 NAVBAR_DIMENSIONS = new(1695f, 35f); private static ButtonRef closeBtn; private static TimeScaleWidget timeScaleWidget;